[bluetooth.hdpowerview] New binding using Bluetooth Low Energy (#17099)

* [bluetooth.hdpowerview] initial contribution

Signed-off-by: AndrewFG <software@whitebear.ch>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Andrew Fiddian-Green 2024-09-08 16:28:30 +01:00 committed by Ciprian Pascu
parent ecede36feb
commit 836c3bdb64
19 changed files with 1627 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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"}
```

View File

@ -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>

View File

@ -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>

View File

@ -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");
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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

View File

@ -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>

View File

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

View 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>

View 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
*/

View 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>

View 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>