[bluetooth.generic] Added support for generic bluetooth devices (#8775)

* Generic Bluetooth Binding Initial Contribution

Signed-off-by: Connor Petty <mistercpp2000+gitsignoff@gmail.com>
This commit is contained in:
Connor Petty 2020-11-23 01:43:44 -08:00 committed by GitHub
parent 0c30d90757
commit fb7fcd886d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1808 additions and 142 deletions

View File

@ -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

View File

@ -126,6 +126,11 @@
<artifactId>org.openhab.binding.bluetooth.daikinmadoka</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth.generic</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth.roaming</artifactId>

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.bluetooth.generic</artifactId>
<name>openHAB Add-ons :: Bundles :: Generic Bluetooth Adapter</name>
<dependencies>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.sputnikdev</groupId>
<artifactId>bluetooth-gatt-parser</artifactId>
<version>1.9.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.9.3</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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
-->
<features name="org.openhab.binding.bluetooth.generic-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-bluetooth-generic" description="Bluetooth Binding Generic" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
</feature>
</features>

View File

@ -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 <T extends State> @Nullable T convert(State state, Class<T> typeClass) {
return state.as(typeClass);
}
}

View File

@ -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<ArealDensity> KILOGRAM_PER_SQUARE_METER = addUnit(
new ProductUnit<ArealDensity>(Units.KILOGRAM.divide(Units.SQUARE_METRE)));
public static final Unit<RadiationExposure> COULOMB_PER_KILOGRAM = addUnit(
new ProductUnit<RadiationExposure>(Units.COULOMB.divide(Units.KILOGRAM)));
public static final Unit<RadiationDoseAbsorptionRate> GRAY_PER_SECOND = addUnit(
new ProductUnit<RadiationDoseAbsorptionRate>(Units.GRAY.divide(Units.SECOND)));
public static final Unit<Mass> POUND = addUnit(
new TransformedUnit<Mass>(Units.KILOGRAM, new MultiplyConverter(0.45359237)));
public static final Unit<Angle> MINUTE_ANGLE = addUnit(new TransformedUnit<Angle>(Units.RADIAN,
new PiMultiplierConverter().concatenate(new RationalConverter(1, 180 * 60))));
public static final Unit<Angle> SECOND_ANGLE = addUnit(new TransformedUnit<Angle>(Units.RADIAN,
new PiMultiplierConverter().concatenate(new RationalConverter(1, 180 * 60 * 60))));
public static final Unit<Area> HECTARE = addUnit(Units.SQUARE_METRE.multiply(10000.0));
public static final Unit<Area> BARN = addUnit(Units.SQUARE_METRE.multiply(10E-28));
public static final Unit<Length> NAUTICAL_MILE = addUnit(SIUnits.METRE.multiply(1852.0));
public static final Unit<RadiantIntensity> WATT_PER_STERADIAN = addUnit(
new ProductUnit<RadiantIntensity>(Units.WATT.divide(Units.STERADIAN)));
public static final Unit<Radiance> WATT_PER_STERADIAN_PER_SQUARE_METRE = addUnit(
new ProductUnit<Radiance>(WATT_PER_STERADIAN.divide(Units.SQUARE_METRE)));
public static final Unit<Frequency> CYCLES_PER_MINUTE = addUnit(new TransformedUnit<Frequency>(Units.HERTZ,
new RationalConverter(BigInteger.valueOf(60), BigInteger.ONE)));
public static final Unit<Angle> REVOLUTION = addUnit(new TransformedUnit<Angle>(Units.RADIAN,
new PiMultiplierConverter().concatenate(new RationalConverter(2, 1))));
public static final Unit<AngularVelocity> REVOLUTION_PER_MINUTE = addUnit(
new ProductUnit<AngularVelocity>(REVOLUTION.divide(Units.MINUTE)));
public static final Unit<Dimensionless> STEPS = addUnit(SmartHomeUnits.ONE.alternate("steps"));
public static final Unit<Dimensionless> BEATS = addUnit(SmartHomeUnits.ONE.alternate("beats"));
public static final Unit<Dimensionless> STROKE = addUnit(SmartHomeUnits.ONE.alternate("stroke"));
public static final Unit<Frequency> STEP_PER_MINUTE = addUnit(
new ProductUnit<Frequency>(STEPS.divide(Units.MINUTE)));
public static final Unit<Frequency> BEATS_PER_MINUTE = addUnit(
new ProductUnit<Frequency>(BEATS.divide(Units.MINUTE)));
public static final Unit<Frequency> STROKE_PER_MINUTE = addUnit(
new ProductUnit<Frequency>(STROKE.divide(Units.MINUTE)));
public static final Unit<MassFlowRate> GRAM_PER_SECOND = addUnit(
new ProductUnit<MassFlowRate>(Units.GRAM.divide(Units.SECOND)));
public static final Unit<LuminousEfficacy> LUMEN_PER_WATT = addUnit(
new ProductUnit<LuminousEfficacy>(Units.LUMEN.divide(Units.WATT)));
public static final Unit<LuminousEnergy> LUMEN_SECOND = addUnit(
new ProductUnit<LuminousEnergy>(Units.LUMEN.multiply(Units.SECOND)));
public static final Unit<LuminousEnergy> LUMEN_HOUR = addUnit(
new ProductUnit<LuminousEnergy>(Units.LUMEN.multiply(Units.HOUR)));
public static final Unit<ElectricCharge> AMPERE_HOUR = addUnit(
new ProductUnit<ElectricCharge>(Units.AMPERE.multiply(Units.HOUR)));
public static final Unit<LuminousExposure> LUX_HOUR = addUnit(
new ProductUnit<LuminousExposure>(Units.LUX.multiply(Units.HOUR)));
public static final Unit<Speed> KILOMETRE_PER_MINUTE = addUnit(Units.KILOMETRE_PER_HOUR.multiply(60.0));
public static final Unit<VolumetricFlowRate> LITRE_PER_SECOND = addUnit(
new ProductUnit<VolumetricFlowRate>(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 extends Unit<?>> U addUnit(U unit) {
return unit;
}
public interface AngularVelocity extends Quantity<AngularVelocity> {
}
public interface LuminousEnergy extends Quantity<LuminousEnergy> {
}
public interface LuminousEfficacy extends Quantity<LuminousEfficacy> {
}
public interface LuminousExposure extends Quantity<LuminousExposure> {
}
public interface RadiantIntensity extends Quantity<RadiantIntensity> {
}
public interface Radiance extends Quantity<Radiance> {
}
public interface RadiationExposure extends Quantity<RadiationExposure> {
}
public interface RadiationDoseAbsorptionRate extends Quantity<RadiationDoseAbsorptionRate> {
}
public interface MassFlowRate extends Quantity<MassFlowRate> {
}
}
}

View File

@ -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<ChannelTypeUID, ChannelType> cache = new ConcurrentHashMap<>();
private final BluetoothGattParser gattParser = BluetoothGattParserFactory.getDefault();
@Override
public Collection<ChannelType> 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<Field> 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<StateOption> 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<StateOption> 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;
}
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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<BluetoothCharacteristic, CharacteristicHandler> charHandlers = new ConcurrentHashMap<>();
private final Map<ChannelUID, CharacteristicHandler> 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<Channel> channels = device.getServices().stream()//
.flatMap(service -> service.getCharacteristics().stream())//
.flatMap(characteristic -> {
logger.trace("{} processing characteristic {}", address, characteristic.getUuid());
CharacteristicHandler handler = getCharacteristicHandler(characteristic);
List<Channel> 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<Channel> buildChannels() {
List<Channel> channels = new ArrayList<>();
String charUUID = getCharacteristicUUID();
Characteristic gattChar = gattParser.getCharacteristic(charUUID);
if (gattChar != null) {
List<Field> 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<String, List<Field>> fieldsMapping = fields.stream().collect(Collectors.groupingBy(Field::getName));
for (List<Field> 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<String, String> getChannelProperties(@Nullable String fieldName) {
Map<String, String> 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;
}
}
}

View File

@ -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<ThingTypeUID> 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;
}
}

View File

@ -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<ThingTypeUID> 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<String, Object> 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<String, Object> 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;
}
}

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="bluetooth"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="generic">
<label>Generic Bluetooth Device</label>
<description>A generic bluetooth device that supports GATT characteristics</description>
<config-description>
<parameter name="address" type="text">
<label>Address</label>
<description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
</parameter>
<parameter name="pollingInterval" type="integer" unit="s">
<advanced>true</advanced>
<label>Polling Interval</label>
<description>The frequency at which readable characteristics refreshed</description>
<default>30</default>
</parameter>
</config-description>
</thing-type>
<channel-type id="char-unknown">
<item-type>String</item-type>
<label>Unknown Bluetooth Characteristic</label>
<description>The raw value of unknown characteristics are represented with hexadecimal</description>
</channel-type>
</thing:thing-descriptions>

View File

@ -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)));
}
}

View File

@ -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();
}
}

View File

@ -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`):

View File

@ -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";

View File

@ -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.
*

View File

@ -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);
}
}

View File

@ -91,4 +91,14 @@ public interface BluetoothDiscoveryParticipant {
BiConsumer<BluetoothAdapter, DiscoveryResult> 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;
}
}

View File

@ -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<DiscoveryResult>, Blu
@Override
public DiscoveryResult get() {
List<BluetoothDiscoveryParticipant> 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<BluetoothDiscoveryParticipant> 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<DiscoveryResult>, 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<DiscoveryResult>, 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<DiscoveryResult>, 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<BluetoothDiscoveryParticipant> 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<DiscoveryResult>, Blu
logger.warn("Participant '{}' threw an exception", participant.getClass().getName(), e);
}
}
} catch (InterruptedException e) {
} catch (InterruptedException | ConnectionException e) {
// do nothing
} finally {
device.removeListener(this);

View File

@ -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<ThingTypeUID> 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;
}

View File

@ -59,6 +59,7 @@
<module>org.openhab.binding.bluetooth.bluez</module>
<module>org.openhab.binding.bluetooth.blukii</module>
<module>org.openhab.binding.bluetooth.daikinmadoka</module>
<module>org.openhab.binding.bluetooth.generic</module>
<module>org.openhab.binding.bluetooth.roaming</module>
<module>org.openhab.binding.bluetooth.ruuvitag</module>
<module>org.openhab.binding.boschindego</module>

View File

@ -6,12 +6,13 @@
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.airthings/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.am43/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.blukii/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.bluez/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.bluegiga/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.blukii/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>
</feature>
<feature name="openhab-binding-mqtt" description="MQTT Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>