diff --git a/CODEOWNERS b/CODEOWNERS
index 45dfce187bf..750a2e8bf9a 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -27,6 +27,7 @@
/bundles/org.openhab.binding.bluetooth.bluez/ @cdjackson @kaikreuzer
/bundles/org.openhab.binding.bluetooth.blukii/ @kaikreuzer
/bundles/org.openhab.binding.bluetooth.daikinmadoka/ @blafois
+/bundles/org.openhab.binding.bluetooth.generic/ @cpmeister
/bundles/org.openhab.binding.bluetooth.roaming/ @cpmeister
/bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen
/bundles/org.openhab.binding.boschindego/ @jofleck
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index a64b92e1ca5..b235f328d21 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -126,6 +126,11 @@
org.openhab.binding.bluetooth.daikinmadoka
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.bluetooth.generic
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.bluetooth.roaming
diff --git a/bundles/org.openhab.binding.bluetooth.generic/NOTICE b/bundles/org.openhab.binding.bluetooth.generic/NOTICE
new file mode 100644
index 00000000000..dbc0cfc144e
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/NOTICE
@@ -0,0 +1,20 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+Vlad Kolotov
+* License: Apache 2.0 License
+* Project: https://github.com/sputnikdev/bluetooth-gatt-parser
+* Source: https://github.com/sputnikdev/bluetooth-gatt-parser
diff --git a/bundles/org.openhab.binding.bluetooth.generic/README.md b/bundles/org.openhab.binding.bluetooth.generic/README.md
new file mode 100644
index 00000000000..4b6be9af5dd
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/README.md
@@ -0,0 +1,33 @@
+# Generic Bluetooth Device
+
+This binding adds support for devices that expose [Bluetooth Generic Attributes (GATT)](https://www.bluetooth.com/specifications/gatt/)
+
+## Supported Things
+
+Only a single thing type is added by this binding:
+
+| Thing Type ID | Description |
+|---------------|-------------------------------------------------|
+| generic | A generic connectable bluetooth device |
+
+## Discovery
+
+As any other Bluetooth device, generic bluetooth devices are discovered automatically by the corresponding bridge.
+Generic bluetooth devices will be discovered for any connectable bluetooth device that doesn't match another bluetooth binding.
+
+## Thing Configuration
+
+| Parameter | Required | Default | Description |
+|-----------------|----------|---------|---------------------------------------------------------------------|
+| address | yes | | The address of the bluetooth device (in format "XX:XX:XX:XX:XX:XX") |
+| pollingInterval | no | 30 | The frequency at which readable characteristics will refresh |
+
+## Channels
+
+Channels will be dynamically created based on types of characteristics the device supports.
+This binding contains a mostly complete database of standardized GATT services and characteristics
+that is used to map characteristics to one or multiple channels.
+
+Characteristics not in the database will be mapped to a single `String` channel labeled `Unknown`.
+The data visible from unknown channels will be the raw binary data formated as hexadecimal.
+Data written (if the unknown characteristic has write support) to unknown channels must likewise be in hexadecimal.
diff --git a/bundles/org.openhab.binding.bluetooth.generic/pom.xml b/bundles/org.openhab.binding.bluetooth.generic/pom.xml
new file mode 100644
index 00000000000..488e91fb138
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/pom.xml
@@ -0,0 +1,44 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.0.0-SNAPSHOT
+
+
+ org.openhab.binding.bluetooth.generic
+
+ openHAB Add-ons :: Bundles :: Generic Bluetooth Adapter
+
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.bluetooth
+ ${project.version}
+ provided
+
+
+ org.sputnikdev
+ bluetooth-gatt-parser
+ 1.9.4
+ compile
+
+
+ commons-collections
+ commons-collections
+ 3.2.2
+ compile
+
+
+ commons-beanutils
+ commons-beanutils
+ 1.9.3
+ compile
+
+
+
+
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/feature/feature.xml b/bundles/org.openhab.binding.bluetooth.generic/src/main/feature/feature.xml
new file mode 100644
index 00000000000..0d0057915fa
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/feature/feature.xml
@@ -0,0 +1,24 @@
+
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtils.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtils.java
new file mode 100644
index 00000000000..47ac1f01c4b
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtils.java
@@ -0,0 +1,221 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sputnikdev.bluetooth.gattparser.BluetoothGattParser;
+import org.sputnikdev.bluetooth.gattparser.FieldHolder;
+import org.sputnikdev.bluetooth.gattparser.GattRequest;
+import org.sputnikdev.bluetooth.gattparser.spec.Enumeration;
+import org.sputnikdev.bluetooth.gattparser.spec.Field;
+import org.sputnikdev.bluetooth.gattparser.spec.FieldFormat;
+import org.sputnikdev.bluetooth.gattparser.spec.FieldType;
+
+/**
+ * The {@link BluetoothChannelUtils} contains utility functions used by the GattChannelHandler
+ *
+ * @author Vlad Kolotov - Original author
+ * @author Connor Petty - Modified for openHAB use
+ */
+@NonNullByDefault
+public class BluetoothChannelUtils {
+
+ private static final Logger logger = LoggerFactory.getLogger(BluetoothChannelUtils.class);
+
+ public static String encodeFieldID(Field field) {
+ String requirements = Optional.ofNullable(field.getRequirements()).orElse(Collections.emptyList()).stream()
+ .collect(Collectors.joining());
+ return encodeFieldName(field.getName() + requirements);
+ }
+
+ public static String encodeFieldName(String fieldName) {
+ return Base64.getEncoder().encodeToString(fieldName.getBytes(StandardCharsets.UTF_8)).replace("=", "");
+ }
+
+ public static String decodeFieldName(String encodedFieldName) {
+ return new String(Base64.getDecoder().decode(encodedFieldName), StandardCharsets.UTF_8);
+ }
+
+ public static @Nullable String getItemType(Field field) {
+ FieldFormat format = field.getFormat();
+ if (format == null) {
+ // unknown format
+ return null;
+ }
+ switch (field.getFormat().getType()) {
+ case BOOLEAN:
+ return "Switch";
+ case UINT:
+ case SINT:
+ case FLOAT_IEE754:
+ case FLOAT_IEE11073:
+ BluetoothUnit unit = BluetoothUnit.findByType(field.getUnit());
+ if (unit != null) {
+ // TODO
+ // return "Number:" + unit.getUnit().getDimension();
+ }
+ return "Number";
+ case UTF8S:
+ case UTF16S:
+ return "String";
+ case STRUCT:
+ return "String";
+ // unsupported format
+ default:
+ return null;
+ }
+ }
+
+ public static State convert(BluetoothGattParser parser, FieldHolder holder) {
+ State state;
+ if (holder.isValueSet()) {
+ if (holder.getField().getFormat().isBoolean()) {
+ state = OnOffType.from(Boolean.TRUE.equals(holder.getBoolean()));
+ } else {
+ // check if we can use enumerations
+ if (holder.getField().hasEnumerations()) {
+ Enumeration enumeration = holder.getEnumeration();
+ if (enumeration != null) {
+ if (holder.getField().getFormat().isNumber()) {
+ return new DecimalType(new BigDecimal(enumeration.getKey()));
+ } else {
+ return new StringType(enumeration.getKey().toString());
+ }
+ }
+ // fall back to simple types
+ }
+ if (holder.getField().getFormat().isNumber()) {
+ state = new DecimalType(holder.getBigDecimal());
+ } else if (holder.getField().getFormat().isStruct()) {
+ state = new StringType(parser.parse(holder.getBytes(), 16));
+ } else {
+ state = new StringType(holder.getString());
+ }
+ }
+ } else {
+ state = UnDefType.UNDEF;
+ }
+ return state;
+ }
+
+ public static void updateHolder(BluetoothGattParser parser, GattRequest request, String fieldName, State state) {
+ Field field = request.getFieldHolder(fieldName).getField();
+ FieldType fieldType = field.getFormat().getType();
+ if (fieldType == FieldType.BOOLEAN) {
+ OnOffType onOffType = convert(state, OnOffType.class);
+ if (onOffType == null) {
+ logger.debug("Could not convert state to OnOffType: {} : {} : {} ", request.getCharacteristicUUID(),
+ fieldName, state);
+ return;
+ }
+ request.setField(fieldName, onOffType == OnOffType.ON);
+ return;
+ }
+ if (field.hasEnumerations()) {
+ // check if we can use enumerations
+ Enumeration enumeration = getEnumeration(field, state);
+ if (enumeration != null) {
+ request.setField(fieldName, enumeration);
+ return;
+ } else {
+ logger.debug("Could not convert state to enumeration: {} : {} : {} ", request.getCharacteristicUUID(),
+ fieldName, state);
+ }
+ // fall back to simple types
+ }
+ switch (fieldType) {
+ case UINT:
+ case SINT: {
+ DecimalType decimalType = convert(state, DecimalType.class);
+ if (decimalType == null) {
+ logger.debug("Could not convert state to DecimalType: {} : {} : {} ",
+ request.getCharacteristicUUID(), fieldName, state);
+ return;
+ }
+ request.setField(fieldName, decimalType.longValue());
+ return;
+ }
+ case FLOAT_IEE754:
+ case FLOAT_IEE11073: {
+ DecimalType decimalType = convert(state, DecimalType.class);
+ if (decimalType == null) {
+ logger.debug("Could not convert state to DecimalType: {} : {} : {} ",
+ request.getCharacteristicUUID(), fieldName, state);
+ return;
+ }
+ request.setField(fieldName, decimalType.doubleValue());
+ return;
+ }
+ case UTF8S:
+ case UTF16S: {
+ StringType textType = convert(state, StringType.class);
+ if (textType == null) {
+ logger.debug("Could not convert state to StringType: {} : {} : {} ",
+ request.getCharacteristicUUID(), fieldName, state);
+ return;
+ }
+ request.setField(fieldName, textType.toString());
+ return;
+ }
+ case STRUCT:
+ StringType textType = convert(state, StringType.class);
+ if (textType == null) {
+ logger.debug("Could not convert state to StringType: {} : {} : {} ",
+ request.getCharacteristicUUID(), fieldName, state);
+ return;
+ }
+ String text = textType.toString().trim();
+ if (text.startsWith("[")) {
+ request.setField(fieldName, parser.serialize(text, 16));
+ } else {
+ request.setField(fieldName, new BigInteger(text));
+ }
+ return;
+ // unsupported format
+ default:
+ return;
+ }
+ }
+
+ private static @Nullable Enumeration getEnumeration(Field field, State state) {
+ DecimalType decimalType = convert(state, DecimalType.class);
+ if (decimalType != null) {
+ try {
+ return field.getEnumeration(new BigInteger(decimalType.toString()));
+ } catch (NumberFormatException ex) {
+ // do nothing
+ }
+ }
+ return null;
+ }
+
+ private static @Nullable T convert(State state, Class typeClass) {
+ return state.as(typeClass);
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnit.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnit.java
new file mode 100644
index 00000000000..3ad8494e6e6
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnit.java
@@ -0,0 +1,363 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.math.BigInteger;
+import java.util.UUID;
+
+import javax.measure.Quantity;
+import javax.measure.Unit;
+import javax.measure.quantity.Angle;
+import javax.measure.quantity.Area;
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.ElectricCharge;
+import javax.measure.quantity.Frequency;
+import javax.measure.quantity.Length;
+import javax.measure.quantity.Mass;
+import javax.measure.quantity.Speed;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.core.library.dimension.ArealDensity;
+import org.openhab.core.library.dimension.VolumetricFlowRate;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.MetricPrefix;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.SmartHomeUnits;
+
+import tec.uom.se.format.SimpleUnitFormat;
+import tec.uom.se.function.MultiplyConverter;
+import tec.uom.se.function.PiMultiplierConverter;
+import tec.uom.se.function.RationalConverter;
+import tec.uom.se.unit.ProductUnit;
+import tec.uom.se.unit.TransformedUnit;
+import tec.uom.se.unit.Units;
+
+/**
+ * The {@link BluetoothUnit} maps bluetooth units to openHAB units.
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+public enum BluetoothUnit {
+
+ UNITLESS(0x2700, "org.bluetooth.unit.unitless", SmartHomeUnits.ONE),
+ METRE(0x2701, "org.bluetooth.unit.length.metre", SIUnits.METRE),
+ KILOGRAM(0x2702, "org.bluetooth.unit.mass.kilogram", SIUnits.KILOGRAM),
+ SECOND(0x2703, "org.bluetooth.unit.time.second", SmartHomeUnits.SECOND),
+ AMPERE(0x2704, "org.bluetooth.unit.electric_current.ampere", SmartHomeUnits.AMPERE),
+ KELVIN(0x2705, "org.bluetooth.unit.thermodynamic_temperature.kelvin", SmartHomeUnits.KELVIN),
+ MOLE(0x2706, "org.bluetooth.unit.amount_of_substance.mole", SmartHomeUnits.MOLE),
+ CANDELA(0x2707, "org.bluetooth.unit.luminous_intensity.candela", SmartHomeUnits.CANDELA),
+ SQUARE_METRES(0x2710, "org.bluetooth.unit.area.square_metres", SIUnits.SQUARE_METRE),
+ CUBIC_METRES(0x2711, "org.bluetooth.unit.volume.cubic_metres", SIUnits.CUBIC_METRE),
+ METRE_PER_SECOND(0x2712, "org.bluetooth.unit.velocity.metres_per_second", SmartHomeUnits.METRE_PER_SECOND),
+ METRE_PER_SQUARE_SECOND(0X2713, "org.bluetooth.unit.acceleration.metres_per_second_squared",
+ SmartHomeUnits.METRE_PER_SQUARE_SECOND),
+ WAVENUMBER(0x2714, "org.bluetooth.unit.wavenumber.reciprocal_metre", SmartHomeUnits.ONE),
+ KILOGRAM_PER_CUBIC_METRE(0x2715, "org.bluetooth.unit.density.kilogram_per_cubic_metre",
+ SmartHomeUnits.KILOGRAM_PER_CUBICMETRE),
+ KILOGRAM_PER_SQUARE_METRE(0x2716, "org.bluetooth.unit.surface_density.kilogram_per_square_metre",
+ BUnits.KILOGRAM_PER_SQUARE_METER),
+ CUBIC_METRE_PER_KILOGRAM(0x2717, "org.bluetooth.unit.specific_volume.cubic_metre_per_kilogram", SmartHomeUnits.ONE),
+ AMPERE_PER_SQUARE_METRE(0x2718, "org.bluetooth.unit.current_density.ampere_per_square_metre", SmartHomeUnits.ONE),
+ AMPERE_PER_METRE(0x2719, "org.bluetooth.unit.magnetic_field_strength.ampere_per_metre", SmartHomeUnits.ONE),
+ MOLE_PER_CUBIC_METRE(0x271A, "org.bluetooth.unit.amount_concentration.mole_per_cubic_metre", SmartHomeUnits.ONE),
+ CONCENTRATION_KILOGRAM_PER_CUBIC_METRE(0x271B, "org.bluetooth.unit.mass_concentration.kilogram_per_cubic_metre",
+ SmartHomeUnits.KILOGRAM_PER_CUBICMETRE),
+ CANDELA_PER_SQUARE_METRE(0x271C, "org.bluetooth.unit.luminance.candela_per_square_metre", SmartHomeUnits.ONE),
+ REFRACTIVE_INDEX(0x271D, "org.bluetooth.unit.refractive_index", SmartHomeUnits.ONE),
+ RELATIVE_PERMEABILITY(0x271E, "org.bluetooth.unit.relative_permeability", SmartHomeUnits.ONE),
+ RADIAN(0x2720, "org.bluetooth.unit.plane_angle.radian", SmartHomeUnits.RADIAN),
+ STERADIAN(0x2721, "org.bluetooth.unit.solid_angle.steradian", SmartHomeUnits.STERADIAN),
+ HERTZ(0x2722, "org.bluetooth.unit.frequency.hertz", SmartHomeUnits.HERTZ),
+ NEWTON(0x2723, "org.bluetooth.unit.force.newton", SmartHomeUnits.NEWTON),
+ PASCAL(0x2724, "org.bluetooth.unit.pressure.pascal", SIUnits.PASCAL),
+ JOULE(0x2725, "org.bluetooth.unit.energy.joule", SmartHomeUnits.JOULE),
+ WATT(0x2726, "org.bluetooth.unit.power.watt", SmartHomeUnits.WATT),
+ COULOMB(0x2727, "org.bluetooth.unit.electric_charge.coulomb", SmartHomeUnits.COULOMB),
+ VOLT(0x2728, "org.bluetooth.unit.electric_potential_difference.volt", SmartHomeUnits.VOLT),
+ FARAD(0x2729, "org.bluetooth.unit.capacitance.farad", SmartHomeUnits.FARAD),
+ OHM(0x272A, "org.bluetooth.unit.electric_resistance.ohm", SmartHomeUnits.OHM),
+ SIEMENS(0x272B, "org.bluetooth.unit.electric_conductance.siemens", SmartHomeUnits.SIEMENS),
+ WEBER(0x272C, "org.bluetooth.unit.magnetic_flux.weber", SmartHomeUnits.WEBER),
+ TESLA(0x272D, "org.bluetooth.unit.magnetic_flux_density.tesla", SmartHomeUnits.TESLA),
+ HENRY(0x272E, "org.bluetooth.unit.inductance.henry", SmartHomeUnits.HENRY),
+ DEGREE_CELSIUS(0x272F, "org.bluetooth.unit.thermodynamic_temperature.degree_celsius", SIUnits.CELSIUS),
+ LUMEN(0x2730, "org.bluetooth.unit.luminous_flux.lumen", SmartHomeUnits.LUMEN),
+ LUX(0x2731, "org.bluetooth.unit.illuminance.lux", SmartHomeUnits.LUX),
+ BECQUEREL(0x2732, "org.bluetooth.unit.activity_referred_to_a_radionuclide.becquerel", SmartHomeUnits.BECQUEREL),
+ GRAY(0x2733, "org.bluetooth.unit.absorbed_dose.gray", SmartHomeUnits.GRAY),
+ SIEVERT(0x2734, "org.bluetooth.unit.dose_equivalent.sievert", SmartHomeUnits.SIEVERT),
+ KATAL(0x2735, "org.bluetooth.unit.catalytic_activity.katal", SmartHomeUnits.KATAL),
+ PASCAL_SECOND(0x2740, "org.bluetooth.unit.dynamic_viscosity.pascal_second", SmartHomeUnits.ONE),
+ NEWTON_METRE(0x2741, "org.bluetooth.unit.moment_of_force.newton_metre", SmartHomeUnits.ONE),
+ NEWTON_PER_METRE(0x2742, "org.bluetooth.unit.surface_tension.newton_per_metre", SmartHomeUnits.ONE),
+ RADIAN_PER_SECOND(0x2743, "org.bluetooth.unit.angular_velocity.radian_per_second", SmartHomeUnits.ONE),
+ RADIAN_PER_SECOND_SQUARED(0x2744, "org.bluetooth.unit.angular_acceleration.radian_per_second_squared",
+ SmartHomeUnits.ONE),
+ FLUX_WATT_PER_SQUARE_METRE(0x2745, "org.bluetooth.unit.heat_flux_density.watt_per_square_metre",
+ SmartHomeUnits.ONE),
+ JOULE_PER_KELVIN(0x2746, "org.bluetooth.unit.heat_capacity.joule_per_kelvin", SmartHomeUnits.ONE),
+ JOULE_PER_KILOGRAM_KELVIN(0x2747, "org.bluetooth.unit.specific_heat_capacity.joule_per_kilogram_kelvin",
+ SmartHomeUnits.ONE),
+ JOULE_PER_KILOGRAM(0x2748, "org.bluetooth.unit.specific_energy.joule_per_kilogram", SmartHomeUnits.ONE),
+ WATT_PER_METRE_KELVIN(0x2749, "org.bluetooth.unit.thermal_conductivity.watt_per_metre_kelvin", SmartHomeUnits.ONE),
+ JOULE_PER_CUBIC_METRE(0x274A, "org.bluetooth.unit.energy_density.joule_per_cubic_metre", SmartHomeUnits.ONE),
+ VOLT_PER_METRE(0x274B, "org.bluetooth.unit.electric_field_strength.volt_per_metre", SmartHomeUnits.ONE),
+ CHARGE_DENSITY_COULOMB_PER_CUBIC_METRE(0x274C, "org.bluetooth.unit.electric_charge_density.coulomb_per_cubic_metre",
+ SmartHomeUnits.ONE),
+ CHARGE_DENSITY_COULOMB_PER_SQUARE_METRE(0x274D,
+ "org.bluetooth.unit.surface_charge_density.coulomb_per_square_metre", SmartHomeUnits.ONE),
+ FLUX_DENSITY_COULOMB_PER_SQUARE_METRE(0x274E, "org.bluetooth.unit.electric_flux_density.coulomb_per_square_metre",
+ SmartHomeUnits.ONE),
+ FARAD_PER_METRE(0x274F, "org.bluetooth.unit.permittivity.farad_per_metre", SmartHomeUnits.ONE),
+ HENRY_PER_METRE(0x2750, "org.bluetooth.unit.permeability.henry_per_metre", SmartHomeUnits.ONE),
+ JOULE_PER_MOLE(0x2751, "org.bluetooth.unit.molar_energy.joule_per_mole", SmartHomeUnits.ONE),
+ JOULE_PER_MOLE_KELVIN(0x2752, "org.bluetooth.unit.molar_entropy.joule_per_mole_kelvin", SmartHomeUnits.ONE),
+ COULOMB_PER_KILOGRAM(0x2753, "org.bluetooth.unit.exposure.coulomb_per_kilogram", SmartHomeUnits.ONE),
+ GRAY_PER_SECOND(0x2754, "org.bluetooth.unit.absorbed_dose_rate.gray_per_second", BUnits.GRAY_PER_SECOND),
+ WATT_PER_STERADIAN(0x2755, "org.bluetooth.unit.radiant_intensity.watt_per_steradian", BUnits.WATT_PER_STERADIAN),
+ WATT_PER_STERADIAN_PER_SQUARE_METRE(0x2756, "org.bluetooth.unit.radiance.watt_per_square_metre_steradian",
+ BUnits.WATT_PER_STERADIAN_PER_SQUARE_METRE),
+ KATAL_PER_CUBIC_METRE(0x2757, "org.bluetooth.unit.catalytic_activity_concentration.katal_per_cubic_metre",
+ SmartHomeUnits.ONE),
+ MINUTE(0x2760, "org.bluetooth.unit.time.minute", SmartHomeUnits.MINUTE),
+ HOUR(0x2761, "org.bluetooth.unit.time.hour", SmartHomeUnits.HOUR),
+ DAY(0x2762, "org.bluetooth.unit.time.day", SmartHomeUnits.DAY),
+ ANGLE_DEGREE(0x2763, "org.bluetooth.unit.plane_angle.degree", SmartHomeUnits.DEGREE_ANGLE),
+ ANGLE_MINUTE(0x2764, "org.bluetooth.unit.plane_angle.minute", BUnits.MINUTE_ANGLE),
+ ANGLE_SECOND(0x2765, "org.bluetooth.unit.plane_angle.second", BUnits.SECOND_ANGLE),
+ HECTARE(0x2766, "org.bluetooth.unit.area.hectare", BUnits.HECTARE),
+ LITRE(0x2767, "org.bluetooth.unit.volume.litre", SmartHomeUnits.LITRE),
+ TONNE(0x2768, "org.bluetooth.unit.mass.tonne", MetricPrefix.KILO(SIUnits.KILOGRAM)),
+ BAR(0x2780, "org.bluetooth.unit.pressure.bar", SmartHomeUnits.BAR),
+ MILLIMETRE_OF_MERCURY(0x2781, "org.bluetooth.unit.pressure.millimetre_of_mercury",
+ SmartHomeUnits.MILLIMETRE_OF_MERCURY),
+ ÅNGSTRÖM(0x2782, "org.bluetooth.unit.length.ångström", SmartHomeUnits.ONE),
+ NAUTICAL_MILE(0x2783, "org.bluetooth.unit.length.nautical_mile", BUnits.NAUTICAL_MILE),
+ BARN(0x2784, "org.bluetooth.unit.area.barn", BUnits.BARN),
+ KNOT(0x2785, "org.bluetooth.unit.velocity.knot", SmartHomeUnits.KNOT),
+ NEPER(0x2786, "org.bluetooth.unit.logarithmic_radio_quantity.neper", SmartHomeUnits.ONE),
+ BEL(0x2787, "org.bluetooth.unit.logarithmic_radio_quantity.bel", SmartHomeUnits.ONE),
+ YARD(0x27A0, "org.bluetooth.unit.length.yard", ImperialUnits.YARD),
+ PARSEC(0x27A1, "org.bluetooth.unit.length.parsec", SmartHomeUnits.ONE),
+ INCH(0x27A2, "org.bluetooth.unit.length.inch", ImperialUnits.INCH),
+ FOOT(0x27A3, "org.bluetooth.unit.length.foot", ImperialUnits.FOOT),
+ MILE(0x27A4, "org.bluetooth.unit.length.mile", ImperialUnits.MILE),
+ POUND_FORCE_PER_SQUARE_INCH(0x27A5, "org.bluetooth.unit.pressure.pound_force_per_square_inch", SmartHomeUnits.ONE),
+ KILOMETRE_PER_HOUR(0x27A6, "org.bluetooth.unit.velocity.kilometre_per_hour", SIUnits.KILOMETRE_PER_HOUR),
+ MILES_PER_HOUR(0x27A7, "org.bluetooth.unit.velocity.mile_per_hour", ImperialUnits.MILES_PER_HOUR),
+ REVOLUTION_PER_MINUTE(0x27A8, "org.bluetooth.unit.angular_velocity.revolution_per_minute",
+ BUnits.REVOLUTION_PER_MINUTE),
+ GRAM_CALORIE(0x27A9, "org.bluetooth.unit.energy.gram_calorie", SmartHomeUnits.ONE),
+ KILOGRAM_CALORIE(0x27AA, "org.bluetooth.unit.energy.kilogram_calorie", SmartHomeUnits.ONE),
+ KILOWATT_HOUR(0x27AB, "org.bluetooth.unit.energy.kilowatt_hour", SmartHomeUnits.KILOWATT_HOUR),
+ DEGREE_FAHRENHEIT(0x27AC, "org.bluetooth.unit.thermodynamic_temperature.degree_fahrenheit",
+ ImperialUnits.FAHRENHEIT),
+ PERCENTAGE(0x27AD, "org.bluetooth.unit.percentage", SmartHomeUnits.PERCENT),
+ PER_MILLE(0x27AE, "org.bluetooth.unit.per_mille", SmartHomeUnits.ONE),
+ BEATS_PER_MINUTE(0x27AF, "org.bluetooth.unit.period.beats_per_minute", BUnits.BEATS_PER_MINUTE),
+ AMPERE_HOURS(0x27B0, "org.bluetooth.unit.electric_charge.ampere_hours", BUnits.AMPERE_HOUR),
+ MILLIGRAM_PER_DECILITRE(0x27B1, "org.bluetooth.unit.mass_density.milligram_per_decilitre", SmartHomeUnits.ONE),
+ MILLIMOLE_PER_LITRE(0x27B2, "org.bluetooth.unit.mass_density.millimole_per_litre", SmartHomeUnits.ONE),
+ YEAR(0x27B3, "org.bluetooth.unit.time.year", SmartHomeUnits.YEAR),
+ MONTH(0x27B4, "org.bluetooth.unit.time.month", SmartHomeUnits.ONE),
+ COUNT_PER_CUBIC_METRE(0x27B5, "org.bluetooth.unit.concentration.count_per_cubic_metre", SmartHomeUnits.ONE),
+ WATT_PER_SQUARE_METRE(0x27B6, "org.bluetooth.unit.irradiance.watt_per_square_metre", SmartHomeUnits.IRRADIANCE),
+ MILLILITER_PER_KILOGRAM_PER_MINUTE(0x27B7, "org.bluetooth.unit.transfer_rate.milliliter_per_kilogram_per_minute",
+ SmartHomeUnits.ONE),
+ POUND(0x27B8, "org.bluetooth.unit.mass.pound", BUnits.POUND),
+ METABOLIC_EQUIVALENT(0x27B9, "org.bluetooth.unit.metabolic_equivalent", SmartHomeUnits.ONE),
+ STEP_PER_MINUTE(0x27BA, "org.bluetooth.unit.step_per_minute", BUnits.STEP_PER_MINUTE),
+ STROKE_PER_MINUTE(0x27BC, "org.bluetooth.unit.stroke_per_minute", BUnits.STROKE_PER_MINUTE),
+ KILOMETER_PER_MINUTE(0x27BD, "org.bluetooth.unit.velocity.kilometer_per_minute", BUnits.KILOMETRE_PER_MINUTE),
+ LUMEN_PER_WATT(0x27BE, "org.bluetooth.unit.luminous_efficacy.lumen_per_watt", BUnits.LUMEN_PER_WATT),
+ LUMEN_HOUR(0x27BF, "org.bluetooth.unit.luminous_energy.lumen_hour", BUnits.LUMEN_HOUR),
+ LUX_HOUR(0x27C0, "org.bluetooth.unit.luminous_exposure.lux_hour", BUnits.LUX_HOUR),
+ GRAM_PER_SECOND(0x27C1, "org.bluetooth.unit.mass_flow.gram_per_second", BUnits.GRAM_PER_SECOND),
+ LITRE_PER_SECOND(0x27C2, "org.bluetooth.unit.volume_flow.litre_per_second", BUnits.LITRE_PER_SECOND),
+ DECIBEL_SPL(0x27C3, "org.bluetooth.unit.sound_pressure.decibel_spl", SmartHomeUnits.ONE),
+ PARTS_PER_MILLION(0x27C4, "org.bluetooth.unit.concentration.parts_per_million", SmartHomeUnits.PARTS_PER_MILLION),
+ PARTS_PER_BILLION(0x27C5, "org.bluetooth.unit.concentration.parts_per_billion", SmartHomeUnits.PARTS_PER_BILLION);
+
+ private UUID uuid;
+
+ private String type;
+
+ private Unit> unit;
+
+ private BluetoothUnit(long key, String type, Unit> unit) {
+ this.uuid = new UUID((key << 32) | 0x1000, BluetoothBindingConstants.BLUETOOTH_BASE_UUID);
+ this.type = type;
+ this.unit = unit;
+ }
+
+ public static @Nullable BluetoothUnit findByType(String type) {
+ for (BluetoothUnit unit : BluetoothUnit.values()) {
+ if (unit.type.equals(type)) {
+ return unit;
+ }
+ }
+ return null;
+ }
+
+ public UUID getUUID() {
+ return uuid;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public Unit> getUnit() {
+ return unit;
+ }
+
+ /**
+ * This class contains the set of units that are not yet defined in SmarthomeUnits.
+ * Once these units are added to the core then this class will be removed.
+ *
+ * @author cpetty
+ * @deprecated
+ */
+ @Deprecated
+ public static class BUnits {
+ public static final Unit KILOGRAM_PER_SQUARE_METER = addUnit(
+ new ProductUnit(Units.KILOGRAM.divide(Units.SQUARE_METRE)));
+
+ public static final Unit COULOMB_PER_KILOGRAM = addUnit(
+ new ProductUnit(Units.COULOMB.divide(Units.KILOGRAM)));
+
+ public static final Unit GRAY_PER_SECOND = addUnit(
+ new ProductUnit(Units.GRAY.divide(Units.SECOND)));
+
+ public static final Unit POUND = addUnit(
+ new TransformedUnit(Units.KILOGRAM, new MultiplyConverter(0.45359237)));
+
+ public static final Unit MINUTE_ANGLE = addUnit(new TransformedUnit(Units.RADIAN,
+ new PiMultiplierConverter().concatenate(new RationalConverter(1, 180 * 60))));
+
+ public static final Unit SECOND_ANGLE = addUnit(new TransformedUnit(Units.RADIAN,
+ new PiMultiplierConverter().concatenate(new RationalConverter(1, 180 * 60 * 60))));
+
+ public static final Unit HECTARE = addUnit(Units.SQUARE_METRE.multiply(10000.0));
+ public static final Unit BARN = addUnit(Units.SQUARE_METRE.multiply(10E-28));
+
+ public static final Unit NAUTICAL_MILE = addUnit(SIUnits.METRE.multiply(1852.0));
+
+ public static final Unit WATT_PER_STERADIAN = addUnit(
+ new ProductUnit(Units.WATT.divide(Units.STERADIAN)));
+
+ public static final Unit WATT_PER_STERADIAN_PER_SQUARE_METRE = addUnit(
+ new ProductUnit(WATT_PER_STERADIAN.divide(Units.SQUARE_METRE)));
+
+ public static final Unit CYCLES_PER_MINUTE = addUnit(new TransformedUnit(Units.HERTZ,
+ new RationalConverter(BigInteger.valueOf(60), BigInteger.ONE)));
+
+ public static final Unit REVOLUTION = addUnit(new TransformedUnit(Units.RADIAN,
+ new PiMultiplierConverter().concatenate(new RationalConverter(2, 1))));
+ public static final Unit REVOLUTION_PER_MINUTE = addUnit(
+ new ProductUnit(REVOLUTION.divide(Units.MINUTE)));
+
+ public static final Unit STEPS = addUnit(SmartHomeUnits.ONE.alternate("steps"));
+ public static final Unit BEATS = addUnit(SmartHomeUnits.ONE.alternate("beats"));
+ public static final Unit STROKE = addUnit(SmartHomeUnits.ONE.alternate("stroke"));
+
+ public static final Unit STEP_PER_MINUTE = addUnit(
+ new ProductUnit(STEPS.divide(Units.MINUTE)));
+
+ public static final Unit BEATS_PER_MINUTE = addUnit(
+ new ProductUnit(BEATS.divide(Units.MINUTE)));
+
+ public static final Unit STROKE_PER_MINUTE = addUnit(
+ new ProductUnit(STROKE.divide(Units.MINUTE)));
+
+ public static final Unit GRAM_PER_SECOND = addUnit(
+ new ProductUnit(Units.GRAM.divide(Units.SECOND)));
+
+ public static final Unit LUMEN_PER_WATT = addUnit(
+ new ProductUnit(Units.LUMEN.divide(Units.WATT)));
+
+ public static final Unit LUMEN_SECOND = addUnit(
+ new ProductUnit(Units.LUMEN.multiply(Units.SECOND)));
+
+ public static final Unit LUMEN_HOUR = addUnit(
+ new ProductUnit(Units.LUMEN.multiply(Units.HOUR)));
+
+ public static final Unit AMPERE_HOUR = addUnit(
+ new ProductUnit(Units.AMPERE.multiply(Units.HOUR)));
+
+ public static final Unit LUX_HOUR = addUnit(
+ new ProductUnit(Units.LUX.multiply(Units.HOUR)));
+
+ public static final Unit KILOMETRE_PER_MINUTE = addUnit(Units.KILOMETRE_PER_HOUR.multiply(60.0));
+
+ public static final Unit LITRE_PER_SECOND = addUnit(
+ new ProductUnit(Units.LITRE.divide(Units.SECOND)));
+
+ static {
+ SimpleUnitFormat.getInstance().label(GRAY_PER_SECOND, "Gy/s");
+ SimpleUnitFormat.getInstance().label(MINUTE_ANGLE, "'");
+ SimpleUnitFormat.getInstance().label(SECOND_ANGLE, "\"");
+ SimpleUnitFormat.getInstance().label(HECTARE, "ha");
+ SimpleUnitFormat.getInstance().label(NAUTICAL_MILE, "NM");
+ SimpleUnitFormat.getInstance().label(KILOGRAM_PER_SQUARE_METER, "kg/m²");
+ SimpleUnitFormat.getInstance().label(POUND, "lb");
+ SimpleUnitFormat.getInstance().label(CYCLES_PER_MINUTE, "cpm");
+ SimpleUnitFormat.getInstance().label(GRAM_PER_SECOND, "g/s");
+ SimpleUnitFormat.getInstance().label(LUMEN_SECOND, "lm·s");
+ SimpleUnitFormat.getInstance().label(LUMEN_HOUR, "lm·h");
+ SimpleUnitFormat.getInstance().label(LUMEN_PER_WATT, "lm/W");
+ SimpleUnitFormat.getInstance().label(LUX_HOUR, "lx·h");
+ SimpleUnitFormat.getInstance().label(KILOMETRE_PER_MINUTE, "km/min");
+ SimpleUnitFormat.getInstance().label(LITRE_PER_SECOND, "l/s");
+ SimpleUnitFormat.getInstance().label(BEATS_PER_MINUTE, "bpm");
+ SimpleUnitFormat.getInstance().label(STEP_PER_MINUTE, "steps/min");
+ SimpleUnitFormat.getInstance().label(STROKE_PER_MINUTE, "spm");
+ SimpleUnitFormat.getInstance().label(REVOLUTION_PER_MINUTE, "rpm");
+ }
+
+ private static > U addUnit(U unit) {
+ return unit;
+ }
+
+ public interface AngularVelocity extends Quantity {
+ }
+
+ public interface LuminousEnergy extends Quantity {
+ }
+
+ public interface LuminousEfficacy extends Quantity {
+ }
+
+ public interface LuminousExposure extends Quantity {
+ }
+
+ public interface RadiantIntensity extends Quantity {
+ }
+
+ public interface Radiance extends Quantity {
+ }
+
+ public interface RadiationExposure extends Quantity {
+ }
+
+ public interface RadiationDoseAbsorptionRate extends Quantity {
+ }
+
+ public interface MassFlowRate extends Quantity {
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/CharacteristicChannelTypeProvider.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/CharacteristicChannelTypeProvider.java
new file mode 100644
index 00000000000..3ebab84aeb7
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/CharacteristicChannelTypeProvider.java
@@ -0,0 +1,204 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ChannelTypeBuilder;
+import org.openhab.core.thing.type.ChannelTypeProvider;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.StateOption;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sputnikdev.bluetooth.gattparser.BluetoothGattParser;
+import org.sputnikdev.bluetooth.gattparser.BluetoothGattParserFactory;
+import org.sputnikdev.bluetooth.gattparser.spec.Enumerations;
+import org.sputnikdev.bluetooth.gattparser.spec.Field;
+
+/**
+ * {@link CharacteristicChannelTypeProvider} that provides channel types for dynamically discovered characteristics.
+ *
+ * @author Vlad Kolotov - Original author
+ * @author Connor Petty - Modified for openHAB use.
+ */
+@NonNullByDefault
+@Component(service = { CharacteristicChannelTypeProvider.class, ChannelTypeProvider.class })
+public class CharacteristicChannelTypeProvider implements ChannelTypeProvider {
+
+ private static final String CHANNEL_TYPE_NAME_PATTERN = "characteristic-%s-%s-%s-%s";
+
+ private final Logger logger = LoggerFactory.getLogger(CharacteristicChannelTypeProvider.class);
+
+ private final @NonNullByDefault({}) Map cache = new ConcurrentHashMap<>();
+
+ private final BluetoothGattParser gattParser = BluetoothGattParserFactory.getDefault();
+
+ @Override
+ public Collection getChannelTypes(@Nullable Locale locale) {
+ return cache.values();
+ }
+
+ @Override
+ public @Nullable ChannelType getChannelType(ChannelTypeUID channelTypeUID, @Nullable Locale locale) {
+ if (isValidUID(channelTypeUID)) {
+ return cache.computeIfAbsent(channelTypeUID, uid -> {
+ String channelID = uid.getId();
+ boolean advanced = "advncd".equals(channelID.substring(15, 21));
+ boolean readOnly = "readable".equals(channelID.substring(22, 30));
+ String characteristicUUID = channelID.substring(31, 67);
+ String fieldName = channelID.substring(68, channelID.length());
+
+ if (gattParser.isKnownCharacteristic(characteristicUUID)) {
+ List fields = gattParser.getFields(characteristicUUID).stream()
+ .filter(field -> BluetoothChannelUtils.encodeFieldID(field).equals(fieldName))
+ .collect(Collectors.toList());
+
+ if (fields.size() > 1) {
+ logger.warn("Multiple fields with the same name found: {} / {}. Skipping them.",
+ characteristicUUID, fieldName);
+ return null;
+ }
+ Field field = fields.get(0);
+ return buildChannelType(uid, advanced, readOnly, field);
+ }
+ return null;
+ });
+ }
+ return null;
+ }
+
+ private static boolean isValidUID(ChannelTypeUID channelTypeUID) {
+ if (!channelTypeUID.getBindingId().equals(BluetoothBindingConstants.BINDING_ID)) {
+ return false;
+ }
+ String channelID = channelTypeUID.getId();
+ if (!channelID.startsWith("characteristic")) {
+ return false;
+ }
+ if (channelID.length() < 68) {
+ return false;
+ }
+ if (channelID.charAt(21) != '-') {
+ return false;
+ }
+ if (channelID.charAt(30) != '-') {
+ return false;
+ }
+ if (channelID.charAt(67) != '-') {
+ return false;
+ }
+ return true;
+ }
+
+ public ChannelTypeUID registerChannelType(String characteristicUUID, boolean advanced, boolean readOnly,
+ Field field) {
+ // characteristic-advncd-readable-00002a04-0000-1000-8000-00805f9b34fb-Battery_Level
+ String channelType = String.format(CHANNEL_TYPE_NAME_PATTERN, advanced ? "advncd" : "simple",
+ readOnly ? "readable" : "writable", characteristicUUID, BluetoothChannelUtils.encodeFieldID(field));
+
+ ChannelTypeUID channelTypeUID = new ChannelTypeUID(BluetoothBindingConstants.BINDING_ID, channelType);
+ cache.computeIfAbsent(channelTypeUID, uid -> buildChannelType(uid, advanced, readOnly, field));
+ logger.debug("registered channel type: {}", channelTypeUID);
+ return channelTypeUID;
+ }
+
+ private ChannelType buildChannelType(ChannelTypeUID channelTypeUID, boolean advanced, boolean readOnly,
+ Field field) {
+ List options = getStateOptions(field);
+ String itemType = BluetoothChannelUtils.getItemType(field);
+
+ if (itemType == null) {
+ throw new IllegalStateException("Unknown field format type: " + field.getUnit());
+ }
+
+ if (itemType.equals("Switch")) {
+ options = Collections.emptyList();
+ }
+
+ StateDescriptionFragmentBuilder stateDescBuilder = StateDescriptionFragmentBuilder.create()//
+ .withPattern(getPattern(field))//
+ .withReadOnly(readOnly)//
+ .withOptions(options);
+
+ BigDecimal min = toBigDecimal(field.getMinimum());
+ BigDecimal max = toBigDecimal(field.getMaximum());
+ if (min != null) {
+ stateDescBuilder = stateDescBuilder.withMinimum(min);
+ }
+ if (max != null) {
+ stateDescBuilder = stateDescBuilder.withMaximum(max);
+ }
+ return ChannelTypeBuilder.state(channelTypeUID, field.getName(), itemType)//
+ .isAdvanced(advanced)//
+ .withDescription(field.getInformativeText())//
+ .withStateDescriptionFragment(stateDescBuilder.build()).build();
+ }
+
+ private static String getPattern(Field field) {
+ String format = getFormat(field);
+ String unit = getUnit(field);
+ StringBuilder pattern = new StringBuilder();
+ pattern.append(format);
+ if (unit != null) {
+ pattern.append(" ").append(unit);
+ }
+ return pattern.toString();
+ }
+
+ private static List getStateOptions(Field field) {
+ return Optional.ofNullable(field.getEnumerations())//
+ .map(Enumerations::getEnumerations)//
+ .stream()//
+ .flatMap(List::stream)
+ .map(enumeration -> new StateOption(String.valueOf(enumeration.getKey()), enumeration.getValue()))
+ .collect(Collectors.toList());
+ }
+
+ private static @Nullable BigDecimal toBigDecimal(@Nullable Double value) {
+ return value != null ? BigDecimal.valueOf(value) : null;
+ }
+
+ private static String getFormat(Field field) {
+ String format = "%s";
+ Integer decimalExponent = field.getDecimalExponent();
+ if (field.getFormat().isReal() && decimalExponent != null && decimalExponent < 0) {
+ format = "%." + Math.abs(decimalExponent) + "f";
+ }
+ return format;
+ }
+
+ private static @Nullable String getUnit(Field field) {
+ String gattUnit = field.getUnit();
+ if (gattUnit != null) {
+ BluetoothUnit unit = BluetoothUnit.findByType(gattUnit);
+ if (unit != null) {
+ return unit.getUnit().getSymbol();
+ }
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConfiguration.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConfiguration.java
new file mode 100644
index 00000000000..1d7e7fd62bd
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConfiguration.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class GenericBindingConfiguration {
+
+ public int pollingInterval = 30;
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConstants.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConstants.java
new file mode 100644
index 00000000000..a9d73573b42
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBindingConstants.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link GenericBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+public class GenericBindingConstants {
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_GENERIC = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID,
+ "generic");
+
+ // Field properties
+ public static final String PROPERTY_FIELD_NAME = "FieldName";
+ public static final String PROPERTY_FIELD_INDEX = "FieldIndex";
+
+ // Characteristic properties
+ public static final String PROPERTY_FLAGS = "Flags";
+ public static final String PROPERTY_SERVICE_UUID = "ServiceUUID";
+ public static final String PROPERTY_CHARACTERISTIC_UUID = "CharacteristicUUID";
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandler.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandler.java
new file mode 100644
index 00000000000..4fd4b206bb6
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandler.java
@@ -0,0 +1,430 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.binding.bluetooth.BluetoothCharacteristic;
+import org.openhab.binding.bluetooth.BluetoothCompletionStatus;
+import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
+import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.openhab.core.util.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sputnikdev.bluetooth.gattparser.BluetoothGattParser;
+import org.sputnikdev.bluetooth.gattparser.BluetoothGattParserFactory;
+import org.sputnikdev.bluetooth.gattparser.FieldHolder;
+import org.sputnikdev.bluetooth.gattparser.GattRequest;
+import org.sputnikdev.bluetooth.gattparser.GattResponse;
+import org.sputnikdev.bluetooth.gattparser.spec.Characteristic;
+import org.sputnikdev.bluetooth.gattparser.spec.Field;
+
+/**
+ * This is a handler for generic connected bluetooth devices that dynamically generates
+ * channels based off of a bluetooth device's GATT characteristics.
+ *
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class GenericBluetoothHandler extends ConnectedBluetoothHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(GenericBluetoothHandler.class);
+ private final Map charHandlers = new ConcurrentHashMap<>();
+ private final Map channelHandlers = new ConcurrentHashMap<>();
+ private final BluetoothGattParser gattParser = BluetoothGattParserFactory.getDefault();
+ private final CharacteristicChannelTypeProvider channelTypeProvider;
+
+ private @Nullable ScheduledFuture> readCharacteristicJob = null;
+
+ public GenericBluetoothHandler(Thing thing, CharacteristicChannelTypeProvider channelTypeProvider) {
+ super(thing);
+ this.channelTypeProvider = channelTypeProvider;
+ }
+
+ @Override
+ public void initialize() {
+ super.initialize();
+
+ GenericBindingConfiguration config = getConfigAs(GenericBindingConfiguration.class);
+ readCharacteristicJob = scheduler.scheduleWithFixedDelay(() -> {
+ if (device.getConnectionState() == ConnectionState.CONNECTED) {
+ if (resolved) {
+ for (CharacteristicHandler charHandler : charHandlers.values()) {
+ if (charHandler.canRead()) {
+ device.readCharacteristic(charHandler.characteristic);
+ try {
+ // TODO the ideal solution would be to use locks/conditions and timeouts
+ // between this code and `onCharacteristicReadComplete` but
+ // that would overcomplicate the code a bit and I plan
+ // on implementing a better more generalized solution later
+ Thread.sleep(50);
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+ } else {
+ // if we are connected and still haven't been able to resolve the services, try disconnecting and
+ // then connecting again
+ device.disconnect();
+ }
+ }
+ }, 15, config.pollingInterval, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void dispose() {
+ ScheduledFuture> future = readCharacteristicJob;
+ if (future != null) {
+ future.cancel(true);
+ }
+ super.dispose();
+
+ charHandlers.clear();
+ channelHandlers.clear();
+ }
+
+ @Override
+ public void onServicesDiscovered() {
+ if (!resolved) {
+ resolved = true;
+ logger.trace("Service discovery completed for '{}'", address);
+ updateThingChannels();
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ super.handleCommand(channelUID, command);
+
+ CharacteristicHandler handler = channelHandlers.get(channelUID);
+ if (handler != null) {
+ handler.handleCommand(channelUID, command);
+ }
+ }
+
+ @Override
+ public void onCharacteristicReadComplete(BluetoothCharacteristic characteristic, BluetoothCompletionStatus status) {
+ super.onCharacteristicReadComplete(characteristic, status);
+ if (status == BluetoothCompletionStatus.SUCCESS) {
+ byte[] data = characteristic.getByteValue();
+ getCharacteristicHandler(characteristic).handleCharacteristicUpdate(data);
+ }
+ }
+
+ @Override
+ public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
+ super.onCharacteristicUpdate(characteristic);
+ byte[] data = characteristic.getByteValue();
+ getCharacteristicHandler(characteristic).handleCharacteristicUpdate(data);
+ }
+
+ private void updateThingChannels() {
+ List channels = device.getServices().stream()//
+ .flatMap(service -> service.getCharacteristics().stream())//
+ .flatMap(characteristic -> {
+ logger.trace("{} processing characteristic {}", address, characteristic.getUuid());
+ CharacteristicHandler handler = getCharacteristicHandler(characteristic);
+ List chans = handler.buildChannels();
+ for (Channel channel : chans) {
+ channelHandlers.put(channel.getUID(), handler);
+ }
+ return chans.stream();
+ })//
+ .collect(Collectors.toList());
+
+ ThingBuilder builder = editThing();
+ boolean changed = false;
+ for (Channel channel : channels) {
+ logger.trace("{} attempting to add channel {}", address, channel.getLabel());
+ // we only want to add each channel, not replace all of them
+ if (getThing().getChannel(channel.getUID()) == null) {
+ changed = true;
+ builder.withChannel(channel);
+ }
+ }
+ if (changed) {
+ updateThing(builder.build());
+ }
+ }
+
+ private CharacteristicHandler getCharacteristicHandler(BluetoothCharacteristic characteristic) {
+ return charHandlers.computeIfAbsent(characteristic, CharacteristicHandler::new);
+ }
+
+ private boolean readCharacteristic(BluetoothCharacteristic characteristic) {
+ return device.readCharacteristic(characteristic);
+ }
+
+ private boolean writeCharacteristic(BluetoothCharacteristic characteristic, byte[] data) {
+ characteristic.setValue(data);
+ return device.writeCharacteristic(characteristic);
+ }
+
+ private class CharacteristicHandler {
+
+ private BluetoothCharacteristic characteristic;
+
+ public CharacteristicHandler(BluetoothCharacteristic characteristic) {
+ this.characteristic = characteristic;
+ }
+
+ private String getCharacteristicUUID() {
+ return characteristic.getUuid().toString();
+ }
+
+ public void handleCommand(ChannelUID channelUID, Command command) {
+
+ // Handle REFRESH
+ if (command == RefreshType.REFRESH) {
+ if (canRead()) {
+ readCharacteristic(characteristic);
+ }
+ return;
+ }
+
+ // handle write
+ if (command instanceof State) {
+ State state = (State) command;
+ String characteristicUUID = getCharacteristicUUID();
+ try {
+ if (gattParser.isKnownCharacteristic(characteristicUUID)) {
+ String fieldName = getFieldName(channelUID);
+ if (fieldName != null) {
+ updateCharacteristic(fieldName, state);
+ } else {
+ logger.warn("Characteristic has no field name!");
+ }
+ } else if (state instanceof StringType) {
+ // unknown characteristic
+ byte[] data = HexUtils.hexToBytes(state.toString());
+ if (!writeCharacteristic(characteristic, data)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Could not write data to characteristic: " + characteristicUUID);
+ }
+ }
+ } catch (RuntimeException ex) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Could not update bluetooth device. Error: " + ex.getMessage());
+ }
+ }
+ }
+
+ private void updateCharacteristic(String fieldName, State state) {
+ // TODO maybe we should check if the characteristic is authenticated?
+ String characteristicUUID = getCharacteristicUUID();
+
+ if (gattParser.isValidForWrite(characteristicUUID)) {
+ GattRequest request = gattParser.prepare(characteristicUUID);
+ try {
+ BluetoothChannelUtils.updateHolder(gattParser, request, fieldName, state);
+ byte[] data = gattParser.serialize(request);
+
+ if (!writeCharacteristic(characteristic, data)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Could not write data to characteristic: " + characteristicUUID);
+ }
+ } catch (NumberFormatException ex) {
+ logger.warn("Could not parse characteristic value: {} : {}", characteristicUUID, state, ex);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Could not parse characteristic value: " + characteristicUUID + " : " + state);
+ }
+ }
+ }
+
+ public void handleCharacteristicUpdate(byte[] data) {
+ String characteristicUUID = getCharacteristicUUID();
+ if (gattParser.isKnownCharacteristic(characteristicUUID)) {
+ GattResponse response = gattParser.parse(characteristicUUID, data);
+ for (FieldHolder holder : response.getFieldHolders()) {
+ Field field = holder.getField();
+ ChannelUID channelUID = getChannelUID(field);
+ updateState(channelUID, BluetoothChannelUtils.convert(gattParser, holder));
+ }
+ } else {
+ // this is a raw channel
+ String hex = HexUtils.bytesToHex(data);
+ ChannelUID channelUID = getChannelUID(null);
+ updateState(channelUID, new StringType(hex));
+ }
+ }
+
+ public List buildChannels() {
+ List channels = new ArrayList<>();
+ String charUUID = getCharacteristicUUID();
+ Characteristic gattChar = gattParser.getCharacteristic(charUUID);
+ if (gattChar != null) {
+ List fields = gattParser.getFields(charUUID);
+
+ String label = null;
+ // check if the characteristic has only on field, if so use its name as label
+ if (fields.size() == 1) {
+ label = gattChar.getName();
+ }
+
+ Map> fieldsMapping = fields.stream().collect(Collectors.groupingBy(Field::getName));
+
+ for (List fieldList : fieldsMapping.values()) {
+ Field field = fieldList.get(0);
+ if (fieldList.size() > 1) {
+ if (field.isFlagField() || field.isOpCodesField()) {
+ logger.debug("Skipping flags/op codes field: {}.", charUUID);
+ } else {
+ logger.warn("Multiple fields with the same name found: {} / {}. Skipping these fields.",
+ charUUID, field.getName());
+ }
+ continue;
+ }
+
+ if (isFieldSupported(field)) {
+ Channel channel = buildFieldChannel(field, label, !gattChar.isValidForWrite());
+ if (channel != null) {
+ channels.add(channel);
+ } else {
+ logger.warn("Unable to build channel for field: {}", field.getName());
+ }
+ } else {
+ logger.warn("GATT field is not supported: {} / {} / {}", charUUID, field.getName(),
+ field.getFormat());
+ }
+ }
+ } else {
+ channels.add(buildUnknownChannel());
+ }
+ return channels;
+ }
+
+ private Channel buildUnknownChannel() {
+ ChannelUID channelUID = getChannelUID(null);
+ ChannelTypeUID channelTypeUID = new ChannelTypeUID(BluetoothBindingConstants.BINDING_ID, "char-unknown");
+ return ChannelBuilder.create(channelUID).withType(channelTypeUID).withProperties(getChannelProperties(null))
+ .build();
+ }
+
+ public boolean canRead() {
+ String charUUID = getCharacteristicUUID();
+ if (gattParser.isKnownCharacteristic(charUUID)) {
+ return gattParser.isValidForRead(charUUID);
+ }
+ // TODO: need to evaluate this from characteristic properties, but such properties aren't support yet
+ return true;
+ }
+
+ public boolean canWrite() {
+ String charUUID = getCharacteristicUUID();
+ if (gattParser.isKnownCharacteristic(charUUID)) {
+ return gattParser.isValidForWrite(charUUID);
+ }
+ // TODO: need to evaluate this from characteristic properties, but such properties aren't support yet
+ return true;
+ }
+
+ private boolean isAdvanced() {
+ return !gattParser.isKnownCharacteristic(getCharacteristicUUID());
+ }
+
+ private boolean isFieldSupported(Field field) {
+ return field.getFormat() != null;
+ }
+
+ private @Nullable Channel buildFieldChannel(Field field, @Nullable String charLabel, boolean readOnly) {
+ String label = charLabel != null ? charLabel : field.getName();
+ String acceptedType = BluetoothChannelUtils.getItemType(field);
+ if (acceptedType == null) {
+ // unknown field format
+ return null;
+ }
+
+ ChannelUID channelUID = getChannelUID(field);
+
+ logger.debug("Building a new channel for a field: {}", channelUID.getId());
+
+ ChannelTypeUID channelTypeUID = channelTypeProvider.registerChannelType(getCharacteristicUUID(),
+ isAdvanced(), readOnly, field);
+
+ return ChannelBuilder.create(channelUID, acceptedType).withType(channelTypeUID)
+ .withProperties(getChannelProperties(field.getName())).withLabel(label).build();
+ }
+
+ private ChannelUID getChannelUID(@Nullable Field field) {
+ StringBuilder builder = new StringBuilder();
+ builder.append("service-")//
+ .append(toBluetoothHandle(characteristic.getService().getUuid()))//
+ .append("-char-")//
+ .append(toBluetoothHandle(characteristic.getUuid()));
+ if (field != null) {
+ builder.append("-").append(BluetoothChannelUtils.encodeFieldName(field.getName()));
+ }
+ return new ChannelUID(getThing().getUID(), builder.toString());
+ }
+
+ private String toBluetoothHandle(UUID uuid) {
+ long leastSig = uuid.getLeastSignificantBits();
+ long mostSig = uuid.getMostSignificantBits();
+
+ if (leastSig == BluetoothBindingConstants.BLUETOOTH_BASE_UUID) {
+ return "0x" + Long.toHexString(mostSig >> 32).toUpperCase();
+ }
+ return uuid.toString().toUpperCase();
+ }
+
+ private @Nullable String getFieldName(ChannelUID channelUID) {
+ String channelId = channelUID.getId();
+ int index = channelId.lastIndexOf("-");
+ if (index == -1) {
+ throw new IllegalArgumentException(
+ "ChannelUID '" + channelUID + "' is not a valid GATT channel format");
+ }
+ String encodedFieldName = channelId.substring(index + 1);
+ if (encodedFieldName.isEmpty()) {
+ return null;
+ }
+ return BluetoothChannelUtils.decodeFieldName(encodedFieldName);
+ }
+
+ private Map getChannelProperties(@Nullable String fieldName) {
+ Map properties = new HashMap<>();
+ if (fieldName != null) {
+ properties.put(GenericBindingConstants.PROPERTY_FIELD_NAME, fieldName);
+ }
+ properties.put(GenericBindingConstants.PROPERTY_SERVICE_UUID,
+ characteristic.getService().getUuid().toString());
+ properties.put(GenericBindingConstants.PROPERTY_CHARACTERISTIC_UUID, getCharacteristicUUID());
+ return properties;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandlerFactory.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandlerFactory.java
new file mode 100644
index 00000000000..13b51309982
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericBluetoothHandlerFactory.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link GenericBluetoothHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.bluetooth.generic", service = ThingHandlerFactory.class)
+public class GenericBluetoothHandlerFactory extends BaseThingHandlerFactory {
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Set
+ .of(GenericBindingConstants.THING_TYPE_GENERIC);
+
+ private final CharacteristicChannelTypeProvider channelTypeProvider;
+
+ @Activate
+ public GenericBluetoothHandlerFactory(@Reference CharacteristicChannelTypeProvider channelTypeProvider) {
+ this.channelTypeProvider = channelTypeProvider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (GenericBindingConstants.THING_TYPE_GENERIC.equals(thingTypeUID)) {
+ return new GenericBluetoothHandler(thing, channelTypeProvider);
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericDiscoveryParticipant.java
new file mode 100644
index 00000000000..22e285dca11
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/java/org/openhab/binding/bluetooth/generic/internal/GenericDiscoveryParticipant.java
@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.binding.bluetooth.BluetoothCompanyIdentifiers;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class implements the BluetoothDiscoveryParticipant for generic bluetooth devices.
+ *
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+@Component(service = BluetoothDiscoveryParticipant.class)
+public class GenericDiscoveryParticipant implements BluetoothDiscoveryParticipant {
+
+ private final Logger logger = LoggerFactory.getLogger(GenericDiscoveryParticipant.class);
+
+ @Override
+ public Set getSupportedThingTypeUIDs() {
+ return Set.of(GenericBindingConstants.THING_TYPE_GENERIC);
+ }
+
+ @Override
+ public @Nullable DiscoveryResult createResult(BluetoothDiscoveryDevice device) {
+ ThingUID thingUID = getThingUID(device);
+ if (thingUID == null) {
+ // the thingUID will never be null in practice but this makes the null checker happy
+ return null;
+ }
+ String label = "Generic Connectable Bluetooth Device";
+ Map properties = new HashMap<>();
+ properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
+ Integer txPower = device.getTxPower();
+ if (txPower != null && txPower > 0) {
+ properties.put(BluetoothBindingConstants.PROPERTY_TXPOWER, Integer.toString(txPower));
+ }
+ String manufacturer = BluetoothCompanyIdentifiers.get(device.getManufacturerId());
+ if (manufacturer == null) {
+ logger.debug("Unknown manufacturer Id ({}) found on bluetooth device.", device.getManufacturerId());
+ } else {
+ properties.put(Thing.PROPERTY_VENDOR, manufacturer);
+ label += " (" + manufacturer + ")";
+ }
+
+ addPropertyIfPresent(properties, Thing.PROPERTY_MODEL_ID, device.getModel());
+ addPropertyIfPresent(properties, Thing.PROPERTY_SERIAL_NUMBER, device.getSerialNumber());
+ addPropertyIfPresent(properties, Thing.PROPERTY_HARDWARE_VERSION, device.getHardwareRevision());
+ addPropertyIfPresent(properties, Thing.PROPERTY_FIRMWARE_VERSION, device.getFirmwareRevision());
+ addPropertyIfPresent(properties, BluetoothBindingConstants.PROPERTY_SOFTWARE_VERSION,
+ device.getSoftwareRevision());
+
+ return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
+ .withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
+ .withBridge(device.getAdapter().getUID()).withLabel(label).build();
+ }
+
+ private static void addPropertyIfPresent(Map properties, String key, @Nullable Object value) {
+ if (value != null) {
+ properties.put(key, value);
+ }
+ }
+
+ @Override
+ public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
+ return new ThingUID(GenericBindingConstants.THING_TYPE_GENERIC, device.getAdapter().getUID(),
+ device.getAddress().toString().toLowerCase().replace(":", ""));
+ }
+
+ @Override
+ public boolean requiresConnection(BluetoothDiscoveryDevice device) {
+ return true;
+ }
+
+ @Override
+ public int order() {
+ // we want to go last
+ return Integer.MAX_VALUE;
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/main/resources/OH-INF/thing/generic.xml b/bundles/org.openhab.binding.bluetooth.generic/src/main/resources/OH-INF/thing/generic.xml
new file mode 100644
index 00000000000..0f51494318f
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/main/resources/OH-INF/thing/generic.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+ A generic bluetooth device that supports GATT characteristics
+
+
+
+
+ Bluetooth address in XX:XX:XX:XX:XX:XX format
+
+
+ true
+
+ The frequency at which readable characteristics refreshed
+ 30
+
+
+
+
+
+
+ String
+
+ The raw value of unknown characteristics are represented with hexadecimal
+
+
+
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtilsTest.java b/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtilsTest.java
new file mode 100644
index 00000000000..f78e22bfdc0
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothChannelUtilsTest.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class BluetoothChannelUtilsTest {
+
+ @Test
+ public void encodeDecodeFieldNameTest() {
+ String str = "easure";
+ assertEquals(str, BluetoothChannelUtils.decodeFieldName(BluetoothChannelUtils.encodeFieldName(str)));
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnitTest.java b/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnitTest.java
new file mode 100644
index 00000000000..a977d89ff3a
--- /dev/null
+++ b/bundles/org.openhab.binding.bluetooth.generic/src/test/java/org/openhab/binding/bluetooth/generic/internal/BluetoothUnitTest.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.generic.internal;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+class BluetoothUnitTest {
+
+ @Test
+ void initializeTest() {
+ BluetoothUnit.AMPERE.getUnit();
+ }
+}
diff --git a/bundles/org.openhab.binding.bluetooth/README.md b/bundles/org.openhab.binding.bluetooth/README.md
index 8a78be4b917..51360c4e545 100644
--- a/bundles/org.openhab.binding.bluetooth/README.md
+++ b/bundles/org.openhab.binding.bluetooth/README.md
@@ -17,23 +17,25 @@ This should be the best choice for any Linux-based single board computers like e
## Supported Things
-Two thing types are supported by this binding:
+The base bluetooth binding only supports a single thing type.
+Additional thing types are available through bluetooth extensions.
| Thing Type ID | Description |
|---------------|---------------------------------------------------------------------------------------------------------|
-| beacon | A Bluetooth device that is not connected, but only broadcasts announcements. |
-| connected | A Bluetooth device that allows a direct connection and which provides specific services when connected. |
+| beacon | A Bluetooth device that is not connected, but only broadcasts announcements. |
## Discovery
Discovery is performed through the Bluetooth bridge.
Normally, any broadcasting Bluetooth device can be uniquely identified and thus a bridge can create an inbox result for it.
-As this might lead to a huge list of devices, bridges usually also offer a way to deactivate this behavior.
+As this might lead to a huge list of devices, bridges usually disable this behavior by default.
## Thing Configuration
-Both thing types only require a single configuration parameter `address`, which corresponds to the Bluetooth address of the device (in format "XX:XX:XX:XX:XX:XX").
+All bluetooth thing types require a configuration parameter `address`, which corresponds to the Bluetooth address of the device (in format "XX:XX:XX:XX:XX:XX").
+Other configuration parameters may be required depending on the bluetooth thing type, look at the documentation for that thing type for details.
+
## Channels
@@ -43,13 +45,6 @@ Every Bluetooth thing has the following channel:
|------------|-----------|-----------------------------------------------------------------------------------------------------|
| rssi | Number | The "Received Signal Strength Indicator", the [RSSI](https://blog.bluetooth.com/proximity-and-rssi) |
-`connected` Things are dynamically queried for their services and if they support certain standard GATT characteristics, the appropriate channels are automatically added as well:
-
-| Channel ID | Item Type | Description |
-|---------------|-----------|-----------------------------------------------------------------|
-| battery_level | Number | The device's battery level in percent |
-
-
## Full Example
demo.things (assuming you have a Bluetooth bridge with the ID `bluetooth:bluez:hci0`):
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothBindingConstants.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothBindingConstants.java
index 7eea293c76f..a258601dffc 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothBindingConstants.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothBindingConstants.java
@@ -30,7 +30,6 @@ public class BluetoothBindingConstants {
public static final String BINDING_ID = "bluetooth";
// List of all Thing Type UIDs
- public static final ThingTypeUID THING_TYPE_CONNECTED = new ThingTypeUID(BINDING_ID, "connected");
public static final ThingTypeUID THING_TYPE_BEACON = new ThingTypeUID(BINDING_ID, "beacon");
// List of all Channel Type IDs
@@ -40,6 +39,7 @@ public class BluetoothBindingConstants {
public static final String PROPERTY_TXPOWER = "txpower";
public static final String PROPERTY_MAXCONNECTIONS = "maxconnections";
+ public static final String PROPERTY_SOFTWARE_VERSION = "softwareVersion";
public static final String CONFIGURATION_ADDRESS = "address";
public static final String CONFIGURATION_DISCOVERY = "backgroundDiscovery";
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothCharacteristic.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothCharacteristic.java
index e3bbf466351..36d61def615 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothCharacteristic.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/BluetoothCharacteristic.java
@@ -221,6 +221,48 @@ public class BluetoothCharacteristic {
return gattDescriptors.get(uuid);
}
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + instance;
+ result = prime * result + ((service == null) ? 0 : service.hashCode());
+ result = prime * result + ((uuid == null) ? 0 : uuid.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ BluetoothCharacteristic other = (BluetoothCharacteristic) obj;
+ if (instance != other.instance) {
+ return false;
+ }
+ if (service == null) {
+ if (other.service != null) {
+ return false;
+ }
+ } else if (!service.equals(other.service)) {
+ return false;
+ }
+ if (uuid == null) {
+ if (other.uuid != null) {
+ return false;
+ }
+ } else if (!uuid.equals(other.uuid)) {
+ return false;
+ }
+ return true;
+ }
+
/**
* Get the stored value for this characteristic.
*
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/ConnectedBluetoothHandler.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/ConnectedBluetoothHandler.java
index 370e3bc2dea..b59d3dd425d 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/ConnectedBluetoothHandler.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/ConnectedBluetoothHandler.java
@@ -19,20 +19,12 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.bluetooth.BluetoothCharacteristic.GattCharacteristic;
import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.binding.builder.ChannelBuilder;
-import org.openhab.core.thing.binding.builder.ThingBuilder;
-import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.util.HexUtils;
@@ -40,11 +32,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * This is a handler for generic Bluetooth devices in connected mode, which at the same time can be used
- * as a base implementation for more specific thing handlers.
+ * This is a base implementation for more specific thing handlers that require constant connection to bluetooth devices.
*
* @author Kai Kreuzer - Initial contribution and API
- *
*/
@NonNullByDefault({ DefaultLocation.PARAMETER, DefaultLocation.RETURN_TYPE, DefaultLocation.ARRAY_CONTENTS,
DefaultLocation.TYPE_ARGUMENT, DefaultLocation.TYPE_BOUND, DefaultLocation.TYPE_PARAMETER })
@@ -67,11 +57,21 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
super.initialize();
connectionJob = scheduler.scheduleWithFixedDelay(() -> {
- if (device.getConnectionState() != ConnectionState.CONNECTED) {
- device.connect();
- // we do not set the Thing status here, because we will anyhow receive a call to onConnectionStateChange
+ try {
+ if (device.getConnectionState() != ConnectionState.CONNECTED) {
+ device.connect();
+ // we do not set the Thing status here, because we will anyhow receive a call to
+ // onConnectionStateChange
+ } else {
+ // just in case it was already connected to begin with
+ updateStatus(ThingStatus.ONLINE);
+ if (!resolved && !device.discoverServices()) {
+ logger.debug("Error while discovering services");
+ }
+ }
+ } catch (RuntimeException ex) {
+ logger.warn("Unexpected error occurred", ex);
}
- updateRSSI();
}, 0, 30, TimeUnit.SECONDS);
}
@@ -81,18 +81,7 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
connectionJob.cancel(true);
connectionJob = null;
}
- scheduler.submit(() -> {
- try {
- deviceLock.lock();
- if (device != null) {
- device.removeListener(this);
- device.disconnect();
- device = null;
- }
- } finally {
- deviceLock.unlock();
- }
- });
+ super.dispose();
}
@Override
@@ -167,12 +156,6 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
if (!resolved) {
resolved = true;
logger.debug("Service discovery completed for '{}'", address);
- BluetoothCharacteristic characteristic = device
- .getCharacteristic(GattCharacteristic.BATTERY_LEVEL.getUUID());
- if (characteristic != null) {
- activateChannel(characteristic, DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_BATTERY_LEVEL.getUID());
- logger.debug("Added GATT characteristic '{}'", characteristic.getGattCharacteristic().name());
- }
}
}
@@ -180,13 +163,9 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
public void onCharacteristicReadComplete(BluetoothCharacteristic characteristic, BluetoothCompletionStatus status) {
super.onCharacteristicReadComplete(characteristic, status);
if (status == BluetoothCompletionStatus.SUCCESS) {
- if (GattCharacteristic.BATTERY_LEVEL.equals(characteristic.getGattCharacteristic())) {
- updateBatteryLevel(characteristic);
- } else {
- if (logger.isDebugEnabled()) {
- logger.debug("Characteristic {} from {} has been read - value {}", characteristic.getUuid(),
- address, HexUtils.bytesToHex(characteristic.getByteValue()));
- }
+ if (logger.isDebugEnabled()) {
+ logger.debug("Characteristic {} from {} has been read - value {}", characteristic.getUuid(), address,
+ HexUtils.bytesToHex(characteristic.getByteValue()));
}
} else {
logger.debug("Characteristic {} from {} has been read - ERROR", characteristic.getUuid(), address);
@@ -210,9 +189,6 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
logger.debug("Recieved update {} to characteristic {} of device {}",
HexUtils.bytesToHex(characteristic.getByteValue()), characteristic.getUuid(), address);
}
- if (GattCharacteristic.BATTERY_LEVEL.equals(characteristic.getGattCharacteristic())) {
- updateBatteryLevel(characteristic);
- }
}
@Override
@@ -223,41 +199,4 @@ public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
descriptor.getUuid(), address);
}
}
-
- protected void updateBatteryLevel(BluetoothCharacteristic characteristic) {
- // the byte has values from 0-255, which we need to map to 0-100
- Double level = characteristic.getValue()[0] / 2.55;
- updateState(characteristic.getGattCharacteristic().name(), new DecimalType(level.intValue()));
- }
-
- protected void activateChannel(@Nullable BluetoothCharacteristic characteristic, ChannelTypeUID channelTypeUID,
- @Nullable String name) {
- if (characteristic != null) {
- String channelId = name != null ? name : characteristic.getGattCharacteristic().name();
- if (channelId == null) {
- // use the type id as a fallback
- channelId = channelTypeUID.getId();
- }
- if (getThing().getChannel(channelId) == null) {
- // the channel does not exist yet, so let's add it
- ThingBuilder updatedThing = editThing();
- Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), channelId), "Number")
- .withType(channelTypeUID).build();
- updatedThing.withChannel(channel);
- updateThing(updatedThing.build());
- logger.debug("Added channel '{}' to Thing '{}'", channelId, getThing().getUID());
- }
- deviceCharacteristics.add(characteristic);
- device.enableNotifications(characteristic);
- if (isLinked(channelId)) {
- device.readCharacteristic(characteristic);
- }
- } else {
- logger.debug("Characteristic is null - not activating any channel.");
- }
- }
-
- protected void activateChannel(@Nullable BluetoothCharacteristic characteristic, ChannelTypeUID channelTypeUID) {
- activateChannel(characteristic, channelTypeUID, null);
- }
}
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/BluetoothDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/BluetoothDiscoveryParticipant.java
index 1378a2cc014..9b8c95150e3 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/BluetoothDiscoveryParticipant.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/BluetoothDiscoveryParticipant.java
@@ -91,4 +91,14 @@ public interface BluetoothDiscoveryParticipant {
BiConsumer publisher) {
// do nothing by default
}
+
+ /**
+ * Overriding this method allows discovery participants to dictate the order in which they should be evaluated
+ * relative to other discovery participants. Participants with a lower order value are evaluated first.
+ *
+ * @return the order of this participant, default 0
+ */
+ public default int order() {
+ return 0;
+ }
}
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/internal/BluetoothDiscoveryProcess.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/internal/BluetoothDiscoveryProcess.java
index 9924a5012ac..a5a55bb4883 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/internal/BluetoothDiscoveryProcess.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/discovery/internal/BluetoothDiscoveryProcess.java
@@ -14,6 +14,7 @@ package org.openhab.binding.bluetooth.discovery.internal;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -35,7 +36,6 @@ import org.openhab.binding.bluetooth.BluetoothCharacteristic.GattCharacteristic;
import org.openhab.binding.bluetooth.BluetoothCompanyIdentifiers;
import org.openhab.binding.bluetooth.BluetoothCompletionStatus;
import org.openhab.binding.bluetooth.BluetoothDescriptor;
-import org.openhab.binding.bluetooth.BluetoothDevice;
import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
import org.openhab.binding.bluetooth.BluetoothDeviceListener;
import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
@@ -44,6 +44,7 @@ import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -86,9 +87,12 @@ public class BluetoothDiscoveryProcess implements Supplier, Blu
@Override
public DiscoveryResult get() {
+ List sortedParticipants = new ArrayList<>(participants);
+ sortedParticipants.sort(Comparator.comparing(BluetoothDiscoveryParticipant::order));
+
// first see if any of the participants that don't require a connection recognize this device
List connectionParticipants = new ArrayList<>();
- for (BluetoothDiscoveryParticipant participant : participants) {
+ for (BluetoothDiscoveryParticipant participant : sortedParticipants) {
if (participant.requiresConnection(device)) {
connectionParticipants.add(participant);
continue;
@@ -105,25 +109,23 @@ public class BluetoothDiscoveryProcess implements Supplier, Blu
// Since we couldn't find a result, lets try the connection based participants
DiscoveryResult result = null;
- if (!connectionParticipants.isEmpty()) {
- BluetoothAddress address = device.getAddress();
- if (isAddressAvailable(address)) {
- result = findConnectionResult(connectionParticipants);
- // make sure to disconnect before letting go of the device
- if (device.getConnectionState() == ConnectionState.CONNECTED) {
- try {
- if (!device.disconnect()) {
- logger.debug("Failed to disconnect from device {}", address);
- }
- } catch (RuntimeException ex) {
- logger.warn("Error occurred during bluetooth discovery for device {} on adapter {}", address,
- device.getAdapter().getUID(), ex);
+ BluetoothAddress address = device.getAddress();
+ if (isAddressAvailable(address)) {
+ result = findConnectionResult(connectionParticipants);
+ // make sure to disconnect before letting go of the device
+ if (device.getConnectionState() == ConnectionState.CONNECTED) {
+ try {
+ if (!device.disconnect()) {
+ logger.debug("Failed to disconnect from device {}", address);
}
+ } catch (RuntimeException ex) {
+ logger.warn("Error occurred during bluetooth discovery for device {} on adapter {}", address,
+ device.getAdapter().getUID(), ex);
}
}
}
if (result == null) {
- result = createDefaultResult(device);
+ result = createDefaultResult();
}
return result;
}
@@ -133,8 +135,8 @@ public class BluetoothDiscoveryProcess implements Supplier, Blu
return adapters.stream().noneMatch(adapter -> adapter.hasHandlerForDevice(address));
}
- private DiscoveryResult createDefaultResult(BluetoothDevice device) {
- // We did not find a thing type for this device, so let's treat it as a generic one
+ private DiscoveryResult createDefaultResult() {
+ // We did not find a thing type for this device, so let's treat it as a generic beacon
String label = device.getName();
if (label == null || label.length() == 0 || label.equals(device.getAddress().toString().replace(':', '-'))) {
label = "Bluetooth Device";
@@ -154,42 +156,51 @@ public class BluetoothDiscoveryProcess implements Supplier, Blu
label += " (" + manufacturer + ")";
}
- ThingUID thingUID = new ThingUID(BluetoothBindingConstants.THING_TYPE_BEACON, device.getAdapter().getUID(),
- device.getAddress().toString().toLowerCase().replace(":", ""));
+ ThingTypeUID thingTypeUID = BluetoothBindingConstants.THING_TYPE_BEACON;
+ ThingUID thingUID = new ThingUID(thingTypeUID, device.getAdapter().getUID(),
+ device.getAddress().toString().toLowerCase().replace(":", ""));
// Create the discovery result and add to the inbox
return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS).withTTL(DISCOVERY_TTL)
.withBridge(device.getAdapter().getUID()).withLabel(label).build();
}
+ // this is really just a special return type for `ensureConnected`
+ private static class ConnectionException extends Exception {
+
+ }
+
+ private void ensureConnected() throws ConnectionException, InterruptedException {
+ if (device.getConnectionState() != ConnectionState.CONNECTED) {
+ if (device.getConnectionState() != ConnectionState.CONNECTING && !device.connect()) {
+ logger.debug("Connection attempt failed to start for device {}", device.getAddress());
+ // something failed, so we abandon connection discovery
+ throw new ConnectionException();
+ }
+ if (!awaitConnection(10, TimeUnit.SECONDS)) {
+ logger.debug("Connection to device {} timed out", device.getAddress());
+ throw new ConnectionException();
+ }
+ if (!servicesDiscovered) {
+ device.discoverServices();
+ if (!awaitServiceDiscovery(10, TimeUnit.SECONDS)) {
+ logger.debug("Service discovery for device {} timed out", device.getAddress());
+ // something failed, so we abandon connection discovery
+ throw new ConnectionException();
+ }
+ }
+ readDeviceInformationIfMissing();
+ logger.debug("Device information fetched from the device: {}", device);
+ }
+ }
+
private @Nullable DiscoveryResult findConnectionResult(List connectionParticipants) {
try {
device.addListener(this);
for (BluetoothDiscoveryParticipant participant : connectionParticipants) {
// we call this every time just in case a participant somehow closes the connection
- if (device.getConnectionState() != ConnectionState.CONNECTED) {
- if (device.getConnectionState() != ConnectionState.CONNECTING && !device.connect()) {
- logger.debug("Connection attempt failed to start for device {}", device.getAddress());
- // something failed, so we abandon connection discovery
- return null;
- }
- if (!awaitConnection(1, TimeUnit.SECONDS)) {
- logger.debug("Connection to device {} timed out", device.getAddress());
- return null;
- }
- if (!servicesDiscovered) {
- device.discoverServices();
- if (!awaitServiceDiscovery(10, TimeUnit.SECONDS)) {
- logger.debug("Service discovery for device {} timed out", device.getAddress());
- // something failed, so we abandon connection discovery
- return null;
- }
- }
- readDeviceInformationIfMissing();
- logger.debug("Device information fetched from the device: {}", device);
- }
-
+ ensureConnected();
try {
DiscoveryResult result = participant.createResult(device);
if (result != null) {
@@ -199,7 +210,7 @@ public class BluetoothDiscoveryProcess implements Supplier, Blu
logger.warn("Participant '{}' threw an exception", participant.getClass().getName(), e);
}
}
- } catch (InterruptedException e) {
+ } catch (InterruptedException | ConnectionException e) {
// do nothing
} finally {
device.removeListener(this);
diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/internal/BluetoothHandlerFactory.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/internal/BluetoothHandlerFactory.java
index 18c7bd8f71f..ec1ce94ab27 100644
--- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/internal/BluetoothHandlerFactory.java
+++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/internal/BluetoothHandlerFactory.java
@@ -19,7 +19,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BeaconBluetoothHandler;
import org.openhab.binding.bluetooth.BluetoothBindingConstants;
-import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@@ -39,7 +38,6 @@ public class BluetoothHandlerFactory extends BaseThingHandlerFactory {
private static final Set SUPPORTED_THING_TYPES_UIDS = new HashSet<>();
static {
SUPPORTED_THING_TYPES_UIDS.add(BluetoothBindingConstants.THING_TYPE_BEACON);
- SUPPORTED_THING_TYPES_UIDS.add(BluetoothBindingConstants.THING_TYPE_CONNECTED);
}
@Override
@@ -53,8 +51,6 @@ public class BluetoothHandlerFactory extends BaseThingHandlerFactory {
if (thingTypeUID.equals(BluetoothBindingConstants.THING_TYPE_BEACON)) {
return new BeaconBluetoothHandler(thing);
- } else if (thingTypeUID.equals(BluetoothBindingConstants.THING_TYPE_CONNECTED)) {
- return new ConnectedBluetoothHandler(thing);
}
return null;
}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 8e46d1fc461..51e709f2d97 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -59,6 +59,7 @@
org.openhab.binding.bluetooth.bluez
org.openhab.binding.bluetooth.blukii
org.openhab.binding.bluetooth.daikinmadoka
+ org.openhab.binding.bluetooth.generic
org.openhab.binding.bluetooth.roaming
org.openhab.binding.bluetooth.ruuvitag
org.openhab.binding.boschindego
diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml
index eeeabd0fd1d..9180ad4c761 100644
--- a/features/openhab-addons/src/main/resources/footer.xml
+++ b/features/openhab-addons/src/main/resources/footer.xml
@@ -6,12 +6,13 @@
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.airthings/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.am43/${project.version}
- mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.blukii/${project.version}
- mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.bluez/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.bluegiga/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.blukii/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}
openhab-runtime-base