]> git.basschouten.com Git - openhab-addons.git/commitdiff
[bluetooth.hdpowerview] New binding using Bluetooth Low Energy (#17099)
authorAndrew Fiddian-Green <software@whitebear.ch>
Sun, 8 Sep 2024 15:28:30 +0000 (16:28 +0100)
committerGitHub <noreply@github.com>
Sun, 8 Sep 2024 15:28:30 +0000 (17:28 +0200)
* [bluetooth.hdpowerview] initial contribution

Signed-off-by: AndrewFG <software@whitebear.ch>
19 files changed:
CODEOWNERS
bundles/org.openhab.binding.bluetooth.hdpowerview/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/README.md [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/ShadeBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/discovery/ShadeDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/factory/ShadeHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataReader.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeDataWriter.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/java/org/openhab/binding/bluetooth/hdpowerview/internal/shade/ShadeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/i18n/bluetooth.properties [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/main/resources/OH-INF/thing/thing.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.hdpowerview/src/test/java/org/openhab/binding/bluetooth/hdpowerview/test/ShadeTests.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth/src/main/resources/OH-INF/addon/addon.xml
bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/database/ShadeCapabilitiesDatabase.java
bundles/pom.xml
features/openhab-addons/src/main/resources/footer.xml

index 2c497dafe73baaba85574ea8c000507082b9a9e7..241076ea3586ea9a3306c798f9abdc109aee6b8b 100755 (executable)
@@ -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 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..9ecbcf1
--- /dev/null
@@ -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 (file)
index 0000000..f0248cc
--- /dev/null
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.3.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.bluetooth.hdpowerview</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: HD Powerview Bluetooth Adapter</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.bluetooth</artifactId>
+      <version>${project.version}</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-resources-plugin</artifactId>
+        <executions>
+          <execution>
+            <phase>generate-sources</phase>
+            <goals>
+              <goal>copy-resources</goal>
+            </goals>
+            <configuration>
+              <outputDirectory>${project.build.directory}/import</outputDirectory>
+              <overwrite>true</overwrite>
+              <resources>
+                <resource>
+                  <directory>../org.openhab.binding.hdpowerview/src/main/java</directory>
+                  <includes>
+                    <include>**/ShadeCapabilitiesDatabase.java</include>
+                  </includes>
+                </resource>
+              </resources>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.codehaus.mojo</groupId>
+        <artifactId>build-helper-maven-plugin</artifactId>
+        <executions>
+          <execution>
+            <phase>generate-sources</phase>
+            <goals>
+              <goal>add-source</goal>
+            </goals>
+            <configuration>
+              <sources>
+                <source>${project.build.directory}/import</source>
+              </sources>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+
+</project>
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 (file)
index 0000000..63ef6e7
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.bluetooth.hdpowerview-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-bluetooth-hdpowerview" description="HD Powerview Bluetooth Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <feature>openhab-transport-serial</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}</bundle>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.hdpowerview/${project.version}</bundle>
+       </feature>
+
+</features>
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 (file)
index 0000000..cc8c177
--- /dev/null
@@ -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<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SHADE);
+
+    public static final int HUNTER_DOUGLAS_MANUFACTURER_ID = 0x819;
+
+    public static final Map<UUID, String> 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 (file)
index 0000000..7adac78
--- /dev/null
@@ -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<ThingTypeUID> 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<String, Object> 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 (file)
index 0000000..adde4c3
--- /dev/null
@@ -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 (file)
index 0000000..c3c713c
--- /dev/null
@@ -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 (file)
index 0000000..a9b16ff
--- /dev/null
@@ -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 (file)
index 0000000..de79f29
--- /dev/null
@@ -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 (file)
index 0000000..d6c61f8
--- /dev/null
@@ -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<Integer, String> HOME_ID_ENCRYPTION_KEYS = new ConcurrentHashMap<>();
+
+    private final Logger logger = LoggerFactory.getLogger(ShadeHandler.class);
+    private final List<Future<?>> readTasks = new ArrayList<>();
+    private final Map<Instant, Future<?>> 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..
+     *
+     * <li>The key for this specific Thing via its own configuration properties, or</li>
+     * <li>The key for any other Thing with the same homeId via the shared ENCRYPTION_KEYS map</li>
+     */
+    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<String, String> properties = new HashMap<>();
+                try {
+                    connectAndWait();
+                    for (BluetoothService service : device.getServices()) {
+                        for (Entry<UUID, String> 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<Channel> 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 (file)
index 0000000..8721de2
--- /dev/null
@@ -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 (file)
index 0000000..0a89cc7
--- /dev/null
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="bluetooth"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <!-- Shade Thing Type -->
+       <thing-type id="shade">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="roaming"/>
+                       <bridge-type-ref id="bluegiga"/>
+                       <bridge-type-ref id="bluez"/>
+               </supported-bridge-type-refs>
+
+               <label>PowerView Shade</label>
+               <description>Hunter Douglas (Luxaflex) PowerView Gen3 Shade</description>
+
+               <channels>
+                       <channel id="primary" typeId="primary"/>
+                       <channel id="secondary" typeId="secondary"/>
+                       <channel id="tilt" typeId="tilt"/>
+                       <channel id="battery-level" typeId="system.battery-level"/>
+                       <channel id="rssi" typeId="rssi"/>
+               </channels>
+
+               <config-description>
+                       <parameter name="address" type="text" required="true">
+                               <label>Address</label>
+                               <description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
+                       </parameter>
+                       <parameter name="pollingDelay" type="integer" min="60">
+                               <label>Polling Interval</label>
+                               <advanced>true</advanced>
+                               <description>Interval in seconds for polling the battery state</description>
+                               <default>300</default>
+                       </parameter>
+                       <parameter name="heartbeatDelay" type="integer" min="5">
+                               <label>Heartbeat Interval</label>
+                               <advanced>true</advanced>
+                               <description>Interval in seconds for Bluetooth device heart beat checks</description>
+                               <default>15</default>
+                       </parameter>
+                       <parameter name="bleTimeout" type="integer" min="1">
+                               <label>BLE Timeout</label>
+                               <advanced>true</advanced>
+                               <description>Timeout in seconds for Bluetooth Low Energy operations</description>
+                               <default>6</default>
+                       </parameter>
+                       <parameter name="encryptionKey" type="text">
+                               <label>Encryption Key</label>
+                               <description>Encryption key to be used on position commands</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="primary">
+               <item-type>Rollershutter</item-type>
+               <label>Position</label>
+               <description>The vertical position of the shade</description>
+               <category>Blinds</category>
+               <state min="0" max="100" step="1" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="secondary">
+               <item-type>Rollershutter</item-type>
+               <label>Secondary Position</label>
+               <description>The secondary vertical position (on top-down/bottom-up shades)</description>
+               <category>Blinds</category>
+               <state min="0" max="100" step="1" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="tilt">
+               <item-type>Dimmer</item-type>
+               <label>Tilt</label>
+               <description>The tilt of the slats in the shade</description>
+               <category>Blinds</category>
+               <state min="0" max="100" step="1" pattern="%.1f %%"/>
+       </channel-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..d55831e
--- /dev/null
@@ -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<Double, byte[]> 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<Double, byte[]> 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);
+        }
+    }
+}
index 8628a274d334f742696bcaadf91d4448b4926e08..05e49f6a21c65689ae150cd2f48c811d4c2c36da 100644 (file)
@@ -8,4 +8,20 @@
        <description>This binding supports the Bluetooth protocol.</description>
        <connection>local</connection>
 
+       <discovery-methods>
+               <discovery-method>
+                       <service-type>usb</service-type>
+                       <match-properties>
+                               <match-property>
+                                       <name>manufacturer</name>
+                                       <regex>(?i).*bluegiga.*</regex>
+                               </match-property>
+                               <match-property>
+                                       <name>chipId</name>
+                                       <regex>0258:0001</regex>
+                               </match-property>
+                       </match-properties>
+               </discovery-method>
+       </discovery-methods>
+
 </addon:addon>
index 591af5dd9e9058bdba50fb56dd82e8aaa942e9ad..070791cc60816808ce7f0695b370d6f121d5a373 100644 (file)
@@ -24,10 +24,17 @@ import org.slf4j.LoggerFactory;
 
 /**
  * Class containing the database of all known shade 'types' and their respective 'capabilities'.
- *
+ * <p>
  * 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.
+ * <p>
+ * <b>NOTA BENE</b>: 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 <b>do NOT delete or modify this file</b> unless you
+ * have carefully checked against regressions in the latter binding.
+ * <li>HD Powerview binding: 'org.openhab.binding.hdpowerview</li>
+ * <li>HD Powerview Bluetooth Low Energy binding: 'org.openhab.binding.bluetooth.hdpowerview</li>
+ * <p>
  *
  * @author Andrew Fiddian-Green - Initial Contribution
  */
index 28596db8a284948d2f836afd450e634b0b3cb5cb..8cb255be2f87551da1500c9fc4d8a6180557b2dd 100644 (file)
@@ -83,6 +83,7 @@
     <module>org.openhab.binding.bluetooth.generic</module>
     <module>org.openhab.binding.bluetooth.govee</module>
     <module>org.openhab.binding.bluetooth.grundfosalpha</module>
+    <module>org.openhab.binding.bluetooth.hdpowerview</module>
     <module>org.openhab.binding.bluetooth.radoneye</module>
     <module>org.openhab.binding.bluetooth.roaming</module>
     <module>org.openhab.binding.bluetooth.ruuvitag</module>
index eab649b5c39659b98b862826a43f3edc18192067..df4215793661e9f8fb1f49e1574fa89980ba286a 100644 (file)
@@ -14,6 +14,7 @@
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.grundfosalpha/${project.version}</bundle>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.hdpowerview/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.radoneye/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>