From 65b7b3814b0950545146a7598ba664de482d6451 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Fri, 20 Dec 2024 20:41:01 +0100 Subject: [PATCH 01/10] disabled home_id filter --- custom_components/hunterdouglas_powerview_ble/manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index 0038dc6..e55df72 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -4,8 +4,7 @@ "bluetooth": [ { "service_uuid": "0000fdc1-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 2073, - "manufacturer_data_start": [0,0] + "manufacturer_id": 2073 } ], "codeowners": ["@patman15"], From b6b7e43b026f4d3288d4d55451c114cc9aec7870 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 22 Dec 2024 13:35:29 +0100 Subject: [PATCH 02/10] stronger typing --- .gitignore | 2 + .../hunterdouglas_powerview_ble/api.py | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index b68bb3b..fbb1d23 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,5 @@ cython_debug/ #.idea/ aes.py *.bak +emu/PV_BLE_cover/PV_BLE_cover.ino +img/ diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 3567b3c..4c1500e 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -12,6 +12,10 @@ from bleak.uuids import normalize_uuid_str from bleak_retry_connector import establish_connection from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.ciphers.base import ( + AEADDecryptionContext, + AEADEncryptionContext, +) from homeassistant.components.cover import ATTR_CURRENT_POSITION from .const import LOGGER, TIMEOUT @@ -25,6 +29,7 @@ SHADE_TYPE: Final[dict[int, str]] = { + # up down only 1: "Designer Roller", 4: "Roman", 5: "Bottom Up", @@ -38,6 +43,11 @@ 52: "Banded Shades", 53: "Sonnette", 84: "Vignette", + # top down bottom up + 8: "Duette, Top Down Bottom Up", + 9: "Duette DuoLite, Top Down Bottom Up", + 33: "Duette Architella, Top Down Bottom Up", + 47: "Pleated, Top Down Bottom Up", } OPEN_POSITION: Final[int] = 100 @@ -91,11 +101,11 @@ def __init__(self, ble_device: BLEDevice, home_key: bytes = b"") -> None: ], ) self._data_event = asyncio.Event() - self._data: bytearray + self._data: bytearray = bytearray() self._info: PVDeviceInfo = PVDeviceInfo() self._cmd_lock: Final = asyncio.Lock() - self._cmd_next = None - self._cipher: Final = ( + self._cmd_next: tuple[ShadeCmd, bytes] + self._cipher: Final[Cipher | None] = ( Cipher(algorithms.AES(home_key), modes.CTR(bytearray(16))) if len(home_key) == 16 else None @@ -117,7 +127,7 @@ def is_connected(self) -> bool: # general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len async def _cmd( - self, cmd: tuple[ShadeCmd, bytearray], disconnect: bool = True + self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True ) -> None: self._cmd_next = cmd if self._cmd_lock.locked(): @@ -127,16 +137,14 @@ async def _cmd( async with self._cmd_lock: try: await self._connect() - cmd_run = self._cmd_next - tx_data = ( - bytearray( - int.to_bytes(cmd_run[0].value, 2, byteorder="little") - + bytes([self._seqcnt, len(cmd_run[1])]) - ) + cmd_run: tuple[ShadeCmd, bytes] = self._cmd_next + tx_data: bytes = bytes( + int.to_bytes(cmd_run[0].value, 2, byteorder="little") + + bytes([self._seqcnt, len(cmd_run[1])]) + cmd_run[1] ) if self._cipher is not None: - enc = self._cipher.encryptor() + enc: AEADEncryptionContext = self._cipher.encryptor() tx_data = enc.update(tx_data) + enc.finalize() self._data_event.clear() LOGGER.debug("sending cmd: %s", tx_data) @@ -161,8 +169,8 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: if len(data) != 9: LOGGER.debug("not a V2 record!") return [] - pos = int.from_bytes(data[3:5], byteorder="little") - pos2 = (int(data[5]) << 4) + (int(data[4]) >> 4) + pos: int = int.from_bytes(data[3:5], byteorder="little") + pos2: int = (int(data[5]) << 4) + (int(data[4]) >> 4) return [ (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), ("position2", pos2 >> 2), @@ -185,7 +193,7 @@ async def set_position(self, value: int, disconnect: bool = True) -> None: await self._cmd( ( ShadeCmd.SET_POSITION, - bytearray( + bytes( int.to_bytes(value * 100, 2, byteorder="little") + bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0]) ), @@ -201,7 +209,7 @@ async def open(self) -> None: async def stop(self) -> None: """Stop device movement.""" LOGGER.debug("%s stop", self.name) - await self._cmd((ShadeCmd.STOP, bytearray(b""))) + await self._cmd((ShadeCmd.STOP, b"")) async def close(self) -> None: """Fully close cover.""" @@ -217,7 +225,7 @@ async def activate_scene(self, idx: int) -> None: await self._cmd( ( ShadeCmd.ACTIVATE_SCENE, - bytearray(int.to_bytes(idx, 1, byteorder="little") + bytes([0xA2])), + int.to_bytes(idx, 1, byteorder="little") + bytes([0xA2]), ), ) @@ -225,7 +233,7 @@ def _verify_response(self, din: bytearray, seq_nr: int, cmd: ShadeCmd) -> bool: """Verify shade response data.""" data: bytearray = din if self._cipher is not None: - dec = self._cipher.decryptor() + dec: AEADDecryptionContext = self._cipher.decryptor() data = bytearray(dec.update(din) + dec.finalize()) if len(data) < 4: LOGGER.error("Reponse message too short") From 8c5c3d3c9aaa537a96bd8ea15d38627fa749dcf4 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 29 Dec 2024 13:00:15 +0100 Subject: [PATCH 03/10] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fbb1d23..f7835aa 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ aes.py *.bak emu/PV_BLE_cover/PV_BLE_cover.ino img/ +*.zip From ea33e08da03b077558481c90b86657acfa4dbe71 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 5 Jan 2025 12:12:16 +0100 Subject: [PATCH 04/10] extend set_position() for all values --- .../hunterdouglas_powerview_ble/api.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 87e2720..df2149c 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -16,7 +16,10 @@ AEADDecryptionContext, AEADEncryptionContext, ) -from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +) from .const import LOGGER, TIMEOUT @@ -122,7 +125,7 @@ def encrypted(self) -> bool: return self._is_encrypted @encrypted.setter - def encrypted(self, value:bool) -> None: + def encrypted(self, value: bool) -> None: self._is_encrypted = value @property @@ -136,9 +139,7 @@ def is_connected(self) -> bool: return self._client.is_connected # general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len - async def _cmd( - self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True - ) -> None: + async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> None: self._cmd_next = cmd if self._cmd_lock.locked(): LOGGER.debug("%s: device busy, queuing %s command", self.name, cmd[0]) @@ -185,7 +186,7 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), ("position2", pos2 >> 2), ("position3", int(data[6])), - ("tilt", int(data[7])), + (ATTR_CURRENT_TILT_POSITION, int(data[7])), ("home_id", int.from_bytes(data[0:2], byteorder="little")), ("type_id", int.from_bytes(data[2:3])), ("is_opening", bool(pos & 0x3 == 0x2)), @@ -197,16 +198,25 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: ] # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity - async def set_position(self, value: int, disconnect: bool = True) -> None: + async def set_position( + self, + pos1: int, + pos2: int = 0x8000, + pos3: int = 0x8000, + tilt: int = 0x8000, + velocity: int = 0x0, + disconnect: bool = True, + ) -> None: """Set position of device.""" - LOGGER.debug("%s setting position to %i", self.name, value) + LOGGER.debug("%s setting position to %i", self.name, pos1) await self._cmd( ( ShadeCmd.SET_POSITION, - bytes( - int.to_bytes(value * 100, 2, byteorder="little") - + bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0]) - ), + int.to_bytes(pos1 * 100, 2, byteorder="little") + + int.to_bytes(pos2 * 100, 2, byteorder="little") + + int.to_bytes(pos3 * 100, 2, byteorder="little") + + int.to_bytes(tilt * 100, 2, byteorder="little") + + int.to_bytes(velocity, 1), ), disconnect, ) From e00e04159a6689d7042fa8fe3d9e6f0cf3888dde Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:31:26 +0100 Subject: [PATCH 05/10] support setting tilt --- .../hunterdouglas_powerview_ble/api.py | 19 ++++--- .../hunterdouglas_powerview_ble/cover.py | 51 ++++++++++++++++++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index df2149c..b2daf23 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -71,6 +71,7 @@ class ShadeCmd(Enum): SET_POSITION = 0x01F7 STOP = 0xB8F7 ACTIVATE_SCENE = 0xBAF7 + IDENTIFY = 0x11F7 @dataclass @@ -154,11 +155,12 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No + bytes([self._seqcnt, len(cmd_run[1])]) + cmd_run[1] ) + LOGGER.debug("sending cmd: %s", tx_data.hex(" ")) if self._cipher is not None and self._is_encrypted: enc: AEADEncryptionContext = self._cipher.encryptor() tx_data = enc.update(tx_data) + enc.finalize() + LOGGER.debug(" encrypted: %s", tx_data.hex(" ")) self._data_event.clear() - LOGGER.debug("sending cmd: %s", tx_data) await self._client.write_gatt_char(UUID_TX, tx_data, False) self._seqcnt += 1 LOGGER.debug("waiting for response") @@ -180,8 +182,8 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: if len(data) != 9: LOGGER.debug("not a V2 record!") return [] - pos: int = int.from_bytes(data[3:5], byteorder="little") - pos2: int = (int(data[5]) << 4) + (int(data[4]) >> 4) + pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") + pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4) return [ (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), ("position2", pos2 >> 2), @@ -270,7 +272,7 @@ def _verify_response(self, din: bytearray, seq_nr: int, cmd: ShadeCmd) -> bool: LOGGER.error("Wrong response data length") return False if int(data[4] != 0): - LOGGER.error("Command %d returned error #%d", cmd.value, int(data[4])) + LOGGER.error("Command %X returned error #%d", cmd.value, int(data[4])) return False return True @@ -308,8 +310,13 @@ def _on_disconnect(self, client: BleakClient) -> None: LOGGER.debug("Disconnected from %s", client.address) def _notification_handler(self, _sender, data: bytearray) -> None: - LOGGER.debug("%s received BLE data: %s", self.name, data) + LOGGER.debug("%s received BLE data: %s", self.name, data.hex(" ")) self._data = data + if self._cipher is not None and self._is_encrypted: + dec: AEADDecryptionContext = self._cipher.decryptor() + self._data = bytearray(dec.update(data) + dec.finalize()) + LOGGER.debug("%s %s", "decoded data: ".rjust(19+len(self.name)), self._data.hex(" ")) + self._data_event.set() async def _connect(self) -> None: @@ -321,7 +328,7 @@ async def _connect(self) -> None: LOGGER.debug("%s already connected", self.name) return - start = time.time() + start: float = time.time() self._client = await establish_connection( BleakClient, self._ble_device, diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 7134592..d5d9327 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -8,7 +8,9 @@ ) from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -35,7 +37,7 @@ async def async_setup_entry( class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride] - """Representation of a powerview shade.""" + """Representation of a PowerView shade with Up/Down functionality only.""" _attr_has_entity_name = True _attr_device_class = CoverDeviceClass.SHADE @@ -170,3 +172,50 @@ async def async_stop_cover(self, **kwargs: Any) -> None: self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to stop cover '%s': %s", self.name, err) + + +class PowerViewCoverTilt(PowerViewCover): + """Representation of a PowerView shade with additional tilt functionality.""" + + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + # | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + # | CoverEntityFeature.OPEN_TILT + ) + + @property + def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] + """Return current tilt of cover. + + None is unknown + """ + pos: Final = self._coord.data.get(ATTR_CURRENT_TILT_POSITION) + return round(pos) if pos is not None else None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the tilt to a specific position.""" + + if isinstance(target_position := kwargs.get(ATTR_TILT_POSITION), int): + LOGGER.debug("set cover tilt to position %i", target_position) + if ( + self.current_cover_tilt_position == round(target_position) + or self.current_cover_position is None + ): + return + + try: + await self._coord.api.set_position( + self.current_cover_position, tilt=target_position + ) + self.async_write_ha_state() + except BleakError as err: + LOGGER.error( + "Failed to tilt cover '%s' to %f%%: %s", + self.name, + target_position, + err, + ) From d73bb1d1c4a758ff51a3692dadf60f34a502aa53 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Mon, 6 Jan 2025 19:46:52 +0100 Subject: [PATCH 06/10] completed tilt control --- .../hunterdouglas_powerview_ble/api.py | 28 +++++++++++++------ .../hunterdouglas_powerview_ble/cover.py | 16 +++++++++-- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index b2daf23..2826e20 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -51,6 +51,9 @@ 9: "Duette DuoLite, Top Down Bottom Up", 33: "Duette Architella, Top Down Bottom Up", 47: "Pleated, Top Down Bottom Up", + # top down, tilt anywhere + 51: "Venetian, Tilt Anywhere", + 62: "Venetian, Tilt Anywhere", } OPEN_POSITION: Final[int] = 100 @@ -203,21 +206,21 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: async def set_position( self, pos1: int, - pos2: int = 0x8000, - pos3: int = 0x8000, - tilt: int = 0x8000, + pos2: int | None = None, + pos3: int | None = None, + tilt: int | None = None, velocity: int = 0x0, disconnect: bool = True, ) -> None: """Set position of device.""" - LOGGER.debug("%s setting position to %i", self.name, pos1) + LOGGER.debug("%s setting position to %i, tilt %i", self.name, pos1, tilt) await self._cmd( ( ShadeCmd.SET_POSITION, - int.to_bytes(pos1 * 100, 2, byteorder="little") - + int.to_bytes(pos2 * 100, 2, byteorder="little") - + int.to_bytes(pos3 * 100, 2, byteorder="little") - + int.to_bytes(tilt * 100, 2, byteorder="little") + int.to_bytes(pos1, 2, byteorder="little") + + int.to_bytes(pos2 if pos2 is not None else 0x8000, 2, byteorder="little") + + int.to_bytes(pos3 if pos3 is not None else 0x8000, 2, byteorder="little") + + int.to_bytes(tilt if tilt is not None else 0x8000, 2, byteorder="little") + int.to_bytes(velocity, 1), ), disconnect, @@ -299,6 +302,9 @@ async def query_dev_info(self) -> dict[str, str]: .copy() .decode("UTF-8") ) + except BleakError as ex: + LOGGER.debug("%s: querying failed: %s", ex) + raise finally: await self.disconnect() LOGGER.debug("%s device data: %s", self.name, data) @@ -315,7 +321,11 @@ def _notification_handler(self, _sender, data: bytearray) -> None: if self._cipher is not None and self._is_encrypted: dec: AEADDecryptionContext = self._cipher.decryptor() self._data = bytearray(dec.update(data) + dec.finalize()) - LOGGER.debug("%s %s", "decoded data: ".rjust(19+len(self.name)), self._data.hex(" ")) + LOGGER.debug( + "%s %s", + "decoded data: ".rjust(19 + len(self.name)), + self._data.hex(" "), + ) self._data_event.set() diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index d5d9327..90be957 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -33,7 +33,11 @@ async def async_setup_entry( """Set up the demo cover platform.""" coordinator: PVCoordinator = config_entry.runtime_data - async_add_entities([PowerViewCover(coordinator)]) + async_add_entities( + [PowerViewCoverTilt(coordinator)] + if coordinator.dev_details.get("model") in ["51", "62"] + else [PowerViewCover(coordinator)] + ) class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride] @@ -53,8 +57,9 @@ def __init__( coordinator: PVCoordinator, ) -> None: """Initialize the shade.""" + LOGGER.debug("%s: init() PowerViewCover", coordinator.name) self._attr_name = CoverDeviceClass.SHADE - self._coord = coordinator + self._coord: PVCoordinator = coordinator self._attr_device_info = self._coord.device_info self._target_position: int | None = round( self._coord.data.get(ATTR_CURRENT_POSITION, OPEN_POSITION) @@ -187,6 +192,13 @@ class PowerViewCoverTilt(PowerViewCover): # | CoverEntityFeature.OPEN_TILT ) + def __init__( + self, + coordinator: PVCoordinator, + ) -> None: + LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name) + super().__init__(coordinator) + @property def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] """Return current tilt of cover. From 49bb1e79c97d90bc5b440bf54f77f00d010d374b Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:50:41 +0100 Subject: [PATCH 07/10] add tilt functions --- .../hunterdouglas_powerview_ble/api.py | 16 +++++++++---- .../hunterdouglas_powerview_ble/cover.py | 23 ++++++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 2826e20..7386113 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -218,9 +218,15 @@ async def set_position( ( ShadeCmd.SET_POSITION, int.to_bytes(pos1, 2, byteorder="little") - + int.to_bytes(pos2 if pos2 is not None else 0x8000, 2, byteorder="little") - + int.to_bytes(pos3 if pos3 is not None else 0x8000, 2, byteorder="little") - + int.to_bytes(tilt if tilt is not None else 0x8000, 2, byteorder="little") + + int.to_bytes( + pos2 if pos2 is not None else 0x8000, 2, byteorder="little" + ) + + int.to_bytes( + pos3 if pos3 is not None else 0x8000, 2, byteorder="little" + ) + + int.to_bytes( + tilt if tilt is not None else 0x8000, 2, byteorder="little" + ) + int.to_bytes(velocity, 1), ), disconnect, @@ -303,7 +309,7 @@ async def query_dev_info(self) -> dict[str, str]: .decode("UTF-8") ) except BleakError as ex: - LOGGER.debug("%s: querying failed: %s", ex) + LOGGER.debug("%s: querying failed: %s", self.name, ex) raise finally: await self.disconnect() @@ -346,7 +352,7 @@ async def _connect(self) -> None: disconnected_callback=self._on_disconnect, services=[ UUID_COV_SERVICE, - # self.UUID_DEV_SERVICE, + UUID_DEV_SERVICE, # self.UUID_BAT_SERVICE, ], ) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 90be957..00c99e9 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -185,11 +185,12 @@ class PowerViewCoverTilt(PowerViewCover): _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION | CoverEntityFeature.STOP - # | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION - # | CoverEntityFeature.OPEN_TILT ) def __init__( @@ -231,3 +232,19 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: target_position, err, ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.async_stop_cover(kwargs=kwargs) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + LOGGER.debug("open cover tilt") + _kwargs = {**kwargs, ATTR_TILT_POSITION: OPEN_POSITION} + await self.async_set_cover_tilt_position(**_kwargs) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + LOGGER.debug("close cover tilt") + _kwargs = {**kwargs, ATTR_TILT_POSITION: CLOSED_POSITION} + await self.async_set_cover_tilt_position(**_kwargs) From 2fb15f1d1349e7394fd1569f98616c3f55b15778 Mon Sep 17 00:00:00 2001 From: Peter LoVerso Date: Thu, 10 Jul 2025 10:17:16 -0700 Subject: [PATCH 08/10] Add changes from vjenne --- .../hunterdouglas_powerview_ble/api.py | 19 ++++++++++++------- .../hunterdouglas_powerview_ble/cover.py | 12 ++++++++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 7386113..7c77fc3 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -185,18 +185,23 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: if len(data) != 9: LOGGER.debug("not a V2 record!") return [] - pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") + # Get the last 4 bits of data[4] and shift them 6 places to the left + last_4_bits = (data[4] & 0b1111) << 6 + # Get the first 6 bits of data[3] + first_6_bits = (data[3] >> 2) & 0b111111 + # Combine them into a single integer + pos = last_4_bits | first_6_bits pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4) return [ - (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), + (ATTR_CURRENT_POSITION, (pos / 10)), ("position2", pos2 >> 2), ("position3", int(data[6])), - (ATTR_CURRENT_TILT_POSITION, int(data[7])), + (ATTR_CURRENT_TILT_POSITION, ((pos2 >> 2)/10)), ("home_id", int.from_bytes(data[0:2], byteorder="little")), ("type_id", int.from_bytes(data[2:3])), - ("is_opening", bool(pos & 0x3 == 0x2)), - ("is_closing", bool(pos & 0x3 == 0x1)), - ("battery_charging", bool(pos & 0x3 == 0x3)), # observed + ("is_opening", bool(data[3] & 0x3 == 0x2)), + ("is_closing", bool(data[3] & 0x3 == 0x1)), + ("battery_charging", bool(data[3] & 0x3 == 0x3)), # observed ("battery_level", POWER_LEVELS[(data[8] >> 6)]), # cannot hit 4 ("resetMode", bool(data[8] & 0x1)), ("resetClock", bool(data[8] & 0x2)), @@ -217,7 +222,7 @@ async def set_position( await self._cmd( ( ShadeCmd.SET_POSITION, - int.to_bytes(pos1, 2, byteorder="little") + int.to_bytes(pos1*100, 2, byteorder="little") + int.to_bytes( pos2 if pos2 is not None else 0x8000, 2, byteorder="little" ) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 00c99e9..9a60be5 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -35,7 +35,7 @@ async def async_setup_entry( coordinator: PVCoordinator = config_entry.runtime_data async_add_entities( [PowerViewCoverTilt(coordinator)] - if coordinator.dev_details.get("model") in ["51", "62"] + if coordinator.dev_details.get("model") in ["8", "51", "62"] else [PowerViewCover(coordinator)] ) @@ -129,7 +129,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: return self._target_position = round(target_position) try: - await self._coord.api.set_position(round(target_position)) + await self._coord.api.set_position(round(target_position), tilt = self.current_cover_tilt_position ) self.async_write_ha_state() except BleakError as err: LOGGER.error( @@ -200,6 +200,14 @@ def __init__( LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name) super().__init__(coordinator) + @property + def current_cover_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] + """Return current position of cover. + None is unknown, 0 is closed, 100 is fully open. + """ + pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) + return round(100-pos) if pos is not None else None + @property def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] """Return current tilt of cover. From fd18bf52ea51b9da9e597e5bcc4c2fc2fc3eee32 Mon Sep 17 00:00:00 2001 From: Peter LoVerso Date: Thu, 10 Jul 2025 10:17:51 -0700 Subject: [PATCH 09/10] Update so type 8 blind lower beam works --- custom_components/hunterdouglas_powerview_ble/api.py | 2 +- custom_components/hunterdouglas_powerview_ble/cover.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 7c77fc3..bd9289c 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -224,7 +224,7 @@ async def set_position( ShadeCmd.SET_POSITION, int.to_bytes(pos1*100, 2, byteorder="little") + int.to_bytes( - pos2 if pos2 is not None else 0x8000, 2, byteorder="little" + pos2 if pos2*100 is not None else 0x8000, 2, byteorder="little" ) + int.to_bytes( pos3 if pos3 is not None else 0x8000, 2, byteorder="little" diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 9a60be5..06d08aa 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -230,7 +230,7 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: try: await self._coord.api.set_position( - self.current_cover_position, tilt=target_position + self.current_cover_position, pos2=target_position ) self.async_write_ha_state() except BleakError as err: From 6621697db01fcfa86e89790e4fc8248179c4d471 Mon Sep 17 00:00:00 2001 From: biotinker Date: Thu, 10 Jul 2025 16:39:45 -0700 Subject: [PATCH 10/10] final update --- custom_components/hunterdouglas_powerview_ble/api.py | 12 +++++++++--- .../hunterdouglas_powerview_ble/cover.py | 12 ++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index bd9289c..e47ce48 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -182,6 +182,7 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No @staticmethod def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: """Decode manufacturer data from BLE advertisement V2.""" + if len(data) != 9: LOGGER.debug("not a V2 record!") return [] @@ -191,12 +192,15 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: first_6_bits = (data[3] >> 2) & 0b111111 # Combine them into a single integer pos = last_4_bits | first_6_bits + + #LOGGER.debug(f"(bin: {data[3]:08b} - {data[4]:08b} - {data[5]:08b})") + pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4) return [ (ATTR_CURRENT_POSITION, (pos / 10)), ("position2", pos2 >> 2), ("position3", int(data[6])), - (ATTR_CURRENT_TILT_POSITION, ((pos2 >> 2)/10)), + (ATTR_CURRENT_TILT_POSITION, ((pos2 >> 2)/10)), # int(data[7])), ("home_id", int.from_bytes(data[0:2], byteorder="little")), ("type_id", int.from_bytes(data[2:3])), ("is_opening", bool(data[3] & 0x3 == 0x2)), @@ -218,13 +222,14 @@ async def set_position( disconnect: bool = True, ) -> None: """Set position of device.""" - LOGGER.debug("%s setting position to %i, tilt %i", self.name, pos1, tilt) + + LOGGER.warn("%s setting position to %i, tilt %i", self.name, pos1, tilt) await self._cmd( ( ShadeCmd.SET_POSITION, int.to_bytes(pos1*100, 2, byteorder="little") + int.to_bytes( - pos2 if pos2*100 is not None else 0x8000, 2, byteorder="little" + pos2*100 if pos2 is not None else 0x8000, 2, byteorder="little" ) + int.to_bytes( pos3 if pos3 is not None else 0x8000, 2, byteorder="little" @@ -237,6 +242,7 @@ async def set_position( disconnect, ) + async def open(self) -> None: """Fully open cover.""" LOGGER.debug("%s open", self.name) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 06d08aa..7069c14 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -119,7 +119,7 @@ def current_cover_position(self) -> int | None: # type: ignore[reportIncompatib return round(pos) if pos is not None else None async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" + target_position: Final = kwargs.get(ATTR_POSITION) if target_position is not None: LOGGER.debug("set cover to position %f", target_position) @@ -127,7 +127,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: self.is_closing or self.is_opening ): return - self._target_position = round(target_position) + self._target_position = round(target_position) try: await self._coord.api.set_position(round(target_position), tilt = self.current_cover_tilt_position ) self.async_write_ha_state() @@ -200,14 +200,6 @@ def __init__( LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name) super().__init__(coordinator) - @property - def current_cover_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] - """Return current position of cover. - None is unknown, 0 is closed, 100 is fully open. - """ - pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) - return round(100-pos) if pos is not None else None - @property def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] """Return current tilt of cover.