diff --git a/README.md b/README.md index 6d827cd..90e21b1 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index dd594f2..3af436b 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -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, @@ -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: @@ -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: diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5a6cc97..3d3ea04 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -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]]: @@ -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 @@ -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 diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index dda9127..b2d06dd 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -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 @@ -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, + ), ] @@ -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.""" diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 7a38b35..38e36ec 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -1,4 +1,19 @@ -"""Config flow for BLE Battery Management System integration.""" +"""Config flow for Hunter Douglas PowerView (BLE). + +Two flavours of flow: + +1. **Shade pairing** — Bluetooth discovery or manual pick. Creates a config + entry per shade. (Existing behaviour.) + +2. **Home-key extraction** — a one-shot helper. The user picks a PowerView + gateway (via mDNS auto-discovery, or by typing its address). The flow + talks to that gateway over HTTP, pulls the AES home key, persists it in + `.storage/hunterdouglas_powerview_ble`, and then aborts without creating + any entry. The gateway itself is never represented in HA — it's only + needed during this single key-extraction call, and after that all shade + traffic goes over BLE proxies. HACS updates can't wipe the key because + `.storage/` lives outside the integration source tree. +""" from dataclasses import dataclass from typing import Any @@ -10,6 +25,7 @@ BluetoothServiceInfoBleak, async_discovered_service_info, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ADDRESS from homeassistant.helpers.selector import ( @@ -19,37 +35,143 @@ ) from .api import UUID_COV_SERVICE as UUID -from .const import DOMAIN, LOGGER, MFCT_ID +from .const import CONF_HOST, DOMAIN, LOGGER, MFCT_ID +from .gateway import GatewayError, extract_home_key, probe_gateway +from .key_store import HomeKeyStore, async_get_home_key class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for BT Battery Management System.""" + """Handle config flow for shades and the home-key extractor.""" VERSION = 1 MINOR_VERSION = 0 @dataclass class DiscoveredDevice: - """A discovered bluetooth device.""" + """A discovered Bluetooth device.""" name: str discovery_info: BluetoothServiceInfoBleak def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_device: ConfigFlow.DiscoveredDevice | None = None self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} + self._gateway_host: str | None = None + + # ---------- entrypoint when user clicks "Add Integration" ---------- + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Top-level menu: extract a key from a gateway, or pair a shade.""" + return self.async_show_menu( + step_id="user", + menu_options=["extract_key", "shade"], + ) + + # ---------- Home-key extraction (one-shot helper, no entry created) ---------- + + async def async_step_extract_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask the user for a gateway address, extract the key, persist, abort.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + try: + info = await probe_gateway(host) + key_hex = await extract_home_key(info["host"]) + except GatewayError as ex: + LOGGER.warning("Home-key extraction failed: %s", ex) + errors["base"] = "cannot_connect" + else: + await HomeKeyStore(self.hass).async_save(bytes.fromhex(key_hex)) + # Reload every shade entry so coordinators pick up the new key. + for entry in self.hass.config_entries.async_entries(DOMAIN): + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + return self.async_abort(reason="home_key_saved") + + default_host = self._gateway_host or "http://powerview-g3.local" + return self.async_show_form( + step_id="extract_key", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), + errors=errors, + description_placeholders={"default_host": default_host}, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """A PowerView gateway announced itself via mDNS — offer to extract. + + Skipped when we already have a stored key, to avoid spamming the + Discovered tile after first-time setup. Users can re-extract any + time from the integration menu if the key rotates. + """ + if await async_get_home_key(self.hass) is not None: + return self.async_abort(reason="home_key_already_saved") + host_str = ( + discovery_info.hostname.rstrip(".") + if discovery_info.hostname + else str(discovery_info.ip_address) + ) + url = f"http://{host_str}" + LOGGER.debug("Zeroconf-discovered PowerView gateway: %s", url) + # Stable unique id keyed on the host so multiple rediscoveries collapse. + await self.async_set_unique_id(f"gateway:{url}") + self._abort_if_unique_id_configured() + self._gateway_host = url + self.context["title_placeholders"] = {"name": host_str} + return await self.async_step_extract_key_confirm() + + async def async_step_extract_key_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm extraction from a zeroconf-discovered gateway.""" + assert self._gateway_host is not None + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await probe_gateway(self._gateway_host) + key_hex = await extract_home_key(info["host"]) + except GatewayError as ex: + LOGGER.warning("Home-key extraction failed: %s", ex) + errors["base"] = "cannot_connect" + else: + await HomeKeyStore(self.hass).async_save(bytes.fromhex(key_hex)) + for entry in self.hass.config_entries.async_entries(DOMAIN): + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + return self.async_abort(reason="home_key_saved") + + self._set_confirm_only() + return self.async_show_form( + step_id="extract_key_confirm", + description_placeholders={"host": self._gateway_host}, + errors=errors, + ) + + # ---------- Shade pairing (Bluetooth) ---------- + + async def async_step_shade( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User picked 'pair a shade' from the menu.""" + return await self.async_step_pick_shade(user_input) async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: - """Handle a flow initialized by Bluetooth discovery.""" + """Bluetooth scanner found a shade.""" LOGGER.debug("Bluetooth device detected: %s", discovery_info) - await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() - self._discovered_device = ConfigFlow.DiscoveredDevice( discovery_info.name, discovery_info ) @@ -59,10 +181,8 @@ async def async_step_bluetooth( async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm bluetooth device discovery.""" + """Confirm adding a BLE-discovered shade.""" assert self._discovered_device is not None - LOGGER.debug("confirm step for %s", self._discovered_device.name) - if user_input is not None: return self.async_create_entry( title=self._discovered_device.name, @@ -72,28 +192,22 @@ async def async_step_bluetooth_confirm( ].hex() }, ) - self._set_confirm_only() - return self.async_show_form( step_id="bluetooth_confirm", description_placeholders={"name": self._discovered_device.name}, ) - async def async_step_user( + async def async_step_pick_shade( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" - LOGGER.debug("user step") - + """List currently-advertising shades for the user to pick.""" if user_input is not None: address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() self._discovered_device = self._discovered_devices[address] - self.context["title_placeholders"] = {"name": self._discovered_device.name} - return self.async_create_entry( title=self._discovered_device.name, data={ @@ -108,13 +222,10 @@ async def async_step_user( address = discovery_info.address if address in current_addresses or address in self._discovered_devices: continue - if MFCT_ID not in discovery_info.manufacturer_data: continue - if UUID not in discovery_info.service_uuids: continue - self._discovered_devices[address] = ConfigFlow.DiscoveredDevice( discovery_info.name, discovery_info ) @@ -127,7 +238,7 @@ async def async_step_user( titles.append({"value": address, "label": discovery.name}) return self.async_show_form( - step_id="user", + step_id="pick_shade", data_schema=vol.Schema( { vol.Required(CONF_ADDRESS): SelectSelector( diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index d723594..242e725 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -7,9 +7,16 @@ LOGGER: Final = logging.getLogger(__package__) MFCT_ID: Final[int] = 2073 TIMEOUT: Final[int] = 5 +STALE_AFTER: Final[float] = 1800.0 # seconds without a V2 advert before entity is unavailable (idle shades back off BLE cadence; 30 min covers typical slow-down) -# put the key here, needs to be 16 bytes long, e.g. -# HOME_KEY: Final[bytes] = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" +# Config flow form field +CONF_HOST: Final[str] = "host" + +# Fallback HOME_KEY for users who haven't run the gateway extractor yet. +# Leaving this populated is harmless (the value persisted via the extractor +# in `.storage/hunterdouglas_powerview_ble` takes precedence), but it gets +# overwritten on every HACS update — so the persistent key store is the +# durable place to put your real key. HOME_KEY: Final[bytes] = b"" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 68883cc..37130b9 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -1,34 +1,56 @@ """Home Assistant coordinator for Hunter Douglas PowerView (BLE) integration.""" +import time from typing import Any from bleak.backends.device import BLEDevice +from homeassistant.components.cover import ATTR_CURRENT_POSITION from homeassistant.components import bluetooth from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothDataUpdateCoordinator, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) from .api import SHADE_TYPE, PowerViewBLE -from .const import ATTR_RSSI, DOMAIN, HOME_KEY, LOGGER +from .const import ATTR_RSSI, DOMAIN, HOME_KEY as _DEFAULT_HOME_KEY, LOGGER, STALE_AFTER class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): """Update coordinator for a battery management system.""" def __init__( - self, hass: HomeAssistant, ble_device: BLEDevice, data: dict[str, Any] + self, + hass: HomeAssistant, + ble_device: BLEDevice, + data: dict[str, Any], + *, + home_key: bytes | None = None, ) -> None: - """Initialize BMS data coordinator.""" + """Initialize BMS data coordinator. + + home_key: 16-byte AES key shared by all shades on this PowerView + home. Pass the value resolved from a gateway config entry. Falls + back to const.HOME_KEY when None, which is empty by default — so + commands go out plaintext (works for ACR rollers, but KDT motors + will silently reject them). + """ assert ble_device.name is not None self._mac = ble_device.address - self.api = PowerViewBLE(ble_device, HOME_KEY) + self.api = PowerViewBLE( + ble_device, home_key if home_key is not None else _DEFAULT_HOME_KEY + ) self.data: dict[str, int | float | bool] = {} self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} + self._last_v2_ts: float | None = None + self._last_position: float | None = None LOGGER.debug( "Initializing coordinator for %s (%s)", @@ -53,7 +75,11 @@ def device_info(self) -> DeviceInfo: LOGGER.debug("%s: device_info, %s", self.name, self.dev_details) return DeviceInfo( identifiers={ - (DOMAIN, self.name), + # Use the immutable MAC, not the BLE-advertised local name. + # local_name can transiently match across devices (e.g. a generic + # firmware fallback like "AWEI T13 Pro"), which causes HA's + # device registry to merge unrelated shades into one record. + (DOMAIN, format_mac(self.address)), (BLUETOOTH_DOMAIN, self.address), }, connections={(CONNECTION_BLUETOOTH, self.address)}, @@ -79,6 +105,14 @@ def device_present(self) -> bool: """Check if a device is present.""" return bluetooth.async_address_present(self.hass, self._mac, connectable=True) + @property + def data_available(self) -> bool: + """Return True iff a V2 advertisement was decoded within STALE_AFTER seconds.""" + return ( + self._last_v2_ts is not None + and (time.time() - self._last_v2_ts) < STALE_AFTER + ) + def _async_stop(self) -> None: """Shutdown coordinator and any connection.""" LOGGER.debug("%s: shutting down BMS device", self.name) @@ -97,14 +131,52 @@ def _async_handle_bluetooth_event( # self.hass.async_create_task(self._get_device_info()) LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data) - self.data = {ATTR_RSSI: service_info.rssi} + # Always refresh RSSI. Preserve last-known positional fields across + # events so a shade doesn't briefly report "no position" — which the + # cover entity surfaces as state="open" with current_position=None, + # the opposite of the truth on a closed shade. + # + # But clear state-of-the-moment flags so they only reflect data from + # the current V2 frame — otherwise stale movement bits would freeze + # the entity in "opening"/"closing" after the shade has actually + # stopped moving. + self.data[ATTR_RSSI] = service_info.rssi + for _k in ( + "type_id", + "is_opening", + "is_closing", + "battery_charging", + "resetMode", + "resetClock", + "low_power_mode", + ): + self.data.pop(_k, None) if change == bluetooth.BluetoothChange.ADVERTISEMENT: - self.data.update( - self.api.dec_manufacturer_data( - bytearray(service_info.manufacturer_data.get(2073, b"")) - ) + mfg = self.api.dec_manufacturer_data( + bytearray(service_info.manufacturer_data.get(2073, b"")) ) - self.api.encrypted = bool(self.data.get("home_id")) + if mfg: + self.data.update(mfg) + # Consume home_id here (drives api.encrypted) rather than + # leaving it sticky in self.data, because cover.supported_features + # treats any non-zero home_id as "encryption required" and + # disables UI controls. In practice many paired roller shades + # accept plaintext commands even with home_id != 0 and no + # HOME_KEY, so over-disabling the UI hurts more than it helps. + # Encryption is still applied when both home_id and HOME_KEY + # are present (Cipher is built only on a 16-byte HOME_KEY). + self.api.encrypted = bool(self.data.pop("home_id", 0)) + self._last_v2_ts = time.time() + # Movement direction is derived in the cover entity from + # _target_position vs current_position, not from these + # advert bits. Bits are unreliable: KDT curtain firmwares + # invert them vs ACR rollers, and with multiple BLE + # proxies the same shade's adverts arrive out of order + # with ±1-3% position bounce that produces "closing" + # flickers during smooth opens. Clear the bits so the + # cover entity falls through to target-based inference. + self.data["is_opening"] = False + self.data["is_closing"] = False LOGGER.debug("data sample %s", self.data) super()._async_handle_bluetooth_event(service_info, change) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 4c25bf5..65ff68e 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -1,5 +1,6 @@ """Hunter Douglas Powerview cover.""" +import time from typing import Any, Final from bleak.exc import BleakError @@ -17,7 +18,8 @@ CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_CLOSING, STATE_OPENING +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -65,43 +67,246 @@ def __init__( self._attr_name = CoverDeviceClass.SHADE 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) - ) + self._target_position: int | None = None + # Wall-clock timestamp of the last command that set _target_position. + # Used by is_opening / is_closing to expire stale targets after + # TARGET_TTL seconds (see those properties for the rationale). + self._target_set_at: float | None = None + # Optional 'opening' / 'closing' override that pins the direction + # while a command is in flight. Necessary because the entity can + # otherwise pass through a transient 'open' or 'closed' state + # within the same event-loop tick as the command (e.g. if a BLE + # advert arrives just before async_set_cover_position runs), and + # HA's HomeKit Bridge clobbers HK's TargetPosition any time the + # entity is not in a moving state — breaking the iOS direction + # label for the rest of the move. See _pin_direction(). + self._pinned_direction: str | None = None + # Last advertised position + when it last changed. Used by + # `_has_settled()` to decide whether the motor is still moving. + # Without this, is_opening/is_closing fall to False at the same + # advert where the shade enters DIRECTION_DEADZONE of its target, + # which leaves a non-moving "open"/"closed" frame *while the motor + # is still physically running*. HA's HomeKit Bridge clobbers HK's + # TargetPosition on any non-moving frame, so that single transient + # frame flips iOS's direction label for the rest of the move. + self._last_position: int | None = None + self._last_position_change_ts: float | None = None self._attr_unique_id = ( f"{DOMAIN}_{format_mac(self._coord.address)}_{CoverDeviceClass.SHADE}" ) super().__init__(coordinator) + async def async_added_to_hass(self) -> None: + """Subscribe to HomeKit Bridge's command event for pre-emptive pinning. + + HA's HomeKit Bridge fires `homekit_state_change` synchronously inside + the TargetPosition setter callback, before the service-call task is + queued. Pinning the direction in that synchronous frame beats any + racing BLE advert that would otherwise write a non-moving state and + trip the Bridge into resetting HK's TargetPosition (which flips the + iOS direction label for the whole move). + """ + await super().async_added_to_hass() + self.async_on_remove( + self.hass.bus.async_listen("homekit_state_change", self._on_homekit_event) + ) + + @callback + def _on_homekit_event(self, event: Event) -> None: + """Pin direction the moment iOS issues a position command.""" + if event.data.get(ATTR_ENTITY_ID) != self.entity_id: + return + if event.data.get("service") != "set_cover_position": + return + value = event.data.get("value") + if not isinstance(value, (int, float)): + return + current = self.current_cover_position + if current is None or int(value) == current: + return + direction = STATE_OPENING if int(value) > current else STATE_CLOSING + self._pin_direction(direction) + self._set_target(int(value)) + self.async_write_ha_state() + @property def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableOverride] """Return the device_info of the device.""" return self._coord.device_info + @property + def available(self) -> bool: + """Always controllable as long as the BLE address is reachable. + + Hunter Douglas shades — especially ACR rollers — back off their + V2 advert cadence after a few minutes of idle and may not transmit + again for hours. They still accept BLE writes the entire time and + wake on command. Gating `available` on advert freshness here would + make HA refuse to dispatch service calls (logs a "Referenced entity + ... not currently available" warning and the command is dropped), + which breaks every UI surface the user controls the shade from. + + Stale advert data is surfaced via `assumed_state` instead — the + UI shows the "assumed" indicator without blocking commands. + """ + return super().available + + @property + def assumed_state(self) -> bool: + """True when the cached position is older than STALE_AFTER seconds. + + Tells the UI not to fully trust `current_cover_position` — the + shade may have been moved by remote / wall switch since the last + advert we decoded. + """ + return not self._coord.data_available + + # Direction inference. The shade's advert movement bits are + # unreliable (KDT curtain firmwares invert them vs ACR rollers), so + # we derive direction from intent: `_target_position` (set by every + # HA / HomeKit command) compared against `current_position`. While + # the target is fresh AND the motor is still moving (see + # `_has_settled()`), we report "opening" / "closing"; once the motor + # has gone quiet we fall through to the open/closed steady state. + # + # The target auto-expires after TARGET_TTL seconds so a stale command + # can't make the entity report "opening" forever when the user has + # since moved the shade by remote. + + ENDPOINT_DEADZONE = 5 # is_closed deadzone, absorbs ±2-3% advert noise + # from a settled-closed shade so it doesn't flip "closed" → "open" + # → "closed" while sitting at the bottom. + TARGET_TTL = 60.0 # seconds since last command — pin/target window + SETTLED_AFTER = 1.5 # seconds without position change = motor stopped + # Maximum position change a single advert is allowed to report. Larger + # jumps are rejected as spurious (stale beacon from a flaky BLE proxy + # or an init/boot advert from the shade module after it wakes — both + # frequently report pos=0 mid-motion). Physical roller motion is + # ~7%/sec, so even a 5-second advert gap caps real change at ~35%. + MAX_POSITION_JUMP = 50 + + def _target_is_fresh(self) -> bool: + if self._target_position is None or self._target_set_at is None: + return False + return (time.time() - self._target_set_at) < self.TARGET_TTL + + def _pin_is_valid(self) -> bool: + """Pin is honored only while the matching target is fresh. + + Backstop for cases where a pin is set (by `_on_homekit_event` or + by a command method) but never released — e.g. the service call + failed to dispatch, or the BLE write raised an unhandled exception. + After TARGET_TTL the pin is treated as released and the entity + falls back to advert-derived state, so the user isn't stuck + looking at "Opening..." forever. + """ + return self._pinned_direction is not None and self._target_is_fresh() + + def _has_settled(self) -> bool: + """True iff the motor is no longer moving toward the current target. + + Position-stability proxy for "motor stopped". Two-stage: + + 1. If the most recent position change happened *before* the current + target was set, the motor hasn't reacted to the command yet — + treat as NOT settled. Without this, a shade that's been stable + for hours would be reported as settled the instant a fresh + command lands, and the next BLE advert (still at the pre-move + position) would write a non-moving "open"/"closed" frame *while + the motor is just about to start*. HA's HomeKit Bridge clobbers + HK's TargetPosition on any non-moving frame, flipping iOS's + direction label backwards for the rest of the move. + + 2. Once the position has changed at least once *after* the command, + settled is True only if the last change was more than + SETTLED_AFTER seconds ago. + """ + if self._last_position_change_ts is None: + # never seen a position update — leave is_opening/is_closing free + # to fall through to their own checks + return True + if ( + self._target_set_at is not None + and self._last_position_change_ts < self._target_set_at + ): + return False + return (time.time() - self._last_position_change_ts) > self.SETTLED_AFTER + @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is opening or not.""" - return bool(self._coord.data.get("is_opening")) or ( - isinstance(self._target_position, int) - and isinstance(self.current_cover_position, int) - and self._target_position > self.current_cover_position - and self._coord.api.is_connected - ) + """True while the shade is being driven upward.""" + if self._pin_is_valid(): + return self._pinned_direction == STATE_OPENING + if not self._target_is_fresh(): + return False + current = self.current_cover_position + if current is None: + return False + if self._target_position <= current: + return False + # Target is above us. Still "opening" until either the position + # matches the target exactly or the motor has gone quiet. + return not self._has_settled() @property def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is closing or not.""" - return bool(self._coord.data.get("is_closing")) or ( - isinstance(self._target_position, int) - and isinstance(self.current_cover_position, int) - and self._target_position < self.current_cover_position - and self._coord.api.is_connected - ) + """True while the shade is being driven downward.""" + if self._pin_is_valid(): + return self._pinned_direction == STATE_CLOSING + if not self._target_is_fresh(): + return False + current = self.current_cover_position + if current is None: + return False + if self._target_position >= current: + return False + return not self._has_settled() @property - def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] + def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Return if the cover is closed.""" - return self.current_cover_position == CLOSED_POSITION + pos = self.current_cover_position + if pos is None: + return None + return pos <= CLOSED_POSITION + self.ENDPOINT_DEADZONE + + @callback + def _handle_coordinator_update(self) -> None: + """Filter spurious position jumps, then track + propagate. + + Two responsibilities: + + 1. Reject single-advert position jumps larger than MAX_POSITION_JUMP. + These are physically impossible (a roller can't move 50%+ between + two adverts) and in practice come from stale BLE-proxy beacons or + the shade's init/boot advert (pos=0, resetClock=False) right when + the motor settles. Without the filter, one such frame mid-motion + is enough to write a moving state in the *wrong direction* — + e.g. on a 100→70 close, a stray pos=0 advert flips is_opening + to True (because target=70 > current=0) and stamps HK's + PositionState as INCREASING, leaving iOS stuck on "Opening...". + + 2. Track when the position last actually changed for `_has_settled()`. + + Skipped adverts don't update `_last_position` so the next good advert + is judged against the last *good* value. + """ + current = self.current_cover_position + if ( + current is not None + and self._last_position is not None + and abs(current - self._last_position) > self.MAX_POSITION_JUMP + ): + LOGGER.debug( + "%s: rejecting spurious position jump %s → %s", + self.entity_id, self._last_position, current, + ) + return + if current is not None and current != self._last_position: + self._last_position = current + self._last_position_change_ts = time.time() + super()._handle_coordinator_update() + @property def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] @@ -122,55 +327,95 @@ def current_cover_position(self) -> int | None: # type: ignore[reportIncompatib pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) return round(pos) if pos is not None else None + @property + def _needs_wake(self) -> bool: + """True when the shade is in low-power mode and needs a double-send. + + See api.set_position(wake_first=...) for the rationale. + """ + return bool(self._coord.data.get("low_power_mode")) + async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" + """Move the cover to a specific position. + + Pins the entity to STATE_OPENING / STATE_CLOSING for the duration + of the command so HA's HomeKit Bridge never sees a non-moving + frame mid-command — see _pin_direction() and is_opening for the + full rationale. + """ target_position: Final = kwargs.get(ATTR_POSITION) - if target_position is not None: - LOGGER.debug("set cover to position %f", target_position) - if self.current_cover_position == round(target_position) and not ( - self.is_closing or self.is_opening - ): - return - self._target_position = round(target_position) - try: - await self._coord.api.set_position(round(target_position)) - self.async_write_ha_state() - except BleakError as err: - LOGGER.error( - "Failed to move cover '%s' to %f%%: %s", - self.name, - target_position, - err, - ) + if target_position is None: + return + target = round(target_position) + LOGGER.debug("set cover to position %d", target) + if self.current_cover_position == target and not ( + self.is_closing or self.is_opening + ): + return + current = self.current_cover_position + if current is not None: + self._pin_direction(STATE_OPENING if target > current else STATE_CLOSING) + self._set_target(target) + self.async_write_ha_state() + try: + await self._coord.api.set_position(target, wake_first=self._needs_wake) + except BleakError as err: + LOGGER.error( + "Failed to move cover '%s' to %d%%: %s", self.name, target, err + ) + finally: + self._pin_direction(None) + + def _set_target(self, position: int) -> None: + """Record a new target position and stamp its freshness window.""" + self._target_position = position + self._target_set_at = time.time() + + def _pin_direction(self, direction: str | None) -> None: + """Pin is_opening / is_closing to a specific direction. + + Used inside command handlers to guarantee the entity enters + STATE_OPENING / STATE_CLOSING immediately and stays there until + we clear it, regardless of what a racing BLE advert would + otherwise compute from target-vs-current. Pass None to release. + """ + self._pinned_direction = direction def _reset_target_position(self) -> None: self._target_position = None + self._target_set_at = None async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" + """Open the cover. Pins direction so HK keeps TargetPosition.""" LOGGER.debug("open cover") if self.current_cover_position == OPEN_POSITION: return + self._pin_direction(STATE_OPENING) + self._set_target(OPEN_POSITION) + self.async_write_ha_state() try: - self._target_position = OPEN_POSITION - await self._coord.api.open() - self.async_write_ha_state() + await self._coord.api.open(wake_first=self._needs_wake) except BleakError as err: LOGGER.error("Failed to open cover '%s': %s", self.name, err) self._reset_target_position() + finally: + self._pin_direction(None) async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover tilt.""" + """Close the cover. Pins direction so HK keeps TargetPosition.""" LOGGER.debug("close cover") if self.current_cover_position == CLOSED_POSITION: return + self._pin_direction(STATE_CLOSING) + self._set_target(CLOSED_POSITION) + self.async_write_ha_state() try: - self._target_position = CLOSED_POSITION - await self._coord.api.close() - self.async_write_ha_state() + await self._coord.api.close(wake_first=self._needs_wake) except BleakError as err: LOGGER.error("Failed to close cover '%s': %s", self.name, err) self._reset_target_position() + finally: + self._pin_direction(None) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" diff --git a/custom_components/hunterdouglas_powerview_ble/gateway.py b/custom_components/hunterdouglas_powerview_ble/gateway.py new file mode 100644 index 0000000..616ed5e --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/gateway.py @@ -0,0 +1,138 @@ +"""PowerView Gen3 gateway interaction. + +Async port of scripts/extract_gateway3_homekey.py for use inside the +integration. Used by the config flow to pull the AES home key out of a +local PowerView gateway so users don't have to hand-edit const.py. + +The gateway speaks the same byte-level GetShadeKey protocol as the shades +themselves, just over HTTP instead of BLE. Each shade in a home returns +the same 16-byte home key — we only need one successful shade query. +""" + +from __future__ import annotations + +import base64 +import struct +from typing import Any, Final + +import aiohttp + +from .const import LOGGER + +GATEWAY_HTTP_TIMEOUT: Final[int] = 10 +DEFAULT_GATEWAY_HOST: Final[str] = "http://powerview-g3.local" + + +class GatewayError(Exception): + """Anything went wrong talking to the PowerView gateway.""" + + +def _frame(sid: int, cid: int, sequence_id: int, data: bytes) -> bytes: + return struct.pack(" dict[str, Any]: + if len(packet) < 4: + raise GatewayError("Response packet too small") + sid, cid, seq, length = struct.unpack(" str: + """Accept 'powerview-g3.local', '192.168.1.50', or a full URL.""" + host = host.strip().rstrip("/") + if not host.startswith(("http://", "https://")): + host = "http://" + host + return host + + +async def _list_shades( + session: aiohttp.ClientSession, host: str +) -> list[dict[str, Any]]: + url = f"{host}/home/shades" + async with session.get(url, timeout=GATEWAY_HTTP_TIMEOUT) as resp: + resp.raise_for_status() + return await resp.json(content_type=None) + + +async def _get_shade_key( + session: aiohttp.ClientSession, host: str, ble_name: str +) -> bytes: + """Query one shade via the gateway; the returned key is the home key.""" + req = _frame(251, 18, 1, b"") # GetShadeKey + url = f"{host}/home/shades/exec?shades={ble_name}" + async with session.post( + url, json={"hex": req.hex()}, timeout=GATEWAY_HTTP_TIMEOUT + ) as resp: + resp.raise_for_status() + result = await resp.json(content_type=None) + if result.get("err") != 0 or len(result.get("responses", [])) != 1: + raise GatewayError(f"Gateway rejected GetShadeKey for {ble_name}") + decoded = _decode(bytes.fromhex(result["responses"][0]["hex"])) + if decoded["err"] != 0: + raise GatewayError(f"BLE errorCode {decoded['err']} for {ble_name}") + if len(decoded["data"]) != 16: + raise GatewayError( + f"Expected 16-byte home key from {ble_name}, got {len(decoded['data'])}" + ) + return decoded["data"] + + +async def probe_gateway(host: str) -> dict[str, Any]: + """Confirm a host is a PowerView gateway and return basic info. + + Returns {"host": normalized_host, "shade_count": int, "sample_name": str} + or raises GatewayError. + """ + host = _normalize_host(host) + async with aiohttp.ClientSession() as session: + try: + shades = await _list_shades(session, host) + except (aiohttp.ClientError, TimeoutError) as ex: + raise GatewayError(f"Cannot reach gateway at {host}: {ex}") from ex + if not isinstance(shades, list) or not shades: + raise GatewayError(f"Gateway at {host} reports no shades") + sample = shades[0] + try: + sample_name = base64.b64decode(sample.get("name", "")).decode("utf-8") + except (ValueError, UnicodeDecodeError): + sample_name = sample.get("bleName", "(unnamed)") + return {"host": host, "shade_count": len(shades), "sample_name": sample_name} + + +async def extract_home_key(host: str) -> str: + """Extract the 16-byte home key from a PowerView gateway, hex-encoded. + + Tries each shade in turn — some shades may be powered off or out of + range from the gateway. Any single successful response is sufficient + because all shades in one home share the same key. + """ + host = _normalize_host(host) + async with aiohttp.ClientSession() as session: + try: + shades = await _list_shades(session, host) + except (aiohttp.ClientError, TimeoutError) as ex: + raise GatewayError(f"Cannot reach gateway at {host}: {ex}") from ex + if not shades: + raise GatewayError("Gateway returned no shades to query") + last_err: Exception | None = None + for shade in shades: + ble_name = shade.get("bleName") + if not ble_name: + continue + try: + key = await _get_shade_key(session, host, ble_name) + LOGGER.debug( + "Got home key from gateway %s via shade %s", host, ble_name + ) + return key.hex() + except (aiohttp.ClientError, TimeoutError, GatewayError) as ex: + LOGGER.debug("GetShadeKey via %s failed: %s", ble_name, ex) + last_err = ex + continue + raise GatewayError( + f"Gateway reachable but no shade returned a key (last error: {last_err})" + ) diff --git a/custom_components/hunterdouglas_powerview_ble/key_store.py b/custom_components/hunterdouglas_powerview_ble/key_store.py new file mode 100644 index 0000000..fedaf37 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/key_store.py @@ -0,0 +1,76 @@ +"""Persistent storage for the PowerView AES home key. + +The key is the only piece of per-installation configuration the integration +needs that isn't already a config entry, and it must survive HACS updates +(which overwrite const.py and the rest of the integration source tree). + +Stored in HA's `.storage/` directory under the integration's domain — a +single JSON document that holds the hex-encoded 16-byte key. +""" + +from __future__ import annotations + +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN, LOGGER + +STORAGE_VERSION: Final[int] = 1 +STORAGE_KEY: Final[str] = DOMAIN # → .storage/hunterdouglas_powerview_ble + + +class HomeKeyStore: + """Async wrapper around an HA Store for the home key.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the store.""" + self._store: Store[dict[str, str]] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._cache: bytes | None = None + self._loaded: bool = False + + async def async_load(self) -> bytes | None: + """Load the persisted key. Returns None if not set.""" + if self._loaded: + return self._cache + data = await self._store.async_load() + self._loaded = True + if not data: + return None + hex_key = data.get("home_key", "") + if not hex_key: + return None + try: + key = bytes.fromhex(hex_key) + except ValueError: + LOGGER.warning("Stored home key is not valid hex; ignoring") + return None + if len(key) != 16: + LOGGER.warning( + "Stored home key is %d bytes, expected 16; ignoring", len(key) + ) + return None + self._cache = key + return key + + async def async_save(self, key: bytes) -> None: + """Persist a 16-byte home key.""" + if len(key) != 16: + raise ValueError(f"Home key must be 16 bytes, got {len(key)}") + await self._store.async_save({"home_key": key.hex()}) + self._cache = key + self._loaded = True + LOGGER.info("Saved PowerView home key to %s", STORAGE_KEY) + + async def async_clear(self) -> None: + """Forget the stored key.""" + await self._store.async_remove() + self._cache = None + self._loaded = True + + +async def async_get_home_key(hass: HomeAssistant) -> bytes | None: + """One-shot helper: load the key (creating the store if needed).""" + store = HomeKeyStore(hass) + return await store.async_load() diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index eeab944..6aa243b 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -11,10 +11,18 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://github.com/patman15/hdpv_ble", - "integration_type": "device", + "integration_type": "hub", "iot_class": "local_polling", "issue_tracker": "https://github.com/patman15/hdpv_ble/issues", "loggers": ["hunterdouglas_powerview_ble"], "requirements": ["cryptography>=43.0.0"], - "version": "0.23" + "version": "0.24", + "zeroconf": [ + { + "type": "_powerview-g3._tcp.local." + }, + { + "type": "_PowerView-G3._tcp.local." + } + ] } diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index d19333d..b23c118 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -67,6 +67,18 @@ def __init__( self.entity_description = descr super().__init__(pv_dev) + @property + def available(self) -> bool: + """Gate availability on freshness of decoded V2 advert. + + RSSI is exempt — it updates on every BLE event regardless of payload, + so it's a useful indicator of raw link presence even when V2 frames + stop decoding. + """ + if self.entity_description.key == ATTR_RSSI: + return super().available + return super().available and self.coordinator.data_available + @property def native_value(self) -> int | float | None: # type: ignore[reportIncompatibleVariableOverride] """Return the sensor value.""" diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 19601be..b6d9fac 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -2,14 +2,52 @@ "config": { "flow_title": "Setup {name}", "step": { + "user": { + "title": "Hunter Douglas PowerView (BLE)", + "description": "What do you want to set up?", + "menu_options": { + "extract_key": "Extract home key from a PowerView gateway", + "shade": "Pair a PowerView shade (Bluetooth)" + } + }, + "extract_key": { + "title": "Extract home key from gateway", + "description": "Enter the address of your PowerView Gen3 gateway. The integration will fetch the AES home key once and store it permanently — the gateway is only needed for this one call, after which it can be offline or out of range.", + "data": { + "host": "Gateway address (URL, hostname, or IP)" + } + }, + "extract_key_confirm": { + "title": "PowerView gateway discovered", + "description": "Fetch the home key from the gateway at {host}?" + }, "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "pick_shade": { + "title": "Pair a PowerView shade", + "description": "Pick a shade that's currently advertising via Bluetooth.", + "data": { + "address": "Shade" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "not_supported": "Device not supported" + "not_supported": "Device not supported", + "home_key_saved": "Home key extracted and saved. Existing shade entries are reloading to use it.", + "home_key_already_saved": "A home key is already saved for this installation. Use the integration menu to re-extract if it has changed." + }, + "error": { + "cannot_connect": "Could not reach the gateway or extract a home key. Check the address and that the gateway has paired shades." + } + }, + "entity": { + "binary_sensor": { + "low_power_mode": { + "name": "Low power mode" + } } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 528686d..a68ac54 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -3,12 +3,50 @@ "abort": { "already_configured": "Device is already configured", "no_devices_found": "No devices found on the network", - "not_supported": "Device not supported" + "not_supported": "Device not supported", + "home_key_saved": "Home key extracted and saved. Existing shade entries are reloading to use it.", + "home_key_already_saved": "A home key is already saved for this installation. Use the integration menu to re-extract if it has changed." }, "flow_title": "Setup {name}", "step": { + "user": { + "title": "Hunter Douglas PowerView (BLE)", + "description": "What do you want to set up?", + "menu_options": { + "extract_key": "Extract home key from a PowerView gateway", + "shade": "Pair a PowerView shade (Bluetooth)" + } + }, + "extract_key": { + "title": "Extract home key from gateway", + "description": "Enter the address of your PowerView Gen3 gateway. The integration will fetch the AES home key once and store it permanently — the gateway is only needed for this one call, after which it can be offline or out of range.", + "data": { + "host": "Gateway address (URL, hostname, or IP)" + } + }, + "extract_key_confirm": { + "title": "PowerView gateway discovered", + "description": "Fetch the home key from the gateway at {host}?" + }, "bluetooth_confirm": { "description": "Do you want to set up {name}?" + }, + "pick_shade": { + "title": "Pair a PowerView shade", + "description": "Pick a shade that's currently advertising via Bluetooth.", + "data": { + "address": "Shade" + } + } + }, + "error": { + "cannot_connect": "Could not reach the gateway or extract a home key. Check the address and that the gateway has paired shades." + } + }, + "entity": { + "binary_sensor": { + "low_power_mode": { + "name": "Low power mode" } } } diff --git a/scripts/get_home_key.py b/scripts/get_home_key.py new file mode 100644 index 0000000..1e7d16f --- /dev/null +++ b/scripts/get_home_key.py @@ -0,0 +1,237 @@ +"""Extract PowerView Gen3 homekeys via the Hunter Douglas cloud account. + +Reproduces what the Android PowerView app (com.hunterdouglas.powerview 3.8.1) +does to obtain each home's homekey: + + 1. POST /api/v5/users/rcUserSignIn -> pvkey + 2. GET /api/v5/homes -> documentId per home + 3. GET /api/v5/firebaseAuth/userToken -> Firebase custom token + 4. Identity Toolkit signInWithCustomToken -> Firebase idToken + 5. Firestore REST: homes/{documentId} -> home.key (the homekey) + +Companion to extract_gateway3_homekey.py — that script reads the homekey from +a Gen3 gateway over the local network; this script reads it from the cloud, +which works for any home on the account whether the gateway is reachable or not. +""" + +import base64 +import getpass +import hashlib +from typing import Any, Final +import urllib.parse + +import requests + +RC_API: Final[str] = "https://homeauto.hunterdouglas.com" +FIREBASE_API_KEY: Final[str] = "AIzaSyAIfkw7TAweHwjikdahH1ZqbL7lxF6yAxQ" +FIREBASE_PROJECT: Final[str] = "powerblue-861ad" +NO_KEY_SENTINEL: Final[str] = "00112233445566778899AABBCCDDEEFF" +TIMEOUT: Final[int] = 20 + +# Identity values matching what the Android PowerView 3.8.1 'hd' release sends. +# Faking these makes us look like a normal app instance to Hunter Douglas; +# inventing 'python-client' style values would stand out in any server-side +# log/filter. See BuildConfig.java, BrandConfig.getAppBrandName(), +# PowerViewApplication.getUserAgentString(), SignInViewModel.doSignIn(). +APP_VERSION: Final[str] = "3.8.1" +APP_BUILD: Final[str] = "7911" +APP_BRAND: Final[str] = "HD" # BrandConfig flavor 'hd' -> "HD" +DEVICE_NAME: Final[str] = "Google Pixel 8" # Build.MANUFACTURER + " " + Build.MODEL +LANGUAGE: Final[str] = "en" # Locale.getDefault().getLanguage() +REGION: Final[str] = "US" # Locale.getDefault().getCountry() +LOCALE: Final[str] = f"{LANGUAGE}_{REGION}" # Locale.getDefault().toString() +USER_AGENT: Final[str] = f"PowerView/{APP_VERSION} {APP_BUILD} ({DEVICE_NAME} {LOCALE})" + + +def device_app_id(email: str) -> str: + """Derive a stable, ANDROID_ID-shaped deviceAppId from the account email. + + The app builds this as `Settings.Secure.ANDROID_ID + "hdrelease"`, capped + at 64 chars (BrandConfig.appDeviceId). ANDROID_ID is a 16-hex-char string. + Hashing the email gives a value that's stable across runs for this account + so we don't look like a brand new device on every login. + """ + android_id: str = hashlib.sha256(email.encode()).hexdigest()[:16] + return f"{android_id}hdrelease" + + +def make_session() -> requests.Session: + """Build a requests.Session with the User-Agent the Android app sends.""" + session: requests.Session = requests.Session() + session.headers["User-Agent"] = USER_AGENT + return session + + +def sign_in(session: requests.Session, email: str, password: str) -> str: + """Sign in to the PowerView account API and return the pvkey token.""" + body: Final[dict[str, dict[str, str]]] = { + "user": { + "email": email, + "password": password, + "deviceAppBrand": APP_BRAND, + "deviceAppId": device_app_id(email), + "deviceAppVersion": APP_VERSION, + "deviceName": DEVICE_NAME, + "deviceType": DEVICE_NAME, + "language": LANGUAGE, + "region": REGION, + } + } + resp: requests.Response = session.post( + f"{RC_API}/api/v5/users/rcUserSignIn", json=body, timeout=TIMEOUT + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + if data.get("error"): + raise RuntimeError(f"Sign-in failed: {data['error']}") + pvkey: str = data["pvkey"] + return pvkey + + +def basic_auth_header(email: str, pvkey: str) -> dict[str, str]: + """Build the Basic auth header that the app uses on every cloud request.""" + token: Final[str] = base64.b64encode(f"{email}:{pvkey}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def list_homes(session: requests.Session, auth: dict[str, str]) -> list[dict[str, Any]]: + """Return the list of homes (RHome objects) on this account.""" + resp: requests.Response = session.get( + f"{RC_API}/api/v5/homes", headers=auth, timeout=TIMEOUT + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + homes: list[dict[str, Any]] = data.get("homes", []) + return homes + + +def firebase_custom_token(session: requests.Session, auth: dict[str, str]) -> str: + """Trade the pvkey for a single-use Firebase custom token.""" + resp: requests.Response = session.get( + f"{RC_API}/api/v5/firebaseAuth/userToken", headers=auth, timeout=TIMEOUT + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + token: str = data["token"] + return token + + +def firebase_id_token(custom_token: str) -> str: + """Exchange a Firebase custom token for a long-lived idToken.""" + url: Final[str] = ( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken" + f"?key={FIREBASE_API_KEY}" + ) + resp: requests.Response = requests.post( + url, + json={"token": custom_token, "returnSecureToken": True}, + timeout=TIMEOUT, + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + id_token: str = data["idToken"] + return id_token + + +def firestore_get_home(id_token: str, document_id: str) -> dict[str, Any]: + """Read the homes/{documentId} document from Firestore.""" + doc_path: Final[str] = f"homes/{urllib.parse.quote(document_id, safe='')}" + url: Final[str] = ( + f"https://firestore.googleapis.com/v1/projects/{FIREBASE_PROJECT}" + f"/databases/(default)/documents/{doc_path}" + ) + resp: requests.Response = requests.get( + url, headers={"Authorization": f"Bearer {id_token}"}, timeout=TIMEOUT + ) + resp.raise_for_status() + doc: dict[str, Any] = resp.json() + return doc + + +def extract_home_key(doc: dict[str, Any]) -> str | None: + """Pull the homekey out of a Firestore home document, or None if not set. + + The Firestore document mirrors FSHomeDoc in the Android app; the homekey + is the `home.key` string field. The app treats both the empty string and + the sentinel '00112233445566778899AABBCCDDEEFF' as 'no key set'. + """ + fields: dict[str, Any] = doc.get("fields", {}) + home: dict[str, Any] = fields.get("home", {}).get("mapValue", {}).get("fields", {}) + key: str | None = home.get("key", {}).get("stringValue") + if not key or key == NO_KEY_SENTINEL: + return None + return key + + +def home_display_name(home: dict[str, Any]) -> str: + """Pick the most user-friendly name for a home from an RHome record.""" + return ( + home.get("name") + or home.get("gen3DisplayName") + or home.get("gen2DisplayName") + or "(unnamed)" + ) + + +def main(email: str, password: str | None) -> int: + """Sign in, then print the homekey for every home on the account.""" + pwd: Final[str] = password or getpass.getpass(f"Password for {email}: ") + session: Final[requests.Session] = make_session() + + print("Signing in...") + pvkey: Final[str] = sign_in(session, email, pwd) + auth: Final[dict[str, str]] = basic_auth_header(email, pvkey) + + homes: Final[list[dict[str, Any]]] = list_homes(session, auth) + if not homes: + print("No homes are associated with this account.") + return 1 + + print("Authenticating to Firebase...") + id_token: Final[str] = firebase_id_token(firebase_custom_token(session, auth)) + + print(f"Found {len(homes)} home(s), interrogating") + for home in homes: + name: str = home_display_name(home) + doc_id: str | None = home.get("documentId") + role: str = home.get("role") or "?" + hub_id: str = home.get("hubId") or "-" + + print(f"Home '{name}':") + print(f"\trole: {role}") + print(f"\thub id: {hub_id}") + print(f"\tdocument id: {doc_id or '(none — Gen2-only home, no Gen3 homekey)'}") + + if not doc_id: + continue + + try: + doc: dict[str, Any] = firestore_get_home(id_token, doc_id) + except requests.HTTPError as ex: + print( + f"\tHomeKey: error — Firestore returned HTTP {ex.response.status_code}" + ) + continue + + key: str | None = extract_home_key(doc) + if key is None: + print("\tHomeKey: not set (this home was created without encryption)") + else: + print(f"\tHomeKey: {key.lower()}") + + return 0 + + +if __name__ == "__main__": + import argparse + import sys + + parser = argparse.ArgumentParser( + description="Extract PowerView Gen3 homekeys from a Hunter Douglas account", + ) + parser.add_argument("-e", "--email", required=True, help="account email") + parser.add_argument( + "-p", "--password", default=None, help="account password (prompted if omitted)" + ) + args = parser.parse_args() + sys.exit(main(**vars(args)))