Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
37a4907
Add scripts/get_home_key.py to fetch home keys from the HD cloud acco…
Frans-Willem May 24, 2026
614e032
Fix device merge, data wipe, staleness; add gateway-driven home key e…
Cloudore May 25, 2026
a01d95c
cover: stop falling back to _target_position in is_opening/is_closing
Cloudore May 25, 2026
6a20683
Expose the "service required" status bit as a diagnostic binary sensor
Cloudore May 25, 2026
5b1e9ca
api: correct service_required bit semantics
Cloudore May 25, 2026
d9a2d4d
api/cover: send SET_POSITION twice when the motor is in low-power state
Cloudore May 25, 2026
85b7151
rename service_required → low_power_mode
Cloudore May 25, 2026
a3e97b4
coordinator: derive cover direction from position delta, not advert bits
Cloudore May 25, 2026
8f20dcd
cover: kill ghost state transitions
Cloudore May 25, 2026
81c2af8
cover: write moving state before BLE await to keep HomeKit TargetPosi…
Cloudore May 25, 2026
21a2746
cover: pin direction during command + skip coordinator updates while …
Cloudore May 25, 2026
8cd9a17
cover: pin direction synchronously from homekit_state_change event
Cloudore May 25, 2026
fb32bdd
cover: separate "can command" from "data fresh"
Cloudore May 25, 2026
03952bc
cover: hold opening/closing state until motor settles, not just deadzone
Cloudore May 25, 2026
9e3cead
cover: _has_settled must require a position change AFTER the command
Cloudore May 25, 2026
ecba38c
cover: filter spurious position jumps > 50% in a single advert
Cloudore May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ Installation can be done using [HACS](https://hacs.xyz/) by [adding a custom rep
1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Hunter Douglas PowerView (BLE)"

## Set the Encryption Key
Currently, there are three methods to obtain the key:
Currently, there are four methods to obtain the key:

1. Via adopting a BLE shade: There is a [shade emulator](/emu/PV_BLE_cover) that works with Arduino IDE and an ESP32 device (≥ 2MiB flash, ≥ 128KiB required), e.g. [Adafruit QT Py ESP32-S3](https://www.adafruit.com/product/5426). Install and connect via serial port, then go to the PowerView app and add the shade `myPVcover` to your home. You will see a log message `set shade key: \xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx` . Copy this key. You can delete the shade from the app when done.
2. Extracting from gateway: This [script](scripts/extract_gateway3_homekey.py) is able to extract the key from a working PowerView gateway.
3. Grabbing from the app: Checkout this [post in the Home Assistant community forum](https://community.home-assistant.io/t/hunter-douglas-powerview-gen-3-integration/424836/228).
3. From the Hunter Douglas cloud account: This [script](scripts/get_home_key.py) signs in to your PowerView account and reads the home key from the same Firestore document the Android app uses. Works for any home on the account, no gateway or BLE access required. Run with `python3 scripts/get_home_key.py -e you@example.com`.
4. Grabbing from the app's local database: Checkout this [post in the Home Assistant community forum](https://community.home-assistant.io/t/hunter-douglas-powerview-gen-3-integration/424836/228).

Finally, you need to manually copy the key to [`const.py`](https://github.com/patman15/hdpv_ble/blob/main/custom_components/hunterdouglas_powerview_ble/const.py).

Expand Down
16 changes: 13 additions & 3 deletions custom_components/hunterdouglas_powerview_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady

from .const import LOGGER
from .const import HOME_KEY as FALLBACK_HOME_KEY, LOGGER
from .coordinator import PVCoordinator
from .key_store import async_get_home_key

PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Expand All @@ -26,8 +27,16 @@
type ConfigEntryType = ConfigEntry[PVCoordinator]


async def _resolve_home_key(hass: HomeAssistant) -> bytes:
"""Return the persisted home key, or const.HOME_KEY fallback."""
stored = await async_get_home_key(hass)
if stored is not None:
return stored
return FALLBACK_HOME_KEY


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool:
"""Set up BT Battery Management System from a config entry."""
"""Set up a single shade config entry."""
LOGGER.debug("Setup of %s", repr(entry))

if entry.unique_id is None:
Expand All @@ -42,7 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool
f"Could not find PowerView device ({entry.unique_id}) via Bluetooth"
)

coordinator = PVCoordinator(hass, ble_device, entry.data.copy())
home_key = await _resolve_home_key(hass)
coordinator = PVCoordinator(hass, ble_device, entry.data.copy(), home_key=home_key)
try:
await coordinator.query_dev_info()
except BleakError as err:
Expand Down
134 changes: 94 additions & 40 deletions custom_components/hunterdouglas_powerview_ble/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,40 +146,65 @@ def is_connected(self) -> bool:

# general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len
async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> None:
"""Send one BLE command, draining any commands queued while we send.

If another _cmd call lands while we hold the connection (typical for
HomeKit users tapping close while a shade is still opening), it
updates self._cmd_next and returns. We then keep the same BLE
session open and immediately run that newer command too, so user
intent is honoured without waiting for the in-flight BLE write
to complete or a subsequent press to re-fire.
"""
self._cmd_next = cmd
if self._cmd_lock.locked():
LOGGER.debug("%s: device busy, queuing %s command", self.name, cmd[0])
LOGGER.debug("%s: device busy, queueing %s", self.name, cmd[0])
return

async with self._cmd_lock:
try:
await self._connect()
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]
)
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()
await self._client.write_gatt_char(UUID_TX, tx_data, False)
self._seqcnt += 1
LOGGER.debug("waiting for response")
try:
await asyncio.wait_for(self._wait_event(), timeout=TIMEOUT)
self._verify_response(self._data, self._seqcnt - 1, cmd_run[0])
except TimeoutError as ex:
raise TimeoutError("Device did not send confirmation.") from ex
finally:
if disconnect:
await self._client.disconnect() # device disconnects itself
while True:
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]
)
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()
await self._client.write_gatt_char(UUID_TX, tx_data, False)
self._seqcnt += 1
LOGGER.debug("waiting for response")
try:
await asyncio.wait_for(self._wait_event(), timeout=TIMEOUT)
self._verify_response(self._data, self._seqcnt - 1, cmd_run[0])
except TimeoutError as ex:
# Don't keep retrying queued commands after a timeout
# — surface the error to the caller.
raise TimeoutError(
"Device did not send confirmation."
) from ex
# If nothing new was queued during this send, we're done.
if self._cmd_next is cmd_run:
break
LOGGER.debug(
"%s: draining queued %s on the same connection",
self.name,
self._cmd_next[0],
)
except Exception as ex:
LOGGER.error("Error: %s - %s", type(ex).__name__, ex)
raise
finally:
if disconnect:
try:
await self._client.disconnect() # device disconnects itself
except BleakError:
pass

@staticmethod
def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
Expand All @@ -202,6 +227,19 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
("battery_level", POWER_LEVELS[(data[8] >> 6)]), # cannot hit 4
("resetMode", bool(data[8] & 0x1)),
("resetClock", bool(data[8] & 0x2)),
# Bit 0x04 observed correlated with shades that ACK BLE
# commands with error_code=0 but refuse to physically move.
# Empirically:
# * set on idle / parked shades
# * transiently clears for ~10s while the motor is
# actively tracking position (e.g. during a remote-
# triggered move)
# * snaps back on as soon as the motor settles
# Best read as a "motor is in low-power / sleep" flag. When
# set, SET_POSITION frames are ACK'd with error_code=0 but
# the motor ignores them — the integration sends a wake-up
# frame first (see api.set_position wake_first kwarg).
("low_power_mode", bool(data[8] & 0x4)),
]

# position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity
Expand All @@ -213,43 +251,59 @@ async def set_position(
tilt: int = 0x8000,
velocity: int = 0x0,
disconnect: bool = True,
wake_first: bool = False,
) -> None:
"""Set position of device."""
"""Set position of device.

wake_first: send the SET_POSITION frame twice with a 0.5s gap. Shades
in low-power state (low_power_mode bit set in their advert) ACK
the first frame with error_code=0 but ignore it physically; the
second frame, sent while the motor is briefly awake, actually moves
the shade. Pass `wake_first=True` whenever the cover entity has
observed `low_power_mode=on` for this shade.
"""
LOGGER.debug(
"%s setting position to %i/%i/%i, tilt %i, velocity %s",
"%s setting position to %i/%i/%i, tilt %i, velocity %s%s",
self.name,
pos1,
pos2,
pos3,
tilt,
velocity,
" (wake_first)" if wake_first else "",
)
await self._cmd(
(
ShadeCmd.SET_POSITION,
int.to_bytes(pos1*100, 2, byteorder="little")
+ int.to_bytes(pos2, 2, byteorder="little")
+ int.to_bytes(pos3, 2, byteorder="little")
+ int.to_bytes(tilt, 2, byteorder="little")
+ int.to_bytes(velocity, 1),
),
disconnect,
payload = (
ShadeCmd.SET_POSITION,
int.to_bytes(pos1 * 100, 2, byteorder="little")
+ int.to_bytes(pos2, 2, byteorder="little")
+ int.to_bytes(pos3, 2, byteorder="little")
+ int.to_bytes(tilt, 2, byteorder="little")
+ int.to_bytes(velocity, 1),
)
if wake_first:
# First send wakes the motor but is ignored. Keep the connection
# open so the second send hits the same BLE session.
try:
await self._cmd(payload, disconnect=False)
except (BleakError, TimeoutError) as ex:
LOGGER.debug("%s wake-up send failed (ignored): %s", self.name, ex)
await asyncio.sleep(0.5)
await self._cmd(payload, disconnect)

async def open(self) -> None:
async def open(self, wake_first: bool = False) -> None:
"""Fully open cover."""
LOGGER.debug("%s open", self.name)
await self.set_position(OPEN_POSITION, disconnect=False)
await self.set_position(OPEN_POSITION, disconnect=False, wake_first=wake_first)

async def stop(self) -> None:
"""Stop device movement."""
LOGGER.debug("%s stop", self.name)
await self._cmd((ShadeCmd.STOP, b""))

async def close(self) -> None:
async def close(self, wake_first: bool = False) -> None:
"""Fully close cover."""
LOGGER.debug("%s close", self.name)
await self.set_position(CLOSED_POSITION, disconnect=False)
await self.set_position(CLOSED_POSITION, disconnect=False, wake_first=wake_first)

# uint8_t scene#, uint8_t unknown
# open: scene 2
Expand Down
18 changes: 16 additions & 2 deletions custom_components/hunterdouglas_powerview_ble/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)
from homeassistant.const import ATTR_BATTERY_CHARGING
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback
Expand All @@ -22,7 +22,16 @@
key=ATTR_BATTERY_CHARGING,
translation_key=ATTR_BATTERY_CHARGING,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
)
),
BinarySensorEntityDescription(
# Reflects bit 0x04 of advert byte 8 — set when the motor is in
# low-power / sleep state. The integration auto-wakes via the
# wake_first path in api.set_position, so this is purely
# diagnostic (no device_class=problem).
key="low_power_mode",
translation_key="low_power_mode",
entity_category=EntityCategory.DIAGNOSTIC,
),
]


Expand Down Expand Up @@ -56,6 +65,11 @@ def __init__(
self.entity_description = descr
super().__init__(coord)

@property
def available(self) -> bool:
"""Gate availability on freshness of decoded V2 advert."""
return super().available and self.coordinator.data_available

@property
def is_on(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
"""Handle updated data from the coordinator."""
Expand Down
Loading