diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b57571a..a42c322 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -5,7 +5,7 @@ on: branches: [ "*" ] push: branches: - - "!master" + - "!master" jobs: @@ -19,19 +19,28 @@ jobs: name: Build OH4 (Java ${{ matrix.java }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: ${{matrix.java}} - distribution: 'temurin' - - name: Cache local Maven repository - uses: actions/cache@v3 - with: - path: ~/.m2/repository - key: ${{ runner.java }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.java }}-maven- + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: ${{matrix.java}} + distribution: 'temurin' + - name: Cache local Maven repository + uses: actions/cache@v3 + with: + path: ~/.m2/repository + key: ${{ runner.java }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.java }}-maven- - - name: Build - run: mvn -P 'standalone' --batch-mode -DskipChecks -Doh.java.version=17 clean verify + - name: Build + run: mvn -P 'standalone' --batch-mode -DskipChecks -Doh.java.version=17 clean verify + + - uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + automatic_release_tag: ${{ github.head_ref || github.ref_name }} + prerelease: true + title: "Latest Build" + files: | + target/*.jar diff --git a/README.md b/README.md index 0789de6..cf667da 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,120 @@ Read more here: https://esphome.io/components/api#advantages-over-mqtt > **Note:** At the current state of the binding, it is highly recommended to use file based configuration for things and > items as channel types etc most likely will change. -## Streaming device logs +## Discovery + +The binding uses mDNS to automatically discover devices on the network. + +## Thing Configuration + +### `device` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------|----------| +| `hostname` | `text` | Hostname or IP address of the device. Typically something like 'myboard.local' | N/A | yes | no | +| `port` | `integer` | IP Port of the device | 6053 | no | no | +| `encryptionKey` | `text` | Encryption key as defined in `api: encryption: key: `. See https://esphome.io/components/api#configuration-variables | N/A | no | no | +| ~~`password`~~ | `text` | Password to access the device if password protected. **DEPRECATED. Use `encryptionKey` instead** | N/A | no | no | +| `enableBluetoothProxy` | `boolean` | Allow this device to proxy Bluetooth traffic. Requires ESPHome device to be configured with `bluetooth_proxy` | false | no | no | +| `pingInterval` | `integer` | Seconds between sending ping requests to device to check if alive | 10 | no | yes | +| `maxPingTimeouts` | `integer` | Number of missed ping requests before deeming device unresponsive. | 4 | no | yes | +| `server` | `text` | Expected name of ESPHome. Used to ensure that we're communicating with the correct device. Use value from `esphome.name` in ESPHome device configuration | | no | yes | +| `logPrefix` | `text` | Log prefix to use for this device. | hostname | no | yes | +| `deviceLogLevel` | `text` | ESPHome device log level to stream from the device. | NONE | no | yes | + +## Channels + +Channels are auto-generated based on actual device configuration. + +## Full Example file example + +### Thing Configuration for ESPHome device + +``` +esphome:device:esp1 "ESPHome Test card 1" [ hostname="testkort1.local", encryptionKey="JVWAgubY1nCe3x/5xeyMBfaN9y68OOUMh5dACIeVmjk=", pingInterval=10, maxPingTimeouts=4, server="esphomename", logPrefix="esp1", deviceLogLevel="INFO"] +``` + +### Item Configuration + +``` +Number:Temperature ESP1_Temperature "Temperature" {channel="esphome:device:esp1:temperature"} +Number:Dimensionless ESP1_Humidity "Humidity" {channel="esphome:device:esp1:humidity"} +Switch ESP1_Switch "Relay" {channel="esphome:device:esp1:relay_4"} +``` + +## Bluetooth proxy support + +It is now possible to utilize the built-in Bluetooth proxy in ESPHome. This allows you to use ESPHome devices as proxies +for other Bluetooth devices such as BTHome sensors or a range of other Bluetooth devices. + +> NOTE: Only beacons / devices broadcasting data are supported at the moment. Connectable devices will be supported in a +> future release. + +The feature is still experimental and may not work as expected. + +1. Configure the ESPHome device with the `bluetooth_proxy` component. See https://esphome.io/components/bluetooth_proxy + +```yaml +bluetooth_proxy: + active: true +``` + +2. Configure the ESPHome `device` in openHAB with `enableBluetoothProxy = true` + +```yaml +esphome:device:esp1 "ESPHome Test card 1" [ ... enableBluetoothProxy=true ] +``` + +3. Configure a Bluetooth Proxy bridge of type `esphome` + +This is the standard configuration for any type of Bluetooth adapter in openHAB (not documented elsewhere) + +| Name | Type | Description | Default | Required | Advanced | +|----------------------------------|-----------|-----------------------------------------------------------------------------|---------|----------|----------| +| `backgroundDiscovery` | `boolean` | Add discovered device automatically to tihe inbox in the background | false | no | no | +| `inactiveDeviceCleanupInterval` | `integer` | Number of seconds of Bluetooth device inactivity before removing from inbox | 60 | no | no | +| `inactiveDeviceCleanupThreshold` | `integer` | | 300 | no | no | + +``` +Bridge bluetooth:esphome:proxy "ESPHome BLE Advertisement listener" [backgroundDiscovery = false] { + bthome parasite1 "b-Parasite #4354" [address="XX:XX:XX:XX:18:91", expectedReportingIntervalSeconds = 600] +} +``` + +> **NOTE:** Set backgroundDiscovery to true if you want to automatically add discovered devices to the inbox. If not use +> manual +> scanning from the inbox. + +## FAQ + +- I get warnings + like `No device_class reported by sensor ''. Add device_class to sensor configuration in ESPHome. Defaulting to plain Number without dimension` + + > This is because the ESP sensor does not report a `device_class`. This field is used to determine item and category + > type in openHAB. + > Solution: Specify a `device_class` to your ESPHome configuration. Example:
+ > ![img.png](esphomeconfig_deviceclass.png) + >
See https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes for valid + device_class values (**use lowercase values**) + > Also note that you may override default device_class by specifying `device_class: ""` to remove any device class + from the sensor. + +Also see https://community.openhab.org/t/esphome-binding-for-the-native-api/146849/1 for more information. + +## Limitations as of 2024-06-17 + +Most entity types and functions are now supported. However, there are some limitations: + +The following entity types are **not** yet supported (please submit a PR of file a feature request!) + +- `lock`, +- `camera` +- `voice` +- `valve` + +In addition, the Bluetooth proxy isn't fully ready yet. + +## Streaming logs from ESPHome device to openHAB As an alternative to manually streaming device logs via ESPHome dashboard, you can have openHAB stream the device logs directly to openHAB - which will write them using the standard log system. @@ -67,10 +180,14 @@ log:set INFO ESPHOMEDEVICE This will produce logs on level `INFO` in the openHAB logs like this: ``` + [2024-04-04 15:06:25.822] [varmtvann] [D][dallas.sensor:143]: 'VV Temp bunn': Got Temperature=21.0°C -[2024-04-04 15:06:25.834] [varmtvann] [D][sensor:094]: 'VV Temp bunn': Sending state 21.00000 °C with 1 decimals of accuracy +[2024-04-04 15:06:25.834] [varmtvann] [D][sensor:094]: 'VV Temp bunn': Sending state 21.00000 °C with 1 decimals of +accuracy [2024-04-04 15:06:25.850] [varmtvann] [D][dallas.sensor:143]: 'VV Temp midt': Got Temperature=71.7°C -[2024-04-04 15:06:25.863] [varmtvann] [D][sensor:094]: 'VV Temp midt': Sending state 71.68750 °C with 1 decimals of accuracy +[2024-04-04 15:06:25.863] [varmtvann] [D][sensor:094]: 'VV Temp midt': Sending state 71.68750 °C with 1 decimals of +accuracy + ``` To redirect device logs to a separate log file, edit your `log4j.xml` file and add the following in the `` @@ -173,71 +290,3 @@ sensor: icon: "mdi:counter" ``` -## FAQ - -- I get warnings - like `No device_class reported by sensor ''. Add device_class to sensor configuration in ESPHome. Defaulting to plain Number without dimension` - - > This is because the ESP sensor does not report a `device_class`. This field is used to determine item and category - > type in openHAB. - > Solution: Specify a `device_class` to your ESPHome configuration. Example:
- > ![img.png](esphomeconfig_deviceclass.png) - >
See https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes for valid - device_class values (**use lowercase values**) - > Also note that you may override default device_class by specifying `device_class: ""` to remove any device class - from the sensor. - -Also see https://community.openhab.org/t/esphome-binding-for-the-native-api/146849/1 for more information. - -## Limitations as of 2024-06-17 - -Most entity types and functions are now supported. However, there are some limitations: - -The following entity types are **not** yet supported (please submit a PR of file a feature request!) - -- `lock`, -- `camera` -- `voice` -- `valve` - -In addition, the Bluetooth proxy isn't ready yet. - -## Discovery - -The binding uses mDNS to automatically discover devices on the network. - -## Thing Configuration - -### `device` Thing Configuration - -| Name | Type | Description | Default | Required | Advanced | -|-------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------|----------|----------|----------| -| `hostname` | `text` | Hostname or IP address of the device. Typically something like 'myboard.local' | N/A | yes | no | -| `port` | `integer` | IP Port of the device | 6053 | no | no | -| `encryptionKey` | `text` | Encryption key as defined in `api: encryption: key: `. See https://esphome.io/components/api#configuration-variables | N/A | no | no | -| ~~`password`~~ | `text` | Password to access the device if password protected. **DEPRECATED. Use `encryptionKey` instead** | N/A | no | no | -| `pingInterval` | `integer` | Seconds between sending ping requests to device to check if alive | 10 | no | yes | -| `maxPingTimeouts` | `integer` | Number of missed ping requests before deeming device unresponsive. | 4 | no | yes | -| `server` | `text` | Expected name of ESPHome. Used to ensure that we're communicating with the correct device | | no | yes | -| `logPrefix` | `text` | Log prefix to use for this device. | hostname | no | yes | -| `deviceLogLevel` | `text` | ESPHome device log level to stream from the device. | NONE | no | yes | - -## Channels - -Channels are auto-generated based on actual device configuration. - -## Full Example - -### Thing Configuration - -``` -esphome:device:esp1 "ESPHome Test card 1" [ hostname="testkort1.local", encryptionKey="JVWAgubY1nCe3x/5xeyMBfaN9y68OOUMh5dACIeVmjk=", pingInterval=10, maxPingTimeouts=4, server="esphomename", logPrefix="esp1", deviceLogLevel="INFO"] -``` - -### Item Configuration - -``` -Number:Temperature ESP1_Temperature "Temperature" {channel="esphome:device:esp1:temperature"} -Number:Dimensionless ESP1_Humidity "Humidity" {channel="esphome:device:esp1:humidity"} -Switch ESP1_Switch "Relay" {channel="esphome:device:esp1:relay_4"} -``` diff --git a/pom.xml b/pom.xml index 98649b4..44f9918 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,13 @@ provided + + org.openhab.addons.bundles + org.openhab.binding.bluetooth + ${project.parent.version} + provided + + org.slf4j @@ -149,6 +156,7 @@ com.google.protobuf:protoc:${proto.version}:exe:${os.detected.classifier} false true + true diff --git a/src/main/feature/feature.xml b/src/main/feature/feature.xml index 702144c..b246141 100644 --- a/src/main/feature/feature.xml +++ b/src/main/feature/feature.xml @@ -6,6 +6,7 @@ openhab-runtime-base openhab-transport-mdns - mvn:org.openhab.addons.bundles/no.seime.openhab.binding.esphome/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version} + mvn:org.openhab.addons.bundles/no.seime.openhab.binding.esphome/${project.version} diff --git a/src/main/history/dependencies.xml b/src/main/history/dependencies.xml index 5752a29..fb53a54 100644 --- a/src/main/history/dependencies.xml +++ b/src/main/history/dependencies.xml @@ -6,6 +6,7 @@ wrap mvn:com.google.protobuf/protobuf-java/3.25.2 mvn:org.openhab.addons.bundles/no.seime.openhab.binding.esphome/4.1.0-SNAPSHOT + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/4.1.0-SNAPSHOT wrap:mvn:org.lastnpe.eea/eea-all/2.2.1 diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/BindingConstants.java b/src/main/java/no/seime/openhab/binding/esphome/internal/BindingConstants.java index 657e8eb..4d2ea38 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/BindingConstants.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/BindingConstants.java @@ -28,6 +28,7 @@ public class BindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + public static final ThingTypeUID THING_TYPE_BLE_PROXY = new ThingTypeUID("bluetooth", "esphome"); public static final String COMMAND_KEY = "command_key"; public static final String COMMAND_CLASS = "command_class"; diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/ESPHomeConfiguration.java b/src/main/java/no/seime/openhab/binding/esphome/internal/ESPHomeConfiguration.java index a3fd960..9b2f952 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/ESPHomeConfiguration.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/ESPHomeConfiguration.java @@ -41,4 +41,6 @@ public class ESPHomeConfiguration { public String logPrefix; public LogLevel deviceLogLevel = LogLevel.NONE; + + public boolean enableBluetoothProxy = false; } diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtil.java b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtil.java new file mode 100644 index 0000000..859ce07 --- /dev/null +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtil.java @@ -0,0 +1,33 @@ +package no.seime.openhab.binding.esphome.internal.bluetooth; + +import java.util.HexFormat; + +import org.openhab.binding.bluetooth.BluetoothAddress; + +public class BluetoothAddressUtil { + public static BluetoothAddress createAddress(long address) { + String hexDigits = HexFormat.of().toHexDigits(address); + + StringBuilder addressBuilder = new StringBuilder(); + + // Skip first 2 bytes as addresses are 48 bits and not 64 bits + for (int i = 4; i < hexDigits.length(); i += 2) { + addressBuilder.append(hexDigits.substring(i, i + 2)); + if (i < hexDigits.length() - 2) { + addressBuilder.append(":"); + } + } + + return new BluetoothAddress(addressBuilder.toString().toUpperCase()); + } + + public static long convertAddressToLong(BluetoothAddress address) { + String[] parts = address.toString().split(":"); + long result = 0; + for (int i = 0; i < parts.length; i++) { + result = result << 8; + result |= Integer.parseInt(parts[i], 16); + } + return result; + } +} diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothDevice.java b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothDevice.java new file mode 100644 index 0000000..3ba0cd5 --- /dev/null +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothDevice.java @@ -0,0 +1,185 @@ +package no.seime.openhab.binding.esphome.internal.bluetooth; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.bluetooth.*; +import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification; +import org.openhab.binding.bluetooth.notification.BluetoothScanNotification; + +import io.esphome.api.*; +import no.seime.openhab.binding.esphome.internal.handler.ESPHomeHandler; + +@NonNullByDefault +public class ESPHomeBluetoothDevice extends BaseBluetoothDevice { + /** + * Construct a Bluetooth device taking the Bluetooth address + * + * @param adapter + * @param address + */ + + @Nullable + private ESPHomeHandler lockToHandler; + + private final ESPHomeBluetoothProxyHandler proxyHandler; + + public void setAddressType(int addressType) { + this.addressType = addressType; + } + + private int addressType; + + public ESPHomeBluetoothDevice(BluetoothAdapter adapter, BluetoothAddress address) { + super(adapter, address); + proxyHandler = (ESPHomeBluetoothProxyHandler) adapter; + } + + public void handleAdvertisementPacket(BluetoothLEAdvertisementResponse packet) { + + BluetoothScanNotification notification = new BluetoothScanNotification(); + packet.getServiceDataList().stream().forEach(serviceData -> { + + notification.getServiceData().put(to128BitUUID(serviceData.getUuid()), serviceData.getData().toByteArray()); + }); + notification.setBeaconType(BluetoothScanNotification.BluetoothBeaconType.BEACON_ADVERTISEMENT); + packet.getManufacturerDataList().stream().findFirst().ifPresent(manufacturerData -> { + notification.setManufacturerData(manufacturerData.getData().toByteArray()); + }); + notification.setRssi(packet.getRssi()); + notification.setDeviceName(packet.getName()); + + notifyListeners(BluetoothEventType.SCAN_RECORD, notification); + } + + public void handleConnectionsMessage(BluetoothDeviceConnectionResponse rsp) { + notifyListeners(BluetoothEventType.CONNECTION_STATE, new BluetoothConnectionStatusNotification( + rsp.getConnected() ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED)); + + if (!rsp.getConnected()) { + proxyHandler.unlinkDevice(this); + lockToHandler = null; + } + } + + public void handleGattServicesMessage(BluetoothGATTGetServicesResponse rsp) { + + for (BluetoothGATTService service : rsp.getServicesList()) { + int handle = service.getHandle(); + for (int i = 0; i < service.getUuidCount(); i++) { + long uuid = service.getUuid(i); + BluetoothGATTCharacteristic characteristics = service.getCharacteristics(i); + + BluetoothService ohService = new BluetoothService(new UUID(uuid, 0), false, handle, handle); // TODO? + for (int j = 0; j < characteristics.getUuidCount(); j++) { + long charUuid = characteristics.getUuid(j); + BluetoothCharacteristic ohCharacteristic = new BluetoothCharacteristic(new UUID(charUuid, 0), + handle); // TODO? + ohService.addCharacteristic(ohCharacteristic); + } + + notifyListeners(BluetoothEventType.SERVICES_DISCOVERED, ohService); + } + } + } + + public void handleGattServicesDoneMessage(BluetoothGATTGetServicesDoneResponse rsp) { + notifyListeners(BluetoothEventType.CONNECTION_STATE, + new BluetoothConnectionStatusNotification(ConnectionState.DISCOVERED)); + } + + private String to128BitUUID(String UUID16bit) { + String uuid = "0000" + UUID16bit.substring(2) + "-0000-1000-8000-00805F9B34FB"; // Trim 0x + return uuid.toLowerCase(); + } + + @Override + public boolean connect() { + ESPHomeHandler nearestESPHomeDevice = proxyHandler + .getNearestESPHomeDevice(BluetoothAddressUtil.convertAddressToLong(address)); + if (nearestESPHomeDevice != null) { + lockToHandler = nearestESPHomeDevice; + proxyHandler.linkDevice(this, nearestESPHomeDevice); + + // Connect to the device + // notifyListeners(BluetoothEventType.CONNECTION_STATE, + // new BluetoothConnectionStatusNotification(ConnectionState.CONNECTING)); + lockToHandler.sendBluetoothCommand(BluetoothDeviceRequest.newBuilder() + .setAddress(BluetoothAddressUtil.convertAddressToLong(address)).setAddressType(addressType) + .setRequestType(BluetoothDeviceRequestType.BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE) + .build()); + + return true; + } + + return true; + } + + @Override + public boolean disconnect() { + if (lockToHandler != null) { + // Disconnect from the device + + // notifyListeners(BluetoothEventType.CONNECTION_STATE, + // new BluetoothConnectionStatusNotification(ConnectionState.DISCONNECTING)); + lockToHandler.sendBluetoothCommand(BluetoothDeviceRequest.newBuilder() + .setAddress(BluetoothAddressUtil.convertAddressToLong(address)) + .setRequestType(BluetoothDeviceRequestType.BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT).build()); + + return true; + + } + return false; + } + + @Override + public boolean discoverServices() { + notifyListeners(BluetoothEventType.CONNECTION_STATE, + new BluetoothConnectionStatusNotification(ConnectionState.DISCOVERING)); + lockToHandler.sendBluetoothCommand(BluetoothGATTGetServicesRequest.newBuilder() + .setAddress(BluetoothAddressUtil.convertAddressToLong(address)).build()); + return true; + } + + @Override + public CompletableFuture readCharacteristic(BluetoothCharacteristic characteristic) { + + lockToHandler.sendBluetoothCommand( + BluetoothGATTReadRequest.newBuilder().setAddress(BluetoothAddressUtil.convertAddressToLong(address)) + .setHandle(characteristic.getHandle()).build()); + + return CompletableFuture.failedFuture(new RuntimeException("Not implemented")); + } + + @Override + public CompletableFuture writeCharacteristic(BluetoothCharacteristic characteristic, byte[] value) { + return CompletableFuture.failedFuture(new RuntimeException("Not implemented")); + } + + @Override + public boolean isNotifying(BluetoothCharacteristic characteristic) { + return false; + } + + @Override + public CompletableFuture enableNotifications(BluetoothCharacteristic characteristic) { + return CompletableFuture.failedFuture(new RuntimeException("Not implemented")); + } + + @Override + public CompletableFuture disableNotifications(BluetoothCharacteristic characteristic) { + return CompletableFuture.failedFuture(new RuntimeException("Not implemented")); + } + + @Override + public boolean enableNotifications(BluetoothDescriptor descriptor) { + return false; + } + + @Override + public boolean disableNotifications(BluetoothDescriptor descriptor) { + return false; + } +} diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothProxyHandler.java b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothProxyHandler.java new file mode 100644 index 0000000..853b4e6 --- /dev/null +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/bluetooth/ESPHomeBluetoothProxyHandler.java @@ -0,0 +1,289 @@ +package no.seime.openhab.binding.esphome.internal.bluetooth; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.bluetooth.AbstractBluetoothBridgeHandler; +import org.openhab.binding.bluetooth.BluetoothAddress; +import org.openhab.core.thing.*; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.protobuf.GeneratedMessageV3; + +import io.esphome.api.BluetoothDeviceConnectionResponse; +import io.esphome.api.BluetoothGATTGetServicesDoneResponse; +import io.esphome.api.BluetoothGATTGetServicesResponse; +import io.esphome.api.BluetoothLEAdvertisementResponse; +import no.seime.openhab.binding.esphome.internal.BindingConstants; +import no.seime.openhab.binding.esphome.internal.handler.ESPHomeHandler; + +@NonNullByDefault +public class ESPHomeBluetoothProxyHandler extends AbstractBluetoothBridgeHandler { + + private final ThingRegistry thingRegistry; + + @Nullable + private ScheduledFuture registrationFuture; + + private final Logger logger = LoggerFactory.getLogger(ESPHomeBluetoothProxyHandler.class); + + private List espHomeHandlers = new ArrayList<>(); + + private final LoadingCache> cache; + + private Map> knownDevices = new ConcurrentHashMap<>(); + + private Map connectionMap = new ConcurrentHashMap<>(); + + /** + * Creates a new instance of this class for the {@link Thing}. + * + * @param bridge the thing that should be handled, not null + * @param thingRegistry + */ + public ESPHomeBluetoothProxyHandler(Bridge bridge, ThingRegistry thingRegistry) { + super(bridge); + this.thingRegistry = thingRegistry; + CacheLoader> loader; + loader = new CacheLoader<>() { + + @Override + public Optional load(Long key) { + return Optional.empty(); + } + }; + + cache = CacheBuilder.newBuilder().expireAfterAccess(5, TimeUnit.SECONDS).maximumSize(1000).build(loader); + } + + @Override + public void initialize() { + + super.initialize(); + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Looking for BLE enabled ESPHome devices"); + + registrationFuture = scheduler.scheduleWithFixedDelay(this::updateESPHomeDeviceList, 0, 5, TimeUnit.SECONDS); + } + + @Override + public void dispose() { + registrationFuture.cancel(true); + espHomeHandlers.forEach(ESPHomeHandler::stopListeningForBLEAdvertisements); + espHomeHandlers.clear(); + super.dispose(); + } + + private synchronized void updateESPHomeDeviceList() { + + // Get all ESPHome devices + // For each device, check if it has BLE enabled, is enabled and ONLINE + // If so, enable registration of BLE advertisements + // If not, remove from list of devices + + // First clean up any disposed handlers or non-ONLINE handlers + List inactiveHandlers = espHomeHandlers.stream() + .filter(handler -> handler.isDisposed() || !handler.getThing().getStatus().equals(ThingStatus.ONLINE)) + .toList(); + espHomeHandlers.removeAll(inactiveHandlers); + inactiveHandlers.stream().forEach(handler -> { + try { + handler.stopListeningForBLEAdvertisements(); + } catch (Exception e) { + // Swallow + } + }); + + List esphomeThings = thingRegistry.stream() + .filter(thing -> thing.getThingTypeUID().equals(BindingConstants.THING_TYPE_DEVICE)).toList(); + for (Thing esphomeThing : esphomeThings) { + if (esphomeThing.isEnabled() && esphomeThing.getStatus().equals(ThingStatus.ONLINE)) { + if (esphomeThing.getConfiguration().get("enableBluetoothProxy") == Boolean.TRUE) { + // Enable registration of BLE advertisements + + ESPHomeHandler handler = (ESPHomeHandler) esphomeThing.getHandler(); + if (handler != null) { + if (!espHomeHandlers.contains(handler)) { + handler.listenForBLEAdvertisements(this); + espHomeHandlers.add(handler); + } + + } + + } + } + } + + if (espHomeHandlers.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, String.format( + "Found no ESPHome devices configured for Bluetooth proxy support. Make sure your ESPHome things are online and have the 'enableBluetoothProxy' option set to 'true'")); + + } else { + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, String + .format("Found %d ESPHome devices configured for Bluetooth proxy support", espHomeHandlers.size())); + } + logger.debug("List of {} ESPHome devices: {}", espHomeHandlers.size(), + espHomeHandlers.stream().map(e -> e.getThing().getUID()).toList()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + @Override + protected ESPHomeBluetoothDevice createDevice(BluetoothAddress address) { + return new ESPHomeBluetoothDevice(this, address); + } + + public void handleBluetoothMessage(@NonNull GeneratedMessageV3 message, ESPHomeHandler handler) { + + // Update RSSi list + + if (message instanceof BluetoothLEAdvertisementResponse advertisementResponse) { + updateDeviceLocation(advertisementResponse, handler); + handleAdvertisement(advertisementResponse, handler); + } else if (message instanceof BluetoothDeviceConnectionResponse rsp) { + Optional> deviceEntry = connectionMap.entrySet().stream() + .filter(device -> device.getKey().getAddress() + .equals(BluetoothAddressUtil.createAddress(rsp.getAddress()))) + .findFirst(); + + deviceEntry.ifPresent(device -> device.getKey().handleConnectionsMessage(rsp)); + } else if (message instanceof BluetoothGATTGetServicesResponse rsp) { + Optional> deviceEntry = connectionMap.entrySet().stream() + .filter(device -> device.getKey().getAddress() + .equals(BluetoothAddressUtil.createAddress(rsp.getAddress()))) + .findFirst(); + + deviceEntry.ifPresent(device -> device.getKey().handleGattServicesMessage(rsp)); + } else if (message instanceof BluetoothGATTGetServicesDoneResponse rsp) { + Optional> deviceEntry = connectionMap.entrySet().stream() + .filter(device -> device.getKey().getAddress() + .equals(BluetoothAddressUtil.createAddress(rsp.getAddress()))) + .findFirst(); + + deviceEntry.ifPresent(device -> device.getKey().handleGattServicesDoneMessage(rsp)); + } else + + { + logger.warn("Received unhandled Bluetooth packet type: {} from {}", message.getClass().getSimpleName(), + handler.getThing().getUID()); + } + } + + private void handleAdvertisement(BluetoothLEAdvertisementResponse rsp, ESPHomeHandler handler) { + try { + Optional cachedAdvertisement = cache.get(rsp.getAddress()); + if (cachedAdvertisement.isPresent() && equalsExceptRssi(rsp, cachedAdvertisement.get())) { + logger.debug("Received duplicate BLE advertisement from device {} via {}", rsp.getAddress(), + handler.getThing().getUID()); + return; + } else { + cache.put(rsp.getAddress(), Optional.of(rsp)); + } + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + + try { + BluetoothAddress address = BluetoothAddressUtil.createAddress(rsp.getAddress()); + ESPHomeBluetoothDevice device = getDevice(address); + + logger.debug("Received BLE advertisement from device {} via {}", address, handler.getThing().getUID()); + device.setAddressType(rsp.getAddressType()); + device.setName(rsp.getName()); + device.setRssi(rsp.getRssi()); + + rsp.getManufacturerDataList().stream().findFirst().ifPresent(manufacturerData -> { + String uuid = manufacturerData.getUuid(); + int manufacturerId = parseManufacturerIdToInt(uuid); + device.setManufacturerId(manufacturerId); + }); + + deviceDiscovered(device); + + device.handleAdvertisementPacket(rsp); + + } catch (Exception e) { + logger.warn("Error handling BLE advertisement", e); + } + } + + @Nullable + public ESPHomeHandler getNearestESPHomeDevice(long address) { + SortedSet deviceAndRSSIS = knownDevices.get(address); + if (deviceAndRSSIS == null || deviceAndRSSIS.isEmpty()) { + return null; + } + + ThingUID device = deviceAndRSSIS.first().device; + @Nullable + Thing esphomeThing = thingRegistry.get(device); + if (esphomeThing != null) { + return (ESPHomeHandler) esphomeThing.getHandler(); + } else { + return null; + } + } + + private void updateDeviceLocation(BluetoothLEAdvertisementResponse rsp, ESPHomeHandler handler) { + SortedSet deviceAndRSSIS = knownDevices.computeIfAbsent(rsp.getAddress(), + k -> new ConcurrentSkipListSet<>()); + deviceAndRSSIS.removeIf(e -> e.device.equals(handler.getThing().getUID())); // Remove previous entry for this + // esphome device + deviceAndRSSIS.add(new DeviceAndRSSI(handler.getThing().getUID(), rsp.getRssi(), Instant.now())); + } + + private boolean equalsExceptRssi(BluetoothLEAdvertisementResponse rsp1, BluetoothLEAdvertisementResponse rsp2) { + return rsp1.getAddress() == rsp2.getAddress() && rsp1.getName().equals(rsp2.getName()) + && rsp1.getManufacturerDataList().equals(rsp2.getManufacturerDataList()) + && rsp1.getServiceDataList().equals(rsp2.getServiceDataList()) + && rsp1.getServiceUuidsList().equals(rsp2.getServiceUuidsList()) + && rsp1.getAddressType() == rsp2.getAddressType(); + } + + private int parseManufacturerIdToInt(String uuid) { + byte[] bytes = HexFormat.of().parseHex(uuid.substring(2)); + int manufacturerId = (bytes[0] & 0xFF) << 8 | (bytes[1] & 0xFF); + logger.debug("Manufacturer UUID: {} -> {}", uuid, manufacturerId); + return manufacturerId; + } + + @Override + public @Nullable BluetoothAddress getAddress() { + return null; + } + + public void linkDevice(ESPHomeBluetoothDevice espHomeBluetoothDevice, ESPHomeHandler lockToHandler) { + connectionMap.put(espHomeBluetoothDevice, lockToHandler); + } + + public void unlinkDevice(ESPHomeBluetoothDevice espHomeBluetoothDevice) { + connectionMap.remove(espHomeBluetoothDevice); + } + + private static class DeviceAndRSSI implements Comparable { + private final ThingUID device; + private final int rssi; + private final Instant lastSeen; + + public DeviceAndRSSI(ThingUID device, int rssi, Instant lastSeen) { + this.device = device; + this.rssi = rssi; + this.lastSeen = lastSeen; + } + + @Override + public int compareTo(DeviceAndRSSI o) { + return Integer.compare(o.rssi, rssi); + } + } +} diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/comm/AbstractFrameHelper.java b/src/main/java/no/seime/openhab/binding/esphome/internal/comm/AbstractFrameHelper.java index 6b5bdfa..989bb4b 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/comm/AbstractFrameHelper.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/comm/AbstractFrameHelper.java @@ -133,6 +133,14 @@ public void send(GeneratedMessageV3 message) throws ProtocolAPIError { logger.debug("[{}] Sending message type {} with content '{}'", logPrefix, message.getClass().getSimpleName(), StringUtils.trimToEmpty(message.toString())); } - connection.send(encodeFrame(message)); + try { + if (connection != null) { + connection.send(encodeFrame(message)); + } else { + logger.debug("Connection is null, cannot send message"); + } + } catch (ProtocolAPIError e) { + logger.warn("Error sending message", e); + } } } diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/comm/ESPHomeConnection.java b/src/main/java/no/seime/openhab/binding/esphome/internal/comm/ESPHomeConnection.java index 1daf2bf..e560de3 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/comm/ESPHomeConnection.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/comm/ESPHomeConnection.java @@ -35,13 +35,18 @@ public ESPHomeConnection(ConnectionSelector connectionSelector, AbstractFrameHel } public synchronized void send(ByteBuffer buffer) throws ProtocolAPIError { - try { - while (buffer.hasRemaining()) { - logger.trace("[{}] Writing data {} bytes", logPrefix, buffer.remaining()); - socketChannel.write(buffer); + if (socketChannel != null) { + try { + while (buffer.hasRemaining()) { + logger.trace("[{}] Writing data {} bytes", logPrefix, buffer.remaining()); + socketChannel.write(buffer); + } + + } catch (IOException e) { + throw new ProtocolAPIError(String.format("[%s] Error sending message: %s ", logPrefix, e)); } - } catch (IOException e) { - throw new ProtocolAPIError(String.format("[%s] Error sending message: %s ", logPrefix, e)); + } else { + logger.warn("[{}] Attempted to send data on a closed connection", logPrefix); } } diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPChannelTypeProvider.java b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPChannelTypeProvider.java index 68e46e5..f629268 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPChannelTypeProvider.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPChannelTypeProvider.java @@ -23,7 +23,7 @@ import org.osgi.service.component.annotations.Reference; /** - * Channel Type Provider that does a callback the SensiboSkyHandler that initiated it. + * Channel Type Provider that does a callback the handler that initiated it. * * @author Arne Seime - Initial contribution */ diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandler.java b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandler.java index 743ee7a..5498c21 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandler.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandler.java @@ -40,6 +40,7 @@ import no.seime.openhab.binding.esphome.internal.CommunicationListener; import no.seime.openhab.binding.esphome.internal.ESPHomeConfiguration; import no.seime.openhab.binding.esphome.internal.LogLevel; +import no.seime.openhab.binding.esphome.internal.bluetooth.ESPHomeBluetoothProxyHandler; import no.seime.openhab.binding.esphome.internal.comm.*; import no.seime.openhab.binding.esphome.internal.message.*; import no.seime.openhab.binding.esphome.internal.message.statesubscription.ESPHomeEventSubscriber; @@ -80,10 +81,14 @@ public class ESPHomeHandler extends BaseThingHandler implements CommunicationLis private boolean disposed = false; private boolean interrogated; + private boolean bluetoothProxyStarted = false; + private boolean bluetoothProxyRequested = false; @Nullable private String logPrefix = null; private final ESPHomeEventSubscriber eventSubscriber; + @Nullable + private ESPHomeBluetoothProxyHandler espHomeBluetoothProxyHandler; public ESPHomeHandler(Thing thing, ConnectionSelector connectionSelector, ESPChannelTypeProvider dynamicChannelTypeProvider, ESPHomeEventSubscriber eventSubscriber) { @@ -207,6 +212,8 @@ public void dispose() { frameHelper.close(); } } + connectionState = ConnectionState.UNINITIALIZED; + super.dispose(); } @@ -331,6 +338,10 @@ private void handleConnected(GeneratedMessageV3 message) throws ProtocolAPIError logger.debug("[{}] Received message type {} with content '{}'", logPrefix, message.getClass().getSimpleName(), StringUtils.trimToEmpty(message.toString())); } + if (disposed) { + return; + } + if (message instanceof DeviceInfoResponse rsp) { Map props = new HashMap<>(); props.put("esphome_version", rsp.getEsphomeVersion()); @@ -365,6 +376,20 @@ private void handleConnected(GeneratedMessageV3 message) throws ProtocolAPIError GetTimeResponse getTimeResponse = GetTimeResponse.newBuilder() .setEpochSeconds((int) (System.currentTimeMillis() / 1000)).build(); frameHelper.send(getTimeResponse); + } else if (message instanceof BluetoothLEAdvertisementResponse + | message instanceof BluetoothLERawAdvertisementsResponse + | message instanceof BluetoothDeviceConnectionResponse + | message instanceof BluetoothGATTGetServicesResponse + | message instanceof BluetoothGATTGetServicesDoneResponse | message instanceof BluetoothGATTReadResponse + | message instanceof BluetoothGATTNotifyDataResponse + | message instanceof BluetoothConnectionsFreeResponse | message instanceof BluetoothGATTErrorResponse + | message instanceof BluetoothGATTWriteResponse | message instanceof BluetoothGATTNotifyResponse + | message instanceof BluetoothDevicePairingResponse + | message instanceof BluetoothDeviceUnpairingResponse + | message instanceof BluetoothDeviceClearCacheResponse) { + if (espHomeBluetoothProxyHandler != null) { + espHomeBluetoothProxyHandler.handleBluetoothMessage(message, this); + } } else { // Regular messages handled by message handlers AbstractMessageHandler abstractMessageHandler = classToHandlerMap @@ -378,6 +403,18 @@ private void handleConnected(GeneratedMessageV3 message) throws ProtocolAPIError } } + public void sendBluetoothCommand(GeneratedMessageV3 message) { + try { + if (connectionState == ConnectionState.CONNECTED) { + frameHelper.send(message); + } else { + logger.warn("[{}] Not connected, ignoring bluetooth command {}", logPrefix, message); + } + } catch (ProtocolAPIError e) { + logger.error("[{}] Error sending bluetooth command", logPrefix, e); + } + } + private void initializeStateSubscription(SubscribeHomeAssistantStateResponse rsp) { // Setup event subscriber logger.debug("[{}] Start subscribe to OH events entity: {}, attribute: {}", logPrefix, rsp.getEntityId(), @@ -463,10 +500,10 @@ private void handleLoginResponse(GeneratedMessageV3 message) throws ProtocolAPIE } }, config.pingInterval, config.pingInterval, TimeUnit.SECONDS); + // Start interrogation frameHelper.send(DeviceInfoRequest.getDefaultInstance()); frameHelper.send(ListEntitiesRequest.getDefaultInstance()); frameHelper.send(SubscribeHomeAssistantStatesRequest.getDefaultInstance()); - } } @@ -487,7 +524,6 @@ private void handleHelloResponse(GeneratedMessageV3 message) throws ProtocolAPIE frameHelper.send(ConnectRequest.newBuilder().setPassword(config.password).build()); } else { frameHelper.send(ConnectRequest.getDefaultInstance()); - } } @@ -503,6 +539,38 @@ public void addChannel(Channel channel) { dynamicChannels.add(channel); } + public boolean isDisposed() { + return disposed; + } + + public void listenForBLEAdvertisements(ESPHomeBluetoothProxyHandler espHomeBluetoothProxyHandler) { + this.espHomeBluetoothProxyHandler = espHomeBluetoothProxyHandler; + if (config.enableBluetoothProxy && !bluetoothProxyStarted && connectionState == ConnectionState.CONNECTED) { + try { + frameHelper.send(SubscribeBluetoothLEAdvertisementsRequest.getDefaultInstance()); + bluetoothProxyStarted = true; + } catch (Exception e) { + logger.error("[{}] Error starting BLE proxy", logPrefix, e); + } + } else { + bluetoothProxyRequested = true; + } + } + + public void stopListeningForBLEAdvertisements() { + + if (connectionState == ConnectionState.CONNECTED) { + try { + frameHelper.send(UnsubscribeBluetoothLEAdvertisementsRequest.getDefaultInstance()); + } catch (Exception e) { + logger.warn("[{}] Error stopping BLE proxy", logPrefix, e); + } + } + + bluetoothProxyStarted = false; + espHomeBluetoothProxyHandler = null; + } + private enum ConnectionState { // Initial state, no connection UNINITIALIZED, diff --git a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandlerFactory.java b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandlerFactory.java index e4acf44..b5451d3 100644 --- a/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandlerFactory.java +++ b/src/main/java/no/seime/openhab/binding/esphome/internal/handler/ESPHomeHandlerFactory.java @@ -13,23 +13,27 @@ package no.seime.openhab.binding.esphome.internal.handler; import java.io.IOException; +import java.util.HashMap; +import java.util.Hashtable; +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.BluetoothAdapter; import org.openhab.core.items.ItemRegistry; -import org.openhab.core.thing.Thing; -import org.openhab.core.thing.ThingRegistry; -import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.*; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import no.seime.openhab.binding.esphome.internal.BindingConstants; +import no.seime.openhab.binding.esphome.internal.bluetooth.ESPHomeBluetoothProxyHandler; import no.seime.openhab.binding.esphome.internal.comm.ConnectionSelector; import no.seime.openhab.binding.esphome.internal.message.statesubscription.ESPHomeEventSubscriber; @@ -43,7 +47,8 @@ @Component(configurationPid = "binding.esphome", service = ThingHandlerFactory.class) public class ESPHomeHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(BindingConstants.THING_TYPE_DEVICE); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(BindingConstants.THING_TYPE_DEVICE, + BindingConstants.THING_TYPE_BLE_PROXY); @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { @@ -54,6 +59,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { private final ESPChannelTypeProvider dynamicChannelTypeProvider; private final ESPHomeEventSubscriber eventSubscriber; + private final ThingRegistry thingRegistry; @Activate public ESPHomeHandlerFactory(@Reference ESPChannelTypeProvider dynamicChannelTypeProvider, @@ -62,6 +68,7 @@ public ESPHomeHandlerFactory(@Reference ESPChannelTypeProvider dynamicChannelTyp this.dynamicChannelTypeProvider = dynamicChannelTypeProvider; this.eventSubscriber = eventSubscriber; + this.thingRegistry = thingRegistry; eventSubscriber.setItemRegistry(itemRegistry); eventSubscriber.setThingRegistry(thingRegistry); @@ -74,6 +81,10 @@ public ESPHomeHandlerFactory(@Reference ESPChannelTypeProvider dynamicChannelTyp if (BindingConstants.THING_TYPE_DEVICE.equals(thingTypeUID)) { return new ESPHomeHandler(thing, connectionSelector, dynamicChannelTypeProvider, eventSubscriber); + } else if (BindingConstants.THING_TYPE_BLE_PROXY.equals(thingTypeUID)) { + ESPHomeBluetoothProxyHandler handler = new ESPHomeBluetoothProxyHandler((Bridge) thing, thingRegistry); + registerBluetoothAdapter(handler); + return handler; } return null; @@ -91,4 +102,22 @@ protected void deactivate(ComponentContext componentContext) { super.deactivate(componentContext); } + + private final Map> serviceRegs = new HashMap<>(); + + private synchronized void registerBluetoothAdapter(BluetoothAdapter adapter) { + serviceRegs.put(adapter.getUID(), + bundleContext.registerService(BluetoothAdapter.class.getName(), adapter, new Hashtable<>())); + } + + @Override + protected synchronized void removeHandler(ThingHandler thingHandler) { + if (thingHandler instanceof BluetoothAdapter bluetoothAdapter) { + UID uid = bluetoothAdapter.getUID(); + ServiceRegistration serviceReg = serviceRegs.remove(uid); + if (serviceReg != null) { + serviceReg.unregister(); + } + } + } } diff --git a/src/main/resources/OH-INF/thing/thing-bluetooth.xml b/src/main/resources/OH-INF/thing/thing-bluetooth.xml new file mode 100644 index 0000000..c8b0afc --- /dev/null +++ b/src/main/resources/OH-INF/thing/thing-bluetooth.xml @@ -0,0 +1,16 @@ + + + + + + + ESPHome Bluetooth Proxy for beacons. Utilizes any registered ESPHome device with bluetooth proxy enabled + + + + + + diff --git a/src/main/resources/OH-INF/thing/thing-types.xml b/src/main/resources/OH-INF/thing/thing-esphome.xml similarity index 93% rename from src/main/resources/OH-INF/thing/thing-types.xml rename to src/main/resources/OH-INF/thing/thing-esphome.xml index 6f06984..a34de79 100644 --- a/src/main/resources/OH-INF/thing/thing-types.xml +++ b/src/main/resources/OH-INF/thing/thing-esphome.xml @@ -43,6 +43,12 @@ device configuration yaml + + + Ensure your ESPHome configuration contains the bluetooth_proxy configuration element + false + + 10 @@ -83,7 +89,4 @@ - - - diff --git a/src/test/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtilTest.java b/src/test/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtilTest.java new file mode 100644 index 0000000..7ce4fbb --- /dev/null +++ b/src/test/java/no/seime/openhab/binding/esphome/internal/bluetooth/BluetoothAddressUtilTest.java @@ -0,0 +1,18 @@ +package no.seime.openhab.binding.esphome.internal.bluetooth; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.openhab.binding.bluetooth.BluetoothAddress; + +public class BluetoothAddressUtilTest { + + @Test + public void testConvertAddress() { + long address = 0x1234567890FFL; + String addressString = "12:34:56:78:90:FF"; + BluetoothAddress bluetoothAddress = BluetoothAddressUtil.createAddress(address); + assertEquals(addressString, bluetoothAddress.toString()); + assertEquals(address, BluetoothAddressUtil.convertAddressToLong(bluetoothAddress)); + } +}