[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
26 changed files with 1808 additions and 142 deletions

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