/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
--- /dev/null
+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
--- /dev/null
+# 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"}
+```
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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");
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+ }
+}
--- /dev/null
+# 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
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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);
+ }
+ }
+}
<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>
/**
* 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
*/
<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>
<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>