From 2e98e7039695f96667e752f1bc37b7ce7fca3f87 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 8 Sep 2024 16:28:30 +0100 Subject: [PATCH] [bluetooth.hdpowerview] New binding using Bluetooth Low Energy (#17099) * [bluetooth.hdpowerview] initial contribution Signed-off-by: AndrewFG Signed-off-by: Patrik Gfeller --- CODEOWNERS | 1 + .../NOTICE | 13 + .../README.md | 81 +++ .../pom.xml | 71 +++ .../src/main/feature/feature.xml | 12 + .../internal/ShadeBindingConstants.java | 61 ++ .../discovery/ShadeDiscoveryParticipant.java | 112 ++++ .../internal/factory/ShadeHandlerFactory.java | 50 ++ .../internal/shade/ShadeConfiguration.java | 35 ++ .../internal/shade/ShadeDataReader.java | 96 ++++ .../internal/shade/ShadeDataWriter.java | 154 +++++ .../internal/shade/ShadeHandler.java | 540 ++++++++++++++++++ .../OH-INF/i18n/bluetooth.properties | 24 + .../src/main/resources/OH-INF/thing/thing.xml | 80 +++ .../hdpowerview/test/ShadeTests.java | 271 +++++++++ .../src/main/resources/OH-INF/addon/addon.xml | 16 + .../database/ShadeCapabilitiesDatabase.java | 9 +- bundles/pom.xml | 1 + .../src/main/resources/footer.xml | 1 + 19 files changed, 1627 insertions(+), 1 deletion(-) create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/NOTICE create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/README.md create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/pom.xml create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/ShadeBindingConstants.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/discovery/ShadeDiscoveryParticipant.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/factory/ShadeHandlerFactory.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeConfiguration.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataReader.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataWriter.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/i18n/bluetooth.properties create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/thing/thing.xml create mode 100644 bundles/org.openhab.binding.bluetooth.hdpowerview/src/test/java/org/openhab/binding/bluetooth/hdpowerview/test/ShadeTests.java diff --git a/CODEOWNERS b/CODEOWNERS index 580c788c1af97..d75f544ce7385 100755 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -50,6 +50,7 @@ /bundles/org.openhab.binding.bluetooth.generic/ @cpmeister /bundles/org.openhab.binding.bluetooth.govee/ @cpmeister /bundles/org.openhab.binding.bluetooth.grundfosalpha/ @tisoft +/bundles/org.openhab.binding.bluetooth.hdpowerview/ @andrewfg /bundles/org.openhab.binding.bluetooth.radoneye/ @petero-dk /bundles/org.openhab.binding.bluetooth.roaming/ @cpmeister /bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/NOTICE b/bundles/org.openhab.binding.bluetooth.hdpowerview/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/NOTICE @@ -0,0 +1,13 @@ +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 diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/README.md b/bundles/org.openhab.binding.bluetooth.hdpowerview/README.md new file mode 100644 index 0000000000000..9ecbcf16be525 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/README.md @@ -0,0 +1,81 @@ +# Hunter Douglas (Luxaflex) PowerView Binding for Bluetooth + +This is an openHAB binding for Bluetooth for [Hunter Douglas PowerView](https://www.hunterdouglas.com/operating-systems/motorized/powerview-motorization/overview) motorized shades via Bluetooth Low Energy (BLE). +In some countries the PowerView system is sold under the brand name [Luxaflex](https://www.luxaflex.com/). + +This binding supports Generation 3 shades connected directly via their in built Bluetooth Low Energy interface. +There is a different binding [here](https://www.openhab.org/addons/bindings/hdpowerview/) for shades that are connected via a hub or gateway. + +PowerView shades have motorization control for their vertical position, and some also have vane controls to change the angle of their slats. + +## Supported Things + +| Thing | Description | +|-------|------------------------------------------------------------------------------------| +| shade | A Powerview Generation 3 motorized shade connected via Bluetooth Low Energy (BLE). | + +## Bluetooth Bridge + +Before you can create `shade` Things, you must first create a Bluetooth Bridge to contain them. +The instructions for creating a Bluetooth Bridge are [here](https://www.openhab.org/addons/bindings/bluetooth/). + +## Discovery + +Make sure your shades are visible via BLE in the PowerView app before attempting discovery. + +The discovery process can be started by pressing the `+` button at the lower right of the Main UI Things page, selecting the Bluetooth binding, and pressing `Scan`. +Any discovered shades will be displayed with the name prefix 'Powerview Shade'. + +## Configuration + +| Configuration Parameter | Type | Description | +|-------------------------|--------------------|---------------------------------------------------------------------------------------------------------------------| +| address | Required | The Bluetooth MAC address of the shade. | +| bleTimeout | Optional, Advanced | The maximum number of seconds to wait before transactions over Bluetooth will time out (default = 6 seconds). | +| heartbeatDelay | Optional, Advanced | The scanning interval in seconds at which the binding checks if the Shade is on- or off- line (default 15 seconds). | +| pollingDelay | Optional, Advanced | The scanning interval in seconds at which the binding checks the battery status (default 300 seconds). | +| encryptionKey | Optional | The key to be used when encrypting commands to the shade. See [next chapter](#encryption-key). | + +## Encryption Key + +If you want to send position commands to a shade, then an encryption key may be required. +If the shade is NOT included in the Powerview App, then an encryption key is not required. +But if it IS in the Powerview App, then openHAB has to use the same encryption key as used by the App. +Currently you can only discover the encryption key by snooping the network traffic between the App and the shade. +Please post on the openHAB community forum for advice about how to do this. + +## Channels + +A shade always implements a roller shutter channel `position` which controls the vertical position of the shade's (primary) rail. +If the shade has slats or rotatable vanes, there is also a dimmer channel `tilt` which controls the slat / vane position. +If it is a dual action (top-down plus bottom-up) shade, there is also a roller shutter channel `secondary` which controls the vertical position of the secondary rail. + +| Channel | Item Type | Description | +|---------------|----------------------|-------------------------------------------------------| +| position | Rollershutter | The vertical position of the shade's rail. | +| secondary | Rollershutter | The vertical position of the secondary rail (if any). | +| tilt | Dimmer | The degree of opening of the slats or vanes (if any). | +| battery-level | Number:Dimensionless | Battery level (10% = low, 50% = medium, 100% = high). | +| rssi | Number:Power | Received Signal Strength Indication. | + +Note: the channels `secondary` and `tilt` only exist if the shade physically supports such channels. + +## Examples + +```java +// things +Bridge bluetooth:bluegiga:abc123 "Bluetooth Stick" @ "Comms Cabinet" [port="COM3"] { + // shade NOT integrated in Powerview App + Thing bluetooth:shade:112233445566 "North Window Shade" @ "Office" [address="11:22:33:44:55:66"] + + // or, shade integrated in Powerview App + Thing bluetooth:shade:112233445566 "North Window Shade" @ "Office" [address="11:22:33:44:55:66", encryptionKey="59409c980e627e2fc702c2efcbd4064d"] +} + +// items +Rollershutter Shade_Position "Shade Position" {channel="bluetooth:shade:abc123:112233445566:position"} +Dimmer Shade_Position2 "Shade Position" {channel="bluetooth:shade:abc123:112233445566:position"} +Dimmer Shade_Tilt "Shade Tilt" {channel="bluetooth:shade:abc123:112233445566:tilt"} +Number:Dimensionless Shade_Battery_Level "Shade Battery Level" {channel="bluetooth:shade:abc123:112233445566:battery-level"} +Number:Power Shade_RSSI "Shade Signal Strength" {channel="bluetooth:shade:abc123:112233445566:rssi"} +``` diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/pom.xml b/bundles/org.openhab.binding.bluetooth.hdpowerview/pom.xml new file mode 100644 index 0000000000000..f0248cc3c7f6f --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.3.0-SNAPSHOT + + + org.openhab.binding.bluetooth.hdpowerview + + openHAB Add-ons :: Bundles :: HD Powerview Bluetooth Adapter + + + + org.openhab.addons.bundles + org.openhab.binding.bluetooth + ${project.version} + provided + + + + + + + maven-resources-plugin + + + generate-sources + + copy-resources + + + ${project.build.directory}/import + true + + + ../org.openhab.binding.hdpowerview/src/main/java + + **/ShadeCapabilitiesDatabase.java + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + generate-sources + + add-source + + + + ${project.build.directory}/import + + + + + + + + + diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/feature/feature.xml b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/feature/feature.xml new file mode 100644 index 0000000000000..63ef6e71a5bc8 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/feature/feature.xml @@ -0,0 +1,12 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + openhab-transport-serial + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.hdpowerview/${project.version} + + + diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/ShadeBindingConstants.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/ShadeBindingConstants.java new file mode 100644 index 0000000000000..cc8c177152c97 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/ShadeBindingConstants.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.bluetooth.BluetoothBindingConstants; +import org.openhab.binding.bluetooth.BluetoothCharacteristic.GattCharacteristic; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link ShadeBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class ShadeBindingConstants { + + public static final ThingTypeUID THING_TYPE_SHADE = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID, "shade"); + + public static final String CHANNEL_SHADE_PRIMARY = "primary"; + public static final String CHANNEL_SHADE_SECONDARY = "secondary"; + public static final String CHANNEL_SHADE_TILT = "tilt"; + public static final String CHANNEL_SHADE_BATTERY_LEVEL = "battery-level"; + + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SHADE); + + public static final int HUNTER_DOUGLAS_MANUFACTURER_ID = 0x819; + + public static final Map MAP_UID_PROPERTY_NAMES = Map.of( // + GattCharacteristic.MANUFACTURER_NAME_STRING.getUUID(), Thing.PROPERTY_VENDOR, // + GattCharacteristic.HARDWARE_REVISION_STRING.getUUID(), Thing.PROPERTY_HARDWARE_VERSION, // + GattCharacteristic.FIRMWARE_REVISION_STRING.getUUID(), Thing.PROPERTY_FIRMWARE_VERSION, // + GattCharacteristic.SERIAL_NUMBER_STRING.getUUID(), Thing.PROPERTY_SERIAL_NUMBER, // + GattCharacteristic.MODEL_NUMBER_STRING.getUUID(), Thing.PROPERTY_MODEL_ID); + + public static final String HUNTER_DOUGLAS = "Hunter Douglas"; + public static final String SHADE_LABEL = "PowerView Shade"; + + public static final String PROPERTY_HOME_ID = "homeId"; + public static final String PROPERTY_ENCRYPTION_KEY = "encryptionKey"; + + public static final UUID UUID_SERVICE_SHADE = UUID.fromString("0000FDC1-0000-1000-8000-00805F9B34FB"); + public static final UUID UUID_CHARACTERISTIC_POSITION = UUID.fromString("CAFE1001-C0FF-EE01-8000-A110CA7AB1E0"); + public static final UUID UUID_CHARACTERISTIC_TBD = UUID.fromString("CAFE1002-C0FF-EE01-8000-A110CA7AB1E0"); +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/discovery/ShadeDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/discovery/ShadeDiscoveryParticipant.java new file mode 100644 index 0000000000000..7adac7899741f --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/discovery/ShadeDiscoveryParticipant.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.discovery; + +import static org.openhab.binding.bluetooth.BluetoothBindingConstants.*; +import static org.openhab.binding.bluetooth.hdpowerview.internal.ShadeBindingConstants.*; + +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.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; + +/** + * Discovery participant recognizes Hunter Douglas Powerview Shades and create discovery results for them. + * + * @author Andrew Fiddian-Green - Initial contribution + * + */ +@NonNullByDefault +@Component +public class ShadeDiscoveryParticipant implements BluetoothDiscoveryParticipant { + + @Override + public Set getSupportedThingTypeUIDs() { + return SUPPORTED_THING_TYPES_UIDS; + } + + @Override + public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) { + Integer manufacturerId = device.getManufacturerId(); + if (manufacturerId != null && manufacturerId.intValue() == HUNTER_DOUGLAS_MANUFACTURER_ID) { + return new ThingUID(THING_TYPE_SHADE, device.getAdapter().getUID(), + device.getAddress().toString().toLowerCase().replace(":", "")); + } + return null; + } + + @Override + public @Nullable DiscoveryResult createResult(BluetoothDiscoveryDevice device) { + ThingUID thingUID = getThingUID(device); + if (thingUID != null) { + Map properties = new HashMap<>(); + + properties.put(CONFIGURATION_ADDRESS, device.getAddress().toString()); + properties.put(Thing.PROPERTY_VENDOR, HUNTER_DOUGLAS); + properties.put(Thing.PROPERTY_MAC_ADDRESS, device.getAddress().toString()); + + String serialNumber = device.getSerialNumber(); + if (serialNumber != null) { + properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNumber); + } + + String firmwareRevision = device.getFirmwareRevision(); + if (firmwareRevision != null) { + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, firmwareRevision); + } + + String model = device.getModel(); + if (model != null) { + properties.put(Thing.PROPERTY_MODEL_ID, model); + } + + String hardwareRevision = device.getHardwareRevision(); + if (hardwareRevision != null) { + properties.put(Thing.PROPERTY_HARDWARE_VERSION, hardwareRevision); + } + + Integer txPower = device.getTxPower(); + if (txPower != null) { + properties.put(PROPERTY_TXPOWER, Integer.toString(txPower)); + } + + String label = String.format("%s (%s)", SHADE_LABEL, device.getName()); + + return DiscoveryResultBuilder.create(thingUID).withProperties(properties) + .withRepresentationProperty(CONFIGURATION_ADDRESS).withBridge(device.getAdapter().getUID()) + .withLabel(label).build(); + } + return null; + } + + @Override + public boolean requiresConnection(BluetoothDiscoveryDevice device) { + return false; + } + + @Override + public int order() { + // we want to go first + return Integer.MIN_VALUE; + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/factory/ShadeHandlerFactory.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/factory/ShadeHandlerFactory.java new file mode 100644 index 0000000000000..adde4c3b6bf77 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/factory/ShadeHandlerFactory.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.factory; + +import static org.openhab.binding.bluetooth.hdpowerview.internal.ShadeBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.bluetooth.hdpowerview.internal.shade.ShadeHandler; +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.Component; + +/** + * The {@link ShadeHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.bluetooth.hdpowerview", service = ThingHandlerFactory.class) +public class ShadeHandlerFactory extends BaseThingHandlerFactory { + + @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 (THING_TYPE_SHADE.equals(thingTypeUID)) { + return new ShadeHandler(thing); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeConfiguration.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeConfiguration.java new file mode 100644 index 0000000000000..c3c713cba4743 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeConfiguration.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.shade; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ShadeConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class ShadeConfiguration { + public String address = ""; + public int bleTimeout = 6; // seconds + public int heartbeatDelay = 15; // seconds + public int pollingDelay = 300; // seconds + public String encryptionKey = ""; + + @Override + public String toString() { + return String.format("[address:%s, bleTimeout:%d, heartbeatDelay:%d, pollingDelay:%d]", address, bleTimeout, + heartbeatDelay, pollingDelay); + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataReader.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataReader.java new file mode 100644 index 0000000000000..a9b16ffaba563 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataReader.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.shade; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.PercentType; + +/** + * Parser for data returned by an HD PowerView Generation 3 Shade. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class ShadeDataReader { + + // internal values 0 to 4000 scale to real position values 0% to 100% + private static final double SCALE = 40; + + // indexes to data field positions in the incoming bytes + private static final int INDEX_MANUFACTURER_ID = 0; + private static final int INDEX_HOME_ID = 2; + private static final int INDEX_TYPE_ID = 4; + private static final int INDEX_PRIMARY = 5; + private static final int INDEX_SECONDARY = 7; + private static final int INDEX_TILT = 9; + private static final int INDEX_VELOCITY = 10; + + private int manufacturerId; + private int homeId; + private int typeId; + private double primary; + private double secondary; + private double tilt; + private double velocity; // not 100% sure about this + + public ShadeDataReader() { + } + + public int getManufacturerId() { + return manufacturerId; + } + + public int getHomeId() { + return homeId; + } + + public PercentType getPrimary() { + return new PercentType(BigDecimal.valueOf(primary)); + } + + public PercentType getSecondary() { + return new PercentType(BigDecimal.valueOf(secondary)); + } + + public PercentType getTilt() { + return new PercentType(BigDecimal.valueOf(tilt)); + } + + public int getTypeId() { + return typeId; + } + + public double getVelocity() { + return velocity; + } + + public ShadeDataReader setBytes(byte[] bytes) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + buffer.order(ByteOrder.LITTLE_ENDIAN); + + manufacturerId = buffer.getShort(INDEX_MANUFACTURER_ID); + homeId = buffer.getShort(INDEX_HOME_ID); + typeId = buffer.get(INDEX_TYPE_ID); + velocity = buffer.get(INDEX_VELOCITY); + + primary = Math.max(0, Math.min(100, buffer.getShort(INDEX_PRIMARY) / SCALE)); + secondary = Math.max(0, Math.min(100, buffer.getShort(INDEX_SECONDARY) / SCALE)); + tilt = Math.max(0, Math.min(100, buffer.get(INDEX_TILT))); + + return this; + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataWriter.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataWriter.java new file mode 100644 index 0000000000000..de79f29c3b063 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataWriter.java @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.shade; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Encoder/decoder for data sent to an HD PowerView Generation 3 Shade. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class ShadeDataWriter { + + // real position values 0% to 100% scale to internal values 0 to 10000 + private static final double SCALE = 100; + + // byte array for a blank 'no-op' write command + private static final byte[] BLANK_WRITE_COMMAND_FRAME = HexFormat.ofDelimiter(":") + .parseHex("f7:01:00:09:00:80:00:80:00:80:00:80:00"); + + // index to data field positions in the outgoing bytes + private static final int INDEX_SEQUENCE = 2; + private static final int INDEX_PRIMARY = 4; + private static final int INDEX_SECONDARY = 6; + private static final int INDEX_TILT = 10; + + private final byte[] bytes; + + public ShadeDataWriter() { + bytes = BLANK_WRITE_COMMAND_FRAME.clone(); + } + + public ShadeDataWriter(byte[] bytes) { + this.bytes = bytes.clone(); + } + + public byte[] getBytes() { + return bytes; + } + + /** + * Decrypt the bytes using the given hexadecimal key. No-Op if key is blank or null. + * + * @param keyHex decryption key + * @return decrypted bytes + * @throws IllegalArgumentException (the key hex value could not be parsed) + * @throws NoSuchAlgorithmException + * @throws NoSuchPaddingException + * @throws InvalidKeyException + * @throws InvalidAlgorithmParameterException + * @throws IllegalBlockSizeException + * @throws BadPaddingException + */ + public byte[] getDecrypted(@Nullable String keyHex) + throws IllegalArgumentException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, + InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException { + if (keyHex != null && !keyHex.isBlank()) { + byte[] keyBytes = HexFormat.of().parseHex(keyHex); + SecretKey keySecret = new SecretKeySpec(keyBytes, "AES"); + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, keySecret, new IvParameterSpec(new byte[16])); + return cipher.doFinal(bytes); + } + return bytes; + } + + /** + * Encrypt the bytes using the given hexadecimal key. No-Op if key is blank or null. + * + * @param keyHex decryption key + * @return encrypted bytes + * @throws IllegalArgumentException (the key hex value could not be parsed) + * @throws NoSuchAlgorithmException + * @throws NoSuchPaddingException + * @throws InvalidKeyException + * @throws InvalidAlgorithmParameterException + * @throws IllegalBlockSizeException + * @throws BadPaddingException + */ + public byte[] getEncrypted(@Nullable String keyHex) + throws IllegalArgumentException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, + InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException { + if (keyHex != null && !keyHex.isBlank()) { + byte[] keyBytes = HexFormat.of().parseHex(keyHex); + SecretKey keySecret = new SecretKeySpec(keyBytes, "AES"); + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, keySecret, new IvParameterSpec(new byte[16])); + return cipher.doFinal(bytes); + } + return bytes; + } + + /** + * Encode the bytes in little endian format. + */ + public byte[] encodeLE(double percent) throws IllegalArgumentException { + if (percent < 0 || percent > 100) { + throw new IllegalArgumentException(String.format("Number '%0.1f' out of range (0% to 100%)", percent)); + } + int position = ((int) Math.round(percent * SCALE)); + return new byte[] { (byte) (position & 0xff), (byte) ((position & 0xff00) >> 8) }; + } + + public ShadeDataWriter withPrimary(double percent) { + byte[] bytes = encodeLE(percent); + System.arraycopy(bytes, 0, this.bytes, INDEX_PRIMARY, bytes.length); + return this; + } + + public ShadeDataWriter withSecondary(double percent) { + byte[] bytes = encodeLE(percent); + System.arraycopy(bytes, 0, this.bytes, INDEX_SECONDARY, bytes.length); + return this; + } + + public ShadeDataWriter withSequence(byte sequence) { + this.bytes[INDEX_SEQUENCE] = sequence; + return this; + } + + public ShadeDataWriter withTilt(double percent) { + if (percent < 0 || percent > 100) { + throw new IllegalArgumentException(String.format("Number '%0.1f' out of range (0% to 100%)", percent)); + } + byte[] bytes = new byte[] { (byte) (percent), 0 }; + System.arraycopy(bytes, 0, this.bytes, INDEX_TILT, bytes.length); + return this; + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeHandler.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeHandler.java new file mode 100644 index 0000000000000..d6c61f8bab630 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeHandler.java @@ -0,0 +1,540 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.internal.shade; + +import static org.openhab.binding.bluetooth.hdpowerview.internal.ShadeBindingConstants.*; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.bluetooth.BeaconBluetoothHandler; +import org.openhab.binding.bluetooth.BluetoothAddress; +import org.openhab.binding.bluetooth.BluetoothCharacteristic; +import org.openhab.binding.bluetooth.BluetoothCharacteristic.GattCharacteristic; +import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState; +import org.openhab.binding.bluetooth.BluetoothService; +import org.openhab.binding.bluetooth.BluetoothUtils; +import org.openhab.binding.bluetooth.ConnectionException; +import org.openhab.binding.bluetooth.notification.BluetoothScanNotification; +import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase; +import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase.Capabilities; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.library.unit.Units; +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.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.UnDefType; +import org.openhab.core.util.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ShadeHandler} is a thing handler for Hunter Douglas Powerview Shades using Bluetooth Low Energy (BLE). + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class ShadeHandler extends BeaconBluetoothHandler { + + private static final String ENCRYPTION_KEY_HELP_URL = // + "https://www.openhab.org/addons/bindings/bluetooth.hdpowerview/readme.html#encryption-key"; + + private static final ShadeCapabilitiesDatabase CAPABILITIES_DATABASE = new ShadeCapabilitiesDatabase(); + private static final Map HOME_ID_ENCRYPTION_KEYS = new ConcurrentHashMap<>(); + + private final Logger logger = LoggerFactory.getLogger(ShadeHandler.class); + private final List> readTasks = new ArrayList<>(); + private final Map> writeTasks = new ConcurrentHashMap<>(); + private final ShadeDataReader dataReader = new ShadeDataReader(); + + private @Nullable Capabilities capabilities; + private @Nullable Future readBatteryTask; + + private byte[] cachedValue = new byte[0]; + private Instant activityTimeout = Instant.MIN; + private ShadeConfiguration configuration = new ShadeConfiguration(); + private boolean propertiesLoaded = false; + private byte writeSequence = Byte.MIN_VALUE; + private int homeId; + + public ShadeHandler(Thing thing) { + super(thing); + } + + /** + * Cancel the given task + */ + private void cancelTask(@Nullable Future task, boolean interrupt) { + if (task != null) { + task.cancel(interrupt); + } + } + + /** + * Cancel all tasks + */ + private void cancelTasks(boolean interrupt) { + readTasks.forEach(task -> cancelTask(task, interrupt)); + writeTasks.values().forEach(task -> cancelTask(task, interrupt)); + cancelTask(readBatteryTask, interrupt); + readBatteryTask = null; + readTasks.clear(); + writeTasks.clear(); + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + if (CHANNEL_SHADE_BATTERY_LEVEL.equals(channelUID.getId())) { + scheduleReadBattery(); + } + } + + /** + * Connect the device and download its services (if not already done). Blocks until the operation completes. + */ + private void connectAndWait() throws TimeoutException, InterruptedException, ConnectionException { + if (device.getConnectionState() != ConnectionState.CONNECTED) { + if (device.getConnectionState() != ConnectionState.CONNECTING) { + if (!device.connect()) { + throw new ConnectionException("Failed to start connecting"); + } + } + if (!device.awaitConnection(configuration.bleTimeout, TimeUnit.SECONDS)) { + throw new TimeoutException("Connection attempt timeout"); + } + } + if (!device.isServicesDiscovered()) { + device.discoverServices(); + if (!device.awaitServiceDiscovery(configuration.bleTimeout, TimeUnit.SECONDS)) { + throw new TimeoutException("Service discovery timeout"); + } + } + } + + @Override + public void dispose() { + cancelTasks(true); + super.dispose(); + } + + /** + * Get the key for encrypting write commands. Uses either.. + * + *
  • The key for this specific Thing via its own configuration properties, or
  • + *
  • The key for any other Thing with the same homeId via the shared ENCRYPTION_KEYS map
  • + */ + private @Nullable String getEncryptionKey() { + String key = null; + if (homeId != 0) { + key = configuration.encryptionKey; + key = key.isBlank() ? HOME_ID_ENCRYPTION_KEYS.get(homeId) : key; + if (key == null || key.isBlank()) { + logger.warn("Device '{}' requires an encryption key => see {}", device.getAddress(), + ENCRYPTION_KEY_HELP_URL); + } else { + HOME_ID_ENCRYPTION_KEYS.putIfAbsent(homeId, key); + if (!configuration.encryptionKey.equals(key)) { + configuration.encryptionKey = key; + Configuration config = getConfig(); + config.put(PROPERTY_ENCRYPTION_KEY, key); + updateConfiguration(config); + } + } + } + return key; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command commandArg) { + super.handleCommand(channelUID, commandArg); + + if (commandArg == RefreshType.REFRESH) { + switch (channelUID.getId()) { + case CHANNEL_SHADE_BATTERY_LEVEL: + scheduleReadBattery(); + break; + + default: + break; + } + return; + } + + Command command = commandArg; + + // convert stop commands to (current) position commands + if (command instanceof StopMoveType stopMove) { + if (StopMoveType.STOP == stopMove) { + switch (channelUID.getId()) { + case CHANNEL_SHADE_PRIMARY: + command = dataReader.getPrimary(); + break; + case CHANNEL_SHADE_SECONDARY: + command = dataReader.getSecondary(); + break; + case CHANNEL_SHADE_TILT: + command = dataReader.getTilt(); + break; + } + } + } + + // convert up/down commands to position command + if (command instanceof UpDownType updown) { + command = UpDownType.DOWN == updown ? PercentType.ZERO : PercentType.HUNDRED; + } + + if (command instanceof PercentType percent) { + Capabilities capabilities = this.capabilities; + if (capabilities == null) { + return; + } + + try { + switch (channelUID.getId()) { + case CHANNEL_SHADE_PRIMARY: + if (capabilities.supportsPrimary()) { + scheduleWritePosition(new ShadeDataWriter().withSequence(writeSequence++) + .withPrimary(percent.doubleValue()).getEncrypted(getEncryptionKey())); + } + break; + + case CHANNEL_SHADE_SECONDARY: + if (capabilities.supportsSecondary()) { + scheduleWritePosition(new ShadeDataWriter().withSequence(writeSequence++) + .withSecondary(percent.doubleValue()).getEncrypted(getEncryptionKey())); + } + break; + + case CHANNEL_SHADE_TILT: + if (capabilities.supportsTiltOnClosed() || capabilities.supportsTilt180() + || capabilities.supportsTiltAnywhere()) { + scheduleWritePosition(new ShadeDataWriter().withSequence(writeSequence++) + .withTilt(percent.doubleValue()).getEncrypted(getEncryptionKey())); + } + break; + } + } catch (InvalidKeyException | IllegalArgumentException | NoSuchAlgorithmException | NoSuchPaddingException + | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + logger.warn("handleCommand() device={} error={}", device.getAddress(), e.getMessage(), + logger.isDebugEnabled() ? e : null); + } + } + } + + @Override + public void initialize() { + super.initialize(); + configuration = getConfigAs(ShadeConfiguration.class); + try { + new BluetoothAddress(configuration.address); + } catch (IllegalArgumentException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + return; + } + updateProperty(PROPERTY_HOME_ID, Integer.toHexString(homeId).toUpperCase()); + activityTimeout = Instant.now().plusSeconds(configuration.pollingDelay * 2); + + cancelTasks(false); + + int initialDelaySeconds = 0; + readTasks.add(scheduler.scheduleWithFixedDelay(() -> readThingStatus(), ++initialDelaySeconds, + configuration.heartbeatDelay, TimeUnit.SECONDS)); + readTasks.add(scheduler.scheduleWithFixedDelay(() -> readProperties(), ++initialDelaySeconds, + configuration.heartbeatDelay, TimeUnit.SECONDS)); + readTasks.add(scheduler.scheduleWithFixedDelay(() -> readBattery(), ++initialDelaySeconds, + configuration.pollingDelay, TimeUnit.SECONDS)); + } + + @Override + protected void onActivity() { + super.onActivity(); + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + activityTimeout = Instant.now().plusSeconds(configuration.pollingDelay * 2); + } + + /** + * Process the scan record and update the channels. + */ + @Override + public void onScanRecordReceived(BluetoothScanNotification scanNotification) { + super.onScanRecordReceived(scanNotification); + onActivity(); + byte[] value = scanNotification.getManufacturerData(); + if (Arrays.equals(cachedValue, value)) { + return; + } + cachedValue = value; + if (logger.isDebugEnabled()) { + logger.debug("onScanRecordReceived() device={} received value={}", device.getAddress(), + HexUtils.bytesToHex(value, ":")); + } + updatePosition(value); + } + + @Override + public void onServicesDiscovered() { + super.onServicesDiscovered(); + scheduleReadBattery(); + } + + /** + * Read the battery state. Blocks until the operation completes. + */ + private void readBattery() { + synchronized (this) { + if (device.isServicesDiscovered()) { + try { + connectAndWait(); + for (BluetoothService service : device.getServices()) { + BluetoothCharacteristic characteristic = service + .getCharacteristic(GattCharacteristic.BATTERY_LEVEL.getUUID()); + if (characteristic != null && characteristic.canRead()) { + byte[] value = device.readCharacteristic(characteristic).get(configuration.bleTimeout, + TimeUnit.SECONDS); + if (logger.isDebugEnabled()) { + logger.debug("readBattery() device={} read uuid={}, value={}", device.getAddress(), + characteristic.getUuid(), HexUtils.bytesToHex(value, ":")); + } + updateState(CHANNEL_SHADE_BATTERY_LEVEL, + value.length > 0 ? QuantityType.valueOf(value[0], Units.PERCENT) : UnDefType.UNDEF); + onActivity(); + } + } + } catch (ConnectionException | TimeoutException | ExecutionException | InterruptedException e) { + // Bluetooth has frequent errors so we do not normally log them + logger.debug("readBattery() device={}, error={}", device.getAddress(), e.getMessage()); + } + } + } + } + + /** + * Read the thing properties. Blocks until the operation completes. + */ + private void readProperties() { + synchronized (this) { + if (!propertiesLoaded && device.isServicesDiscovered()) { + Map properties = new HashMap<>(); + try { + connectAndWait(); + for (BluetoothService service : device.getServices()) { + for (Entry property : MAP_UID_PROPERTY_NAMES.entrySet()) { + BluetoothCharacteristic characteristic = service.getCharacteristic(property.getKey()); + if (characteristic != null && characteristic.canRead()) { + byte[] value = device.readCharacteristic(characteristic).get(configuration.bleTimeout, + TimeUnit.SECONDS); + if (logger.isDebugEnabled()) { + logger.debug("readProperties() device={} read uuid={}, value={}", + device.getAddress(), characteristic.getUuid(), + HexUtils.bytesToHex(value, ":")); + } + String propertyName = property.getValue(); + String propertyValue = BluetoothUtils.getStringValue(value, 0); + if (propertyValue != null) { + properties.put(propertyName, propertyValue); + } + } + } + } + } catch (ConnectionException | TimeoutException | ExecutionException | InterruptedException e) { + // Bluetooth has frequent errors so we do not normally log them + logger.debug("readProperties() device={}, error={}", device.getAddress(), e.getMessage()); + } finally { + if (!properties.isEmpty()) { + propertiesLoaded = true; + properties.put(Thing.PROPERTY_MAC_ADDRESS, device.getAddress().toString()); + thing.setProperties(properties); + onActivity(); + } + } + } + } + } + + /** + * Read the Bluetooth services. Blocks until the operation completes. + */ + private void readServices() { + synchronized (this) { + if (!device.isServicesDiscovered()) { + try { + connectAndWait(); + onActivity(); + } catch (ConnectionException | TimeoutException | InterruptedException e) { + // Bluetooth has frequent errors so we do not normally log them + logger.debug("readServices() device={}, error={}", device.getAddress(), e.getMessage()); + } + } + } + } + + /** + * Heartbeat task. Updates the online state and ensures that services are loaded. + */ + private void readThingStatus() { + if (thing.getStatus() == ThingStatus.ONLINE) { + if (Instant.now().isAfter(activityTimeout)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + } else { + readServices(); + } + } + } + + /** + * Schedule a readBattery command + */ + private void scheduleReadBattery() { + cancelTask(readBatteryTask, false); + readBatteryTask = scheduler.submit(() -> readBattery()); + } + + /** + * Schedule a writePosition command with the given value + */ + private void scheduleWritePosition(byte[] value) { + Instant taskId = Instant.now(); + writeTasks.put(taskId, scheduler.submit(() -> writePosition(taskId, value))); + } + + /** + * Update homeId and if necessary update the encryption key. + */ + private void updateHomeId(int newHomeId) { + if (homeId != newHomeId) { + homeId = newHomeId; + updateProperty(PROPERTY_HOME_ID, Integer.toHexString(homeId).toUpperCase()); + getEncryptionKey(); + } + } + + /** + * Update the position channels + */ + private void updatePosition(byte[] value) { + logger.debug("updatePosition() device={}", device.getAddress()); + dataReader.setBytes(value); + updateHomeId(dataReader.getHomeId()); + + Capabilities capabilities = this.capabilities; + if (capabilities == null) { + capabilities = CAPABILITIES_DATABASE.getCapabilities(dataReader.getTypeId(), null); + this.capabilities = capabilities; + + // remove unused channels + List removeChannels = new ArrayList<>(); + Channel channel; + if (!capabilities.supportsPrimary()) { + channel = thing.getChannel(CHANNEL_SHADE_PRIMARY); + if (channel != null) { + removeChannels.add(channel); + } + } + if (!capabilities.supportsSecondary()) { + channel = thing.getChannel(CHANNEL_SHADE_SECONDARY); + if (channel != null) { + removeChannels.add(channel); + } + } + if (!(capabilities.supportsTilt180() || capabilities.supportsTiltAnywhere() + || capabilities.supportsTiltOnClosed())) { + channel = thing.getChannel(CHANNEL_SHADE_TILT); + if (channel != null) { + removeChannels.add(channel); + } + } + if (!removeChannels.isEmpty()) { + updateThing(editThing().withoutChannels(removeChannels).build()); + } + } + + // update channel states + if (capabilities.supportsPrimary()) { + updateState(CHANNEL_SHADE_PRIMARY, dataReader.getPrimary()); + } + if (capabilities.supportsSecondary()) { + updateState(CHANNEL_SHADE_SECONDARY, dataReader.getSecondary()); + } + if (capabilities.supportsTilt180() || capabilities.supportsTiltAnywhere() + || capabilities.supportsTiltOnClosed()) { + updateState(CHANNEL_SHADE_TILT, dataReader.getTilt()); + } + } + + /** + * Write position channel value task. Blocks until the operation completes. + * + * @param taskId identifies the task entry in the writeTasks map + * @param value the data to write + */ + private void writePosition(Instant taskId, byte[] value) { + synchronized (this) { + try { + if (device.isServicesDiscovered()) { + connectAndWait(); + BluetoothService shadeService = device.getServices(UUID_SERVICE_SHADE); + if (shadeService != null) { + BluetoothCharacteristic characteristic = shadeService + .getCharacteristic(UUID_CHARACTERISTIC_POSITION); + if (characteristic != null) { + device.writeCharacteristic(characteristic, value).get(configuration.bleTimeout, + TimeUnit.SECONDS); + if (logger.isDebugEnabled()) { + logger.debug("writePosition() device={} sent uuid={}, value={}", device.getAddress(), + characteristic.getUuid(), HexUtils.bytesToHex(value, ":")); + } + onActivity(); + } + } + } + } catch (ConnectionException | TimeoutException | ExecutionException | InterruptedException e) { + // Bluetooth has frequent errors so we do not normally log them + logger.debug("writePosition() device={}, error={}", device.getAddress(), e.getMessage()); + } finally { + writeTasks.remove(taskId); + } + } + } +} diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/i18n/bluetooth.properties b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/i18n/bluetooth.properties new file mode 100644 index 0000000000000..8721de2f3a600 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/i18n/bluetooth.properties @@ -0,0 +1,24 @@ +# thing types + +thing-type.bluetooth.shade.label = PowerView Shade +thing-type.bluetooth.shade.description = Hunter Douglas (Luxaflex) PowerView Gen3 Shade + +# thing types config + +thing-type.config.bluetooth.shade.address.label = Address +thing-type.config.bluetooth.shade.address.description = Bluetooth address in XX:XX:XX:XX:XX:XX format +thing-type.config.bluetooth.shade.bleTimeout.label = BLE Timeout +thing-type.config.bluetooth.shade.bleTimeout.description = Timeout in seconds for Bluetooth Low Energy operations +thing-type.config.bluetooth.shade.heartbeatDelay.label = Heartbeat Interval +thing-type.config.bluetooth.shade.heartbeatDelay.description = Interval in seconds for Bluetooth device heart beat checks +thing-type.config.bluetooth.shade.pollingDelay.label = Polling Interval +thing-type.config.bluetooth.shade.pollingDelay.description = Interval in seconds for polling the battery state + +# channel types + +channel-type.bluetooth.primary.label = Position +channel-type.bluetooth.primary.description = The vertical position of the shade +channel-type.bluetooth.secondary.label = Secondary Position +channel-type.bluetooth.secondary.description = The secondary vertical position (on top-down/bottom-up shades) +channel-type.bluetooth.tilt.label = Tilt +channel-type.bluetooth.tilt.description = The tilt of the slats in the shade diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/thing/thing.xml b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/thing/thing.xml new file mode 100644 index 0000000000000..0a89cc750b61d --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/thing/thing.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + Hunter Douglas (Luxaflex) PowerView Gen3 Shade + + + + + + + + + + + + + Bluetooth address in XX:XX:XX:XX:XX:XX format + + + + true + Interval in seconds for polling the battery state + 300 + + + + true + Interval in seconds for Bluetooth device heart beat checks + 15 + + + + true + Timeout in seconds for Bluetooth Low Energy operations + 6 + + + + Encryption key to be used on position commands + + + + + + Rollershutter + + The vertical position of the shade + Blinds + + + + + Rollershutter + + The secondary vertical position (on top-down/bottom-up shades) + Blinds + + + + + Dimmer + + The tilt of the slats in the shade + Blinds + + + + diff --git a/bundles/org.openhab.binding.bluetooth.hdpowerview/src/test/java/org/openhab/binding/bluetooth/hdpowerview/test/ShadeTests.java b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/test/java/org/openhab/binding/bluetooth/hdpowerview/test/ShadeTests.java new file mode 100644 index 0000000000000..d55831ea13166 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.hdpowerview/src/test/java/org/openhab/binding/bluetooth/hdpowerview/test/ShadeTests.java @@ -0,0 +1,271 @@ +/** + * Copyright (c) 2010-2024 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.hdpowerview.test; + +import static org.junit.jupiter.api.Assertions.*; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.bluetooth.hdpowerview.internal.shade.ShadeDataWriter; +import org.openhab.core.util.HexUtils; + +/** + * Test of shade position calculations etc. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class ShadeTests { + + /** + * Map of position command values as sniffed during testing with the HD Powerview App. The map keys are the target + * position values (range 0..100%) set manually via the App, and the map values are the results sniffed as output + * from the App. + */ + private static final Map HD_POWERVIEW_APP_OBSERVED_RESULTS = new TreeMap<>(); + static { + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.00, HexFormat.ofDelimiter(":").parseHex("5c:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.01, HexFormat.ofDelimiter(":").parseHex("5d:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.02, HexFormat.ofDelimiter(":").parseHex("5e:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.03, HexFormat.ofDelimiter(":").parseHex("5f:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.04, HexFormat.ofDelimiter(":").parseHex("58:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.05, HexFormat.ofDelimiter(":").parseHex("59:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.06, HexFormat.ofDelimiter(":").parseHex("5a:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.07, HexFormat.ofDelimiter(":").parseHex("5b:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.08, HexFormat.ofDelimiter(":").parseHex("54:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.09, HexFormat.ofDelimiter(":").parseHex("55:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.10, HexFormat.ofDelimiter(":").parseHex("56:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.11, HexFormat.ofDelimiter(":").parseHex("57:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.12, HexFormat.ofDelimiter(":").parseHex("50:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.13, HexFormat.ofDelimiter(":").parseHex("51:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.14, HexFormat.ofDelimiter(":").parseHex("52:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.15, HexFormat.ofDelimiter(":").parseHex("53:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.16, HexFormat.ofDelimiter(":").parseHex("4c:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.17, HexFormat.ofDelimiter(":").parseHex("4d:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.18, HexFormat.ofDelimiter(":").parseHex("4e:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.19, HexFormat.ofDelimiter(":").parseHex("4f:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.20, HexFormat.ofDelimiter(":").parseHex("48:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.30, HexFormat.ofDelimiter(":").parseHex("42:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.40, HexFormat.ofDelimiter(":").parseHex("74:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.50, HexFormat.ofDelimiter(":").parseHex("6e:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.60, HexFormat.ofDelimiter(":").parseHex("60:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.70, HexFormat.ofDelimiter(":").parseHex("1a:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.80, HexFormat.ofDelimiter(":").parseHex("0c:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(0.90, HexFormat.ofDelimiter(":").parseHex("06:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.00, HexFormat.ofDelimiter(":").parseHex("38:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.10, HexFormat.ofDelimiter(":").parseHex("32:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.20, HexFormat.ofDelimiter(":").parseHex("24:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.30, HexFormat.ofDelimiter(":").parseHex("de:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.40, HexFormat.ofDelimiter(":").parseHex("d0:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.50, HexFormat.ofDelimiter(":").parseHex("ca:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.60, HexFormat.ofDelimiter(":").parseHex("fc:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.70, HexFormat.ofDelimiter(":").parseHex("f6:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.80, HexFormat.ofDelimiter(":").parseHex("e8:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(1.90, HexFormat.ofDelimiter(":").parseHex("e2:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(2.00, HexFormat.ofDelimiter(":").parseHex("94:87")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(3.00, HexFormat.ofDelimiter(":").parseHex("70:86")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(4.00, HexFormat.ofDelimiter(":").parseHex("cc:86")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(5.00, HexFormat.ofDelimiter(":").parseHex("a8:86")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(6.00, HexFormat.ofDelimiter(":").parseHex("04:85")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(7.00, HexFormat.ofDelimiter(":").parseHex("e0:85")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(8.00, HexFormat.ofDelimiter(":").parseHex("7c:84")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(9.00, HexFormat.ofDelimiter(":").parseHex("d8:84")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(10.00, HexFormat.ofDelimiter(":").parseHex("b4:84")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(11.00, HexFormat.ofDelimiter(":").parseHex("10:83")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(12.00, HexFormat.ofDelimiter(":").parseHex("ec:83")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(13.00, HexFormat.ofDelimiter(":").parseHex("48:82")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(14.00, HexFormat.ofDelimiter(":").parseHex("24:82")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(15.00, HexFormat.ofDelimiter(":").parseHex("80:82")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(16.00, HexFormat.ofDelimiter(":").parseHex("1c:81")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(17.00, HexFormat.ofDelimiter(":").parseHex("f8:81")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(18.00, HexFormat.ofDelimiter(":").parseHex("54:80")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(19.00, HexFormat.ofDelimiter(":").parseHex("30:80")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.00, HexFormat.ofDelimiter(":").parseHex("8c:80")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.46, HexFormat.ofDelimiter(":").parseHex("a2:80")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.47, HexFormat.ofDelimiter(":").parseHex("a3:80")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.48, HexFormat.ofDelimiter(":").parseHex("5c:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.49, HexFormat.ofDelimiter(":").parseHex("5c:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(20.50, HexFormat.ofDelimiter(":").parseHex("5e:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(21.00, HexFormat.ofDelimiter(":").parseHex("68:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(22.00, HexFormat.ofDelimiter(":").parseHex("c4:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(23.00, HexFormat.ofDelimiter(":").parseHex("a0:8f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(24.00, HexFormat.ofDelimiter(":").parseHex("3c:8e")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(25.00, HexFormat.ofDelimiter(":").parseHex("98:8e")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(26.00, HexFormat.ofDelimiter(":").parseHex("74:8d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(27.00, HexFormat.ofDelimiter(":").parseHex("d0:8d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(28.00, HexFormat.ofDelimiter(":").parseHex("ac:8d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(29.00, HexFormat.ofDelimiter(":").parseHex("08:8c")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(30.00, HexFormat.ofDelimiter(":").parseHex("e4:8c")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(31.00, HexFormat.ofDelimiter(":").parseHex("40:8b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(32.00, HexFormat.ofDelimiter(":").parseHex("dc:8b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(33.00, HexFormat.ofDelimiter(":").parseHex("b8:8b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(34.00, HexFormat.ofDelimiter(":").parseHex("14:8a")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(35.00, HexFormat.ofDelimiter(":").parseHex("f0:8a")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(36.00, HexFormat.ofDelimiter(":").parseHex("4c:89")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(37.00, HexFormat.ofDelimiter(":").parseHex("28:89")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(38.00, HexFormat.ofDelimiter(":").parseHex("84:89")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(39.00, HexFormat.ofDelimiter(":").parseHex("60:88")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.00, HexFormat.ofDelimiter(":").parseHex("fc:88")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.94, HexFormat.ofDelimiter(":").parseHex("a2:88")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.95, HexFormat.ofDelimiter(":").parseHex("a3:88")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.96, HexFormat.ofDelimiter(":").parseHex("5c:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.97, HexFormat.ofDelimiter(":").parseHex("5d:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(40.98, HexFormat.ofDelimiter(":").parseHex("5e:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(41.00, HexFormat.ofDelimiter(":").parseHex("58:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(42.00, HexFormat.ofDelimiter(":").parseHex("34:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(43.00, HexFormat.ofDelimiter(":").parseHex("90:97")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(44.00, HexFormat.ofDelimiter(":").parseHex("6c:96")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(45.00, HexFormat.ofDelimiter(":").parseHex("c8:96")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(46.00, HexFormat.ofDelimiter(":").parseHex("a4:96")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(47.00, HexFormat.ofDelimiter(":").parseHex("00:95")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(48.00, HexFormat.ofDelimiter(":").parseHex("9c:95")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(49.00, HexFormat.ofDelimiter(":").parseHex("78:94")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(50.00, HexFormat.ofDelimiter(":").parseHex("d4:94")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(51.00, HexFormat.ofDelimiter(":").parseHex("b0:94")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(52.00, HexFormat.ofDelimiter(":").parseHex("0c:93")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(53.00, HexFormat.ofDelimiter(":").parseHex("e8:93")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(54.00, HexFormat.ofDelimiter(":").parseHex("44:92")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(55.00, HexFormat.ofDelimiter(":").parseHex("20:92")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(56.00, HexFormat.ofDelimiter(":").parseHex("bc:92")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(57.00, HexFormat.ofDelimiter(":").parseHex("18:91")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(58.00, HexFormat.ofDelimiter(":").parseHex("f4:91")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(59.00, HexFormat.ofDelimiter(":").parseHex("50:90")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(60.00, HexFormat.ofDelimiter(":").parseHex("2c:90")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.00, HexFormat.ofDelimiter(":").parseHex("88:90")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.42, HexFormat.ofDelimiter(":").parseHex("a2:90")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.43, HexFormat.ofDelimiter(":").parseHex("a3:90")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.44, HexFormat.ofDelimiter(":").parseHex("5c:9f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.45, HexFormat.ofDelimiter(":").parseHex("5d:9f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(61.46, HexFormat.ofDelimiter(":").parseHex("5e:9f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(62.00, HexFormat.ofDelimiter(":").parseHex("64:9f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(63.00, HexFormat.ofDelimiter(":").parseHex("c0:9f")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(64.00, HexFormat.ofDelimiter(":").parseHex("5c:9e")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(65.00, HexFormat.ofDelimiter(":").parseHex("38:9e")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(66.00, HexFormat.ofDelimiter(":").parseHex("94:9e")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(67.00, HexFormat.ofDelimiter(":").parseHex("70:9d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(68.00, HexFormat.ofDelimiter(":").parseHex("cc:9d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(69.00, HexFormat.ofDelimiter(":").parseHex("a8:9d")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(70.00, HexFormat.ofDelimiter(":").parseHex("04:9c")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(71.00, HexFormat.ofDelimiter(":").parseHex("e0:9c")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(72.00, HexFormat.ofDelimiter(":").parseHex("7c:9b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(73.00, HexFormat.ofDelimiter(":").parseHex("d8:9b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(74.00, HexFormat.ofDelimiter(":").parseHex("b4:9b")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(75.00, HexFormat.ofDelimiter(":").parseHex("10:9a")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(76.00, HexFormat.ofDelimiter(":").parseHex("ec:9a")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(77.00, HexFormat.ofDelimiter(":").parseHex("48:99")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(78.00, HexFormat.ofDelimiter(":").parseHex("24:99")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(79.00, HexFormat.ofDelimiter(":").parseHex("80:99")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(80.00, HexFormat.ofDelimiter(":").parseHex("1c:98")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.00, HexFormat.ofDelimiter(":").parseHex("f8:98")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.90, HexFormat.ofDelimiter(":").parseHex("a2:98")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.91, HexFormat.ofDelimiter(":").parseHex("a3:98")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.92, HexFormat.ofDelimiter(":").parseHex("5c:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.93, HexFormat.ofDelimiter(":").parseHex("5d:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(81.94, HexFormat.ofDelimiter(":").parseHex("5e:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(82.00, HexFormat.ofDelimiter(":").parseHex("54:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(83.00, HexFormat.ofDelimiter(":").parseHex("30:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(84.00, HexFormat.ofDelimiter(":").parseHex("8c:a7")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(85.00, HexFormat.ofDelimiter(":").parseHex("68:a6")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(86.00, HexFormat.ofDelimiter(":").parseHex("c4:a6")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(87.00, HexFormat.ofDelimiter(":").parseHex("a0:a6")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(88.00, HexFormat.ofDelimiter(":").parseHex("3c:a5")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(89.00, HexFormat.ofDelimiter(":").parseHex("98:a5")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(90.00, HexFormat.ofDelimiter(":").parseHex("74:a4")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(91.00, HexFormat.ofDelimiter(":").parseHex("d0:a4")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(92.00, HexFormat.ofDelimiter(":").parseHex("ac:a4")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(93.00, HexFormat.ofDelimiter(":").parseHex("08:a3")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(94.00, HexFormat.ofDelimiter(":").parseHex("e4:a3")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(95.00, HexFormat.ofDelimiter(":").parseHex("40:a2")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(96.00, HexFormat.ofDelimiter(":").parseHex("dc:a2")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(97.00, HexFormat.ofDelimiter(":").parseHex("b8:a2")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(98.00, HexFormat.ofDelimiter(":").parseHex("14:a1")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(99.00, HexFormat.ofDelimiter(":").parseHex("f0:a1")); + HD_POWERVIEW_APP_OBSERVED_RESULTS.put(100.00, HexFormat.ofDelimiter(":").parseHex("4c:a0")); + } + + private static final String TEST_KEY = "02c2efcbd4064d59409c980e627e2fc7"; // (or 9440bf8b334c2b6c8564d80548b67c00) + + /** + * Compare the results of the binding {@code ShadeDataWriter} conversions against the results of the HD Powerview + * App conversions, as sniffed over the air using a Bluetooth sniffer. + */ + @Test + void testCalculatedEqualsObserved() { + for (Entry observedResult : HD_POWERVIEW_APP_OBSERVED_RESULTS.entrySet()) { + try { + byte[] calculated = new ShadeDataWriter().withPrimary(observedResult.getKey()).getEncrypted(TEST_KEY); + byte[] observed = observedResult.getValue(); + assertEquals(observed[0], calculated[4], 1); // allow error of 1 in LSB for rounding + assertEquals(observed[1], calculated[5]); + + } catch (InvalidKeyException | IllegalArgumentException | NoSuchAlgorithmException | NoSuchPaddingException + | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + fail(e); + } + } + } + + /** + * Test that {@code ShadeDataWriter} produces correct values. + */ + @Test + void testShadeDataWriter() { + try { + String actual; + String expected; + + // test basic output + actual = HexUtils.bytesToHex(new ShadeDataWriter().getEncrypted(TEST_KEY)); + expected = "1F70847E5C07AD03100E0FB3DA"; + assertTrue(expected.equals(actual)); + + // test sequence number only + actual = HexUtils.bytesToHex(new ShadeDataWriter().withSequence((byte) 1).getEncrypted(TEST_KEY)); + expected = "1F70857E5C07AD03100E0FB3DA"; + assertTrue(expected.equals(actual)); + + // test primary position only + actual = HexUtils.bytesToHex(new ShadeDataWriter().withPrimary(100).getEncrypted(TEST_KEY)); + expected = "1F70847E4CA0AD03100E0FB3DA"; + assertTrue(expected.equals(actual)); + + // test tilt position only + actual = HexUtils.bytesToHex(new ShadeDataWriter().withTilt(40).getEncrypted(TEST_KEY)); + expected = "1F70847E5C07AD03100E2733DA"; + assertTrue(expected.equals(actual)); + + // test sequence number, plus primary position, plus secondary position + expected = "1F70227EE48C4580100E0FB3DA"; + actual = HexUtils.bytesToHex(new ShadeDataWriter().withSequence((byte) 0xa6).withPrimary(30) + .withSecondary(10).getEncrypted(TEST_KEY)); + assertTrue(expected.equals(actual)); + + } catch (InvalidKeyException | IllegalArgumentException | NoSuchAlgorithmException | NoSuchPaddingException + | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + fail(e); + } + } +} diff --git a/bundles/org.openhab.binding.bluetooth/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.bluetooth/src/main/resources/OH-INF/addon/addon.xml index 8628a274d334f..05e49f6a21c65 100644 --- a/bundles/org.openhab.binding.bluetooth/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.bluetooth/src/main/resources/OH-INF/addon/addon.xml @@ -8,4 +8,20 @@ This binding supports the Bluetooth protocol. local + + + usb + + + manufacturer + (?i).*bluegiga.* + + + chipId + 0258:0001 + + + + + diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/database/ShadeCapabilitiesDatabase.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/database/ShadeCapabilitiesDatabase.java index 591af5dd9e905..070791cc60816 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/database/ShadeCapabilitiesDatabase.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/database/ShadeCapabilitiesDatabase.java @@ -24,10 +24,17 @@ /** * Class containing the database of all known shade 'types' and their respective 'capabilities'. - * + *

    * If user systems detect shade types that are not in the database, then this class can issue logger warning messages * indicating such absence, and prompting the user to report it to developers so that the database and the respective * binding functionality can (hopefully) be extended over time. + *

    + * NOTA BENE: this database is required by the two bindings listed below. It is maintained here in the former + * binding, but it is consumed also by the latter binding. Therefore do NOT delete or modify this file unless you + * have carefully checked against regressions in the latter binding. + *

  • HD Powerview binding: 'org.openhab.binding.hdpowerview
  • + *
  • HD Powerview Bluetooth Low Energy binding: 'org.openhab.binding.bluetooth.hdpowerview
  • + *

    * * @author Andrew Fiddian-Green - Initial Contribution */ diff --git a/bundles/pom.xml b/bundles/pom.xml index fc438ed1fefa1..05972b267b340 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -83,6 +83,7 @@ org.openhab.binding.bluetooth.generic org.openhab.binding.bluetooth.govee org.openhab.binding.bluetooth.grundfosalpha + org.openhab.binding.bluetooth.hdpowerview org.openhab.binding.bluetooth.radoneye org.openhab.binding.bluetooth.roaming org.openhab.binding.bluetooth.ruuvitag diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml index eab649b5c3965..df4215793661e 100644 --- a/features/openhab-addons/src/main/resources/footer.xml +++ b/features/openhab-addons/src/main/resources/footer.xml @@ -14,6 +14,7 @@ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.grundfosalpha/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.hdpowerview/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.radoneye/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}