From 37a49078ac69826b2eabb823ee0b3ea337524bf0 Mon Sep 17 00:00:00 2001 From: Frans-Willem Hardijzer Date: Sun, 24 May 2026 19:12:22 +0200 Subject: [PATCH 01/16] Add scripts/get_home_key.py to fetch home keys from the HD cloud account (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add scripts/get_home_key.py to fetch home keys from the HD cloud account A fourth way to obtain the HOME_KEY: signs in to the PowerView account API, exchanges the pvkey for a Firebase token, and reads the same Firestore home document the Android app reads from. Works for any home on the account whether or not a Gen3 gateway is reachable, and avoids the SQLite-extraction route described in the HA forum thread. The flow mirrors the Android app's (decompiled from com.hunterdouglas.powerview 3.8.1): 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 homes/{documentId} -> home.key Empty and the sentinel '00112233445566778899AABBCCDDEEFF' are reported as 'not set', matching the app's CloudDecoder behaviour. README updated to list the new method alongside the existing three. * Send the same identity fields the Android app does The previous body fields (`deviceAppBrand: "PowerView"`, `deviceAppId: "python-client"`, `deviceName: "python"`, etc.) and the default `requests` User-Agent would stand out trivially on the Hunter Douglas server side. Match what com.hunterdouglas.powerview 3.8.1 actually sends: - deviceAppBrand "HD" (BrandConfig.getAppBrandName for the 'hd' flavor) - deviceAppId "<16 hex chars>hdrelease" (BrandConfig.appDeviceId; 16 hex chars derived from sha256(email) for stability across runs) - deviceName / deviceType "Google Pixel 8" (Build.MANUFACTURER+MODEL) - language/region from a fixed en_US locale - User-Agent "PowerView/3.8.1 7911 (Google Pixel 8 en_US)" on every Hunter Douglas request (PowerViewApplication.getUserAgentString) The User-Agent is now applied via a `requests.Session` shared by all three HD endpoints. Firebase/Firestore calls keep the default User-Agent — those endpoints aren't HD-controlled and the app uses the Firebase SDK there anyway. --- README.md | 5 +- scripts/get_home_key.py | 237 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 scripts/get_home_key.py 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/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))) From 614e0325ed70047de5015dcd2867a976b3254395 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 19:38:19 -0500 Subject: [PATCH 02/16] Fix device merge, data wipe, staleness; add gateway-driven home key extraction Bug fixes - Device-registry merge from non-unique identifier: coordinator.device_info used (DOMAIN, self.name) where self.name is the BLE-advertised local name. When firmware fallback or transient mis-advertisement made multiple shades share a name (e.g. "AWEI T13 Pro"), HAs registry merged them into one device record and lost per-shade name_by_user and area assignment. Switched to (DOMAIN, format_mac(self.address)). - Position wiped on every BLE event: self.data was reset to just {ATTR_RSSI: rssi} on every event, then conditionally merged with manufacturer data. Any non-V2 packet (or empty decode) left the cover with no position, surfacing state="open" with current_position=None even on closed shades. Now preserves last-known V2 fields and only updates them when a V2 advert decodes successfully; state-of-the-moment flags (is_opening/is_closing/battery_charging/etc.) are still cleared per-event so movement bits do not latch. - home_id stickiness disabling controls: cover.supported_features returns 0 when home_id != 0 and HOME_KEY isn't 16 bytes. With the data-preservation fix above, this gate now correctly triggers, but many paired roller shades accept plaintext commands even without HOME_KEY, so the gate is over-protective. home_id is now consumed (drives api.encrypted) and popped from self.data so UI controls stay visible; cipher is applied only when both home_id and a real 16-byte HOME_KEY are present. - Stale entities masquerading as fresh: PassiveBluetoothCoordinatorEntity treats any BLE address activity as "present", so an out-of-range shade kept reporting its last known position indefinitely. Added a data_available property on the coordinator (True iff V2 advert decoded within STALE_AFTER=300s) and overrode available in cover/sensor/ binary_sensor to AND with it. RSSI sensor is exempt. Gateway-driven home key extraction - New gateway.py: async aiohttp port of scripts/extract_gateway3_homekey.py. Probes a PowerView Gen3 gateway over HTTP, walks the shade list, and asks each in turn for its home key via the GetShadeKey frame. Any single successful response is sufficient since all shades in one home share the same key. - New key_store.py: thin wrapper over homeassistant.helpers.storage.Store that persists the 16-byte key as hex in .storage/hunterdouglas_powerview_ble. Outside the integration source tree, so HACS updates cannot wipe it. - Config flow now offers a top-level menu: "Extract home key from a PowerView gateway" (one-shot helper that aborts without creating an entry) or "Pair a PowerView shade" (existing BLE pairing flow). Zeroconf discovery via _PowerView-G3._tcp. auto-triggers the extraction flow when a gateway appears on the network; skipped once a key is already stored to avoid Discovered-tile spam. - Coordinator now accepts home_key as a parameter (resolved from the store at setup) rather than importing const.HOME_KEY directly. const.HOME_KEY remains as a final fallback (default b""). manifest bumped to 0.24; integration_type changed from "device" to "hub" to reflect the home-key extraction role. Tested against a 10-shade installation with 4 KDT curtain motors (which require encrypted commands) and 6 ACR rollers (which do not). All ten respond to open/close after extraction; previously the KDT motors silently timed out and const.py had to be hand-edited every HACS update. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/__init__.py | 16 +- .../binary_sensor.py | 5 + .../config_flow.py | 157 +++++++++++++++--- .../hunterdouglas_powerview_ble/const.py | 11 +- .../coordinator.py | 83 +++++++-- .../hunterdouglas_powerview_ble/cover.py | 11 ++ .../hunterdouglas_powerview_ble/gateway.py | 138 +++++++++++++++ .../hunterdouglas_powerview_ble/key_store.py | 76 +++++++++ .../hunterdouglas_powerview_ble/manifest.json | 12 +- .../hunterdouglas_powerview_ble/sensor.py | 12 ++ .../hunterdouglas_powerview_ble/strings.json | 33 +++- .../translations/en.json | 33 +++- 12 files changed, 543 insertions(+), 44 deletions(-) create mode 100644 custom_components/hunterdouglas_powerview_ble/gateway.py create mode 100644 custom_components/hunterdouglas_powerview_ble/key_store.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/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index dda9127..c2026bc 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -56,6 +56,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..684cdd8 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] = 300.0 # seconds without a V2 advert before entity is unavailable -# 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..56d0dc4 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -1,5 +1,6 @@ """Home Assistant coordinator for Hunter Douglas PowerView (BLE) integration.""" +import time from typing import Any from bleak.backends.device import BLEDevice @@ -10,25 +11,44 @@ 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 LOGGER.debug( "Initializing coordinator for %s (%s)", @@ -53,7 +73,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 +103,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 +129,41 @@ 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", + ): + 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() 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..40a1bfb 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -78,6 +78,17 @@ def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableO """Return the device_info of the device.""" return self._coord.device_info + @property + def available(self) -> bool: + """Return True only when the shade has produced a recent V2 advert. + + Without this gate, an out-of-range shade keeps reporting its last + known position indefinitely (e.g. stuck at "closed" while it is + actually open), because PassiveBluetoothCoordinatorEntity treats + any BLE address activity as "present". + """ + return super().available and self._coord.data_available + @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Return if the cover is opening or not.""" 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..609cf8a 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -2,14 +2,45 @@ "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." } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 528686d..4e57480 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -3,13 +3,44 @@ "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." } } } From a01d95c39a47411b59ddaa6be75834f4447f89e4 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 20:54:08 -0500 Subject: [PATCH 03/16] cover: stop falling back to _target_position in is_opening/is_closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback was meant to give responsive UI during the brief window between sending a BLE command and the first advert echoing the new movement bits, but it has a much worse failure mode: _target_position is never reset on successful command completion, and api.is_connected flickers as BLE connections come and go after each command. The combined result is that hours after a successful set_cover_position, the entity still reports is_opening or is_closing based on a stale target — which freezes the cached state on the optimistic write ("state=closed" with "current_position=100" for the rest of the session) because subsequent state recomputations keep returning a movement state instead of resolving to the actual position. Both properties now use only the V2 advert movement bit. State during a command transitions purely on advert evidence, which is what the shade itself reports anyway. Verified end-to-end with HomeKit driving sequential set_cover_position calls across 6 shades: every entity settled cleanly to state matching its current_position, no stale movement state lingered after the motors stopped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 40a1bfb..fb65671 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -91,23 +91,25 @@ def available(self) -> bool: @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 - ) + """Return if the cover is opening or not. + + Uses only the V2 advert movement bit. The previous implementation + also fell back to (target_position > current_position AND + api.is_connected), which permanently latches after a command: + _target_position never resets, api.is_connected flickers as BLE + connections come and go, and the entity ends up reporting + "opening" minutes after the shade has physically settled. + """ + return bool(self._coord.data.get("is_opening")) @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 - ) + """Return if the cover is closing or not. + + Uses only the V2 advert movement bit (see is_opening docstring + for why the target-position fallback was removed). + """ + return bool(self._coord.data.get("is_closing")) @property def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] From 6a206830397dcca916d74c444e28d0b9fb6e460e Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 21:29:31 -0500 Subject: [PATCH 04/16] Expose the "service required" status bit as a diagnostic binary sensor Bit 0x04 of manufacturer-data byte 8 is set on shades that accept encrypted BLE commands with error_code=0 but refuse to physically move. Verified against a live installation: of 10 shades, the 2 that silently failed every set_position call had this bit set, the 8 that moved correctly had it clear. The bit clears once the motor has been re-calibrated via the PowerView app or driven through its full range with the physical remote. Without this sensor the failure mode is invisible: HA service calls return HTTP 200, the integration logs no error (the shade ACKs with error_code=0), and the cover entity just sits at its last reported position. Surfacing the flag as a binary_sensor with device_class=problem lets users notice the broken motor immediately and points them at the fix. Diagnostic-category sensor so it doesn't clutter the dashboard for healthy installations. Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/hunterdouglas_powerview_ble/api.py | 8 ++++++++ .../hunterdouglas_powerview_ble/binary_sensor.py | 10 ++++++++-- custom_components/hunterdouglas_powerview_ble/const.py | 2 +- .../hunterdouglas_powerview_ble/coordinator.py | 1 + .../hunterdouglas_powerview_ble/strings.json | 7 +++++++ .../hunterdouglas_powerview_ble/translations/en.json | 7 +++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5a6cc97..8668d6f 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -202,6 +202,14 @@ 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 set on shades that ACK BLE commands with + # error_code=0 but refuse to physically move — likely a + # "needs calibration / service required" flag the motor sets + # after losing its position reference (factory reset, encoder + # fault, or running past a limit). The bit clears once the + # motor has been re-calibrated via the PowerView app or + # driven through its full range with the physical remote. + ("service_required", bool(data[8] & 0x4)), ] # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index c2026bc..ec1f203 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,13 @@ key=ATTR_BATTERY_CHARGING, translation_key=ATTR_BATTERY_CHARGING, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - ) + ), + BinarySensorEntityDescription( + key="service_required", + translation_key="service_required", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), ] diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index 684cdd8..242e725 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -7,7 +7,7 @@ LOGGER: Final = logging.getLogger(__package__) MFCT_ID: Final[int] = 2073 TIMEOUT: Final[int] = 5 -STALE_AFTER: Final[float] = 300.0 # seconds without a V2 advert before entity is unavailable +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) # Config flow form field CONF_HOST: Final[str] = "host" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 56d0dc4..9341264 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -146,6 +146,7 @@ def _async_handle_bluetooth_event( "battery_charging", "resetMode", "resetClock", + "service_required", ): self.data.pop(_k, None) if change == bluetooth.BluetoothChange.ADVERTISEMENT: diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 609cf8a..62b0f9c 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -42,5 +42,12 @@ "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": { + "service_required": { + "name": "Service required" + } + } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 4e57480..a6cfe8a 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -42,5 +42,12 @@ "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": { + "service_required": { + "name": "Service required" + } + } } } From 5b1e9ca58faf8738a51282d827420c59c392d68b Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 21:38:48 -0500 Subject: [PATCH 05/16] api: correct service_required bit semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live: the bit transiently clears for ~10s when the motor is actively tracking position (during a remote-triggered move), then snaps back on as soon as the motor settles. So it indicates "motor in low-power state", not "permanent calibration fault". The functional implication for HA users is the same — BLE SET_POSITION commands sent while the bit is on are ACK'd with error_code=0 but ignored — but the docstring now reflects what we actually saw rather than guessing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/api.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 8668d6f..e54a785 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -202,13 +202,18 @@ 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 set on shades that ACK BLE commands with - # error_code=0 but refuse to physically move — likely a - # "needs calibration / service required" flag the motor sets - # after losing its position reference (factory reset, encoder - # fault, or running past a limit). The bit clears once the - # motor has been re-calibrated via the PowerView app or - # driven through its full range with the physical remote. + # 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 in low-power state" flag. When on, + # SET_POSITION frames are ACK'd with error_code=0 but the + # motor ignores them; clears reliably after a calibration + # via the PowerView app or a full-range manual move. ("service_required", bool(data[8] & 0x4)), ] From d9a2d4d8d20612b9566fed1cc23f2491f829d013 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 21:48:53 -0500 Subject: [PATCH 06/16] api/cover: send SET_POSITION twice when the motor is in low-power state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically: shades whose advert reports bit 0x04 of byte 8 set (the service_required flag) ACK SET_POSITION with error_code=0 but ignore it physically. The pattern can be cleared by sending the same frame again ~500ms later in the same BLE session — the first frame wakes the motor without moving anything; the second frame, sent while it is briefly awake, actually drives the curtain. This is what a physical remote effectively does: button press → motor wakes → command processed. Without this, HA users have to physically press the remote or recalibrate via the PowerView app every time a shade slips into low-power state, even though the motor is otherwise healthy (battery 100, BLE reachable, encryption key correct). Implementation: - api.set_position / api.open / api.close: new wake_first kwarg. When true, the SET_POSITION frame is sent twice with a 0.5s gap, both over the same BLE connection (disconnect=False on the first send). The wake-up send swallows BleakError / TimeoutError so the real send still runs even if the wake-up itself failed. - cover.py: each command path checks self._coord.data["service_required"] via a new _needs_wake property and passes wake_first=True when set. Shades that don't have the bit set continue to get a single send with no added latency. Verified against a curtain motor (KDT:D079) that had refused every single HA-issued open/close for hours, reporting service_required=on the entire time. After this change a single HK press transitioned the shade from closed/0 to closing/47 within seconds and to open/100 in ~30s, with the bit clearing partway through the move as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/api.py | 48 ++++++++++++------- .../hunterdouglas_powerview_ble/cover.py | 16 +++++-- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index e54a785..556b69a 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -226,43 +226,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 (service_required 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 `service_required=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/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index fb65671..3d74f36 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -135,6 +135,14 @@ 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 state and needs a double-send. + + See api.set_position(wake_first=...) for the rationale. + """ + return bool(self._coord.data.get("service_required")) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" target_position: Final = kwargs.get(ATTR_POSITION) @@ -146,7 +154,9 @@ 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), wake_first=self._needs_wake + ) self.async_write_ha_state() except BleakError as err: LOGGER.error( @@ -166,7 +176,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: return try: self._target_position = OPEN_POSITION - await self._coord.api.open() + await self._coord.api.open(wake_first=self._needs_wake) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to open cover '%s': %s", self.name, err) @@ -179,7 +189,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: return try: self._target_position = CLOSED_POSITION - await self._coord.api.close() + await self._coord.api.close(wake_first=self._needs_wake) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to close cover '%s': %s", self.name, err) From 85b71519d9390295a480adaf8413bef28c593207 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 21:59:14 -0500 Subject: [PATCH 07/16] =?UTF-8?q?rename=20service=5Frequired=20=E2=86=92?= =?UTF-8?q?=20low=5Fpower=5Fmode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Service required" implied user action when in practice the integration auto-recovers via the wake_first send path. The bit is actually a transient low-power / sleep flag — it cycles off during motor activity and back on once the motor settles. Renamed end-to-end (decode key, coordinator pop list, cover._needs_wake, binary_sensor key + translation_key, English string) to "low_power_mode" / "Low power mode". Dropped device_class=problem since it is no longer a problem the user must respond to. Kept entity_category=diagnostic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/api.py | 14 +++++++------- .../hunterdouglas_powerview_ble/binary_sensor.py | 9 ++++++--- .../hunterdouglas_powerview_ble/coordinator.py | 2 +- .../hunterdouglas_powerview_ble/cover.py | 4 ++-- .../hunterdouglas_powerview_ble/strings.json | 4 ++-- .../translations/en.json | 4 ++-- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 556b69a..f9234ca 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -210,11 +210,11 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: # actively tracking position (e.g. during a remote- # triggered move) # * snaps back on as soon as the motor settles - # Best read as a "motor in low-power state" flag. When on, - # SET_POSITION frames are ACK'd with error_code=0 but the - # motor ignores them; clears reliably after a calibration - # via the PowerView app or a full-range manual move. - ("service_required", bool(data[8] & 0x4)), + # 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 @@ -231,11 +231,11 @@ async def set_position( """Set position of device. wake_first: send the SET_POSITION frame twice with a 0.5s gap. Shades - in low-power state (service_required bit set in their advert) ACK + 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 `service_required=on` for this shade. + observed `low_power_mode=on` for this shade. """ LOGGER.debug( "%s setting position to %i/%i/%i, tilt %i, velocity %s%s", diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index ec1f203..b2d06dd 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -24,9 +24,12 @@ device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), BinarySensorEntityDescription( - key="service_required", - translation_key="service_required", - device_class=BinarySensorDeviceClass.PROBLEM, + # 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, ), ] diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 9341264..b28752e 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -146,7 +146,7 @@ def _async_handle_bluetooth_event( "battery_charging", "resetMode", "resetClock", - "service_required", + "low_power_mode", ): self.data.pop(_k, None) if change == bluetooth.BluetoothChange.ADVERTISEMENT: diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 3d74f36..0b5f86f 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -137,11 +137,11 @@ def current_cover_position(self) -> int | None: # type: ignore[reportIncompatib @property def _needs_wake(self) -> bool: - """True when the shade is in low-power state and needs a double-send. + """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("service_required")) + 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.""" diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 62b0f9c..b6d9fac 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -45,8 +45,8 @@ }, "entity": { "binary_sensor": { - "service_required": { - "name": "Service required" + "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 a6cfe8a..a68ac54 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -45,8 +45,8 @@ }, "entity": { "binary_sensor": { - "service_required": { - "name": "Service required" + "low_power_mode": { + "name": "Low power mode" } } } From a3e97b490dfc30e23411e542783f97f37b7ae8eb Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 22:15:37 -0500 Subject: [PATCH 08/16] coordinator: derive cover direction from position delta, not advert bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V2 advert encodes is_opening / is_closing in pos & 0x3 (0x2 and 0x1 respectively). Empirically, at least some KDT curtain firmwares swap those bit meanings vs ACR rollers — symptom was HomeKit displaying "Closing" while a KDT curtain was physically opening, even though the position field was climbing from 0 toward 100 the whole time. Position itself is unambiguous. After updating self.data with the decoded V2 fields, compare the new ATTR_CURRENT_POSITION against the previous one and override is_opening / is_closing accordingly: * new > prev -> is_opening = True, is_closing = False * new < prev -> is_opening = False, is_closing = True * new == prev -> both False (shade is stopped) The advert bits are now ignored for direction inference. The is_closed property still drives the steady-state "open" vs "closed" classification. Verified live on KDT:3F34 (Master Bedroom Curtains), which was previously showing "Closing" in HomeKit while opening: post-fix the state trace is closed/0 → opening/44 → opening/95 → open/100. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../coordinator.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index b28752e..cb29aea 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -5,6 +5,7 @@ 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 ( @@ -49,6 +50,7 @@ def __init__( 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)", @@ -165,6 +167,25 @@ def _async_handle_bluetooth_event( # 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() + # Override the V2 movement bits with the direction inferred + # from position delta. Some KDT curtain firmwares encode + # the is_opening/is_closing bits with the opposite meaning + # of ACR rollers — symptom was HomeKit showing "Closing" + # while a curtain was physically opening. Position itself + # is unambiguous: increasing = opening, decreasing = closing, + # stable = stopped. + new_pos = self.data.get(ATTR_CURRENT_POSITION) + if new_pos is not None and self._last_position is not None: + if new_pos > self._last_position: + self.data["is_opening"] = True + self.data["is_closing"] = False + elif new_pos < self._last_position: + self.data["is_opening"] = False + self.data["is_closing"] = True + else: + self.data["is_opening"] = False + self.data["is_closing"] = False + self._last_position = new_pos LOGGER.debug("data sample %s", self.data) super()._async_handle_bluetooth_event(service_info, change) From 8f20dcd2595778afce977dce23979fea550dbdeb Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 23:20:57 -0500 Subject: [PATCH 09/16] cover: kill ghost state transitions Two fixes that eliminate the "closed -> closing -> closed" and "open -> opening -> open" ghost transitions observed during normal HomeKit use: 1. is_closed uses the same DIRECTION_DEADZONE (currently 2%) that the movement properties use, so motors that settle 1-2% short of fully closed (common end-of-travel calibration drift) no longer bounce between "open" and "closed". Now also correctly returns None when position is unknown, instead of falsely reporting "open". 2. The three command handlers no longer call async_write_ha_state() at the end of their BLE round-trip. The integration's coordinator already calls async_write_ha_state on every BLE advert via the PassiveBluetoothCoordinatorEntity update path, so position and state stay live. The optimistic post-BLE write was racy: a stale-position advert arriving during the ~500ms BLE round-trip caused the entity to write inconsistent state (e.g. "closed" at command time when target was 0 and position was momentarily reported as 0, before the real position re-asserted itself). Verified live on Baby Room Roller (cover.acr_a67c_shade): pre: state=closed pos=0 HK open(100): one brief "open" transient -> "opening" solid 20s with smooth position updates (0 -> 3 -> 6 -> ... -> 96) -> "open"/100. No more closed/closing/closed oscillations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/api.py | 73 ++++++--- .../coordinator.py | 29 ++-- .../hunterdouglas_powerview_ble/cover.py | 143 ++++++++++++------ 3 files changed, 154 insertions(+), 91 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index f9234ca..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]]: diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index cb29aea..37130b9 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -167,25 +167,16 @@ def _async_handle_bluetooth_event( # 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() - # Override the V2 movement bits with the direction inferred - # from position delta. Some KDT curtain firmwares encode - # the is_opening/is_closing bits with the opposite meaning - # of ACR rollers — symptom was HomeKit showing "Closing" - # while a curtain was physically opening. Position itself - # is unambiguous: increasing = opening, decreasing = closing, - # stable = stopped. - new_pos = self.data.get(ATTR_CURRENT_POSITION) - if new_pos is not None and self._last_position is not None: - if new_pos > self._last_position: - self.data["is_opening"] = True - self.data["is_closing"] = False - elif new_pos < self._last_position: - self.data["is_opening"] = False - self.data["is_closing"] = True - else: - self.data["is_opening"] = False - self.data["is_closing"] = False - self._last_position = new_pos + # 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 0b5f86f..5310450 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 @@ -65,9 +66,11 @@ 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 self._attr_unique_id = ( f"{DOMAIN}_{format_mac(self._coord.address)}_{CoverDeviceClass.SHADE}" ) @@ -89,32 +92,65 @@ def available(self) -> bool: """ return super().available and self._coord.data_available + # Direction inference. The shade's advert movement bits are + # unreliable: KDT curtain firmwares invert is_opening / is_closing + # vs ACR rollers, and with multiple BLE proxies in the same home, + # adverts arrive out of order with ±1-3% position bounce that + # produces "closing" flickers during smooth opens. + # + # Direction is derived from intent — _target_position (set by every + # HA / HomeKit command) vs current_position. While the shade is more + # than DIRECTION_DEADZONE percent away from its commanded target, we + # report the corresponding "opening" or "closing" state; once it's + # within the deadzone, the target is considered reached and we fall + # through to the open/closed steady state. + # + # The target also auto-expires after TARGET_TTL seconds of inactivity + # so a stale command can't make the entity report "opening" forever + # when the user has since moved the shade by remote. + + DIRECTION_DEADZONE = 2 + TARGET_TTL = 60.0 # seconds since last command + + 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 + @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is opening or not. - - Uses only the V2 advert movement bit. The previous implementation - also fell back to (target_position > current_position AND - api.is_connected), which permanently latches after a command: - _target_position never resets, api.is_connected flickers as BLE - connections come and go, and the entity ends up reporting - "opening" minutes after the shade has physically settled. - """ - return bool(self._coord.data.get("is_opening")) + """True while a fresh command is still driving the shade upward.""" + if not self._target_is_fresh(): + return False + current = self.current_cover_position + if current is None: + return False + return self._target_position > current + self.DIRECTION_DEADZONE @property def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is closing or not. - - Uses only the V2 advert movement bit (see is_opening docstring - for why the target-position fallback was removed). - """ - return bool(self._coord.data.get("is_closing")) + """True while a fresh command is still driving the shade downward.""" + if not self._target_is_fresh(): + return False + current = self.current_cover_position + if current is None: + return False + return self._target_position < current - self.DIRECTION_DEADZONE @property - def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is closed.""" - return self.current_cover_position == CLOSED_POSITION + def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] + """Return if the cover is closed. + + Uses the same DIRECTION_DEADZONE as is_opening / is_closing so a + motor that settles 1-2% short of fully closed (common end-of- + travel calibration drift) doesn't bounce between "open" and + "closed" in HomeKit. Returns None when position is unknown so + HA reports the entity as 'unknown' rather than assuming "open". + """ + pos = self.current_cover_position + if pos is None: + return None + return pos <= CLOSED_POSITION + self.DIRECTION_DEADZONE @property def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] @@ -144,53 +180,64 @@ def _needs_wake(self) -> bool: 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. + + State updates are driven exclusively by incoming BLE adverts via + the coordinator → entity update path. Writing state optimistically + after the BLE call produces spurious "closed → closing → closed" + and "open → opening → open" ghost transitions whenever an advert + with stale position arrives during the ~500ms BLE round-trip. + """ 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), wake_first=self._needs_wake - ) - 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 + 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._set_target(round(target_position)) + try: + await self._coord.api.set_position( + round(target_position), wake_first=self._needs_wake + ) + except BleakError as err: + LOGGER.error( + "Failed to move cover '%s' to %f%%: %s", + self.name, + target_position, + err, + ) + + 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 _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. State driven by BLE adverts, not optimistic write.""" LOGGER.debug("open cover") if self.current_cover_position == OPEN_POSITION: return try: - self._target_position = OPEN_POSITION + self._set_target(OPEN_POSITION) await self._coord.api.open(wake_first=self._needs_wake) - self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to open cover '%s': %s", self.name, err) self._reset_target_position() async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover tilt.""" + """Close the cover. State driven by BLE adverts, not optimistic write.""" LOGGER.debug("close cover") if self.current_cover_position == CLOSED_POSITION: return try: - self._target_position = CLOSED_POSITION + self._set_target(CLOSED_POSITION) await self._coord.api.close(wake_first=self._needs_wake) - self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to close cover '%s': %s", self.name, err) self._reset_target_position() From 81c2af8451fcbebf3e85cbc705e093fdb161479c Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 23:31:15 -0500 Subject: [PATCH 10/16] cover: write moving state before BLE await to keep HomeKit TargetPosition stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA's HomeKit Bridge resets the HomeKit accessory's TargetPosition to CurrentPosition whenever the cover entity is *not* in a moving state (see homeassistant.components.homekit.type_covers.OpeningDevice.async_update_state). The previous patch removed the post-BLE optimistic write to kill ghost "closed -> closing -> closed" transitions; the side effect was that during the ~500ms between async_set_cover_position being entered and the next BLE advert arriving, the entity could appear in HA as state "open" or "closed" with the new target already set. The Bridge sees that brief non-moving frame, clobbers HK's TargetPosition to match CurrentPosition, and from that point onward iOS computes direction from (Target - Current) and displays it backwards for the rest of the move — e.g. "Closing..." for the entire physical open. The fix is to write state once at the moment the command is registered, *before* the BLE await. With _target_position set, is_opening / is_closing already report the correct direction, so the entity immediately enters "opening" or "closing" and stays there until the next BLE advert recomputes (and stays in the same moving state because target hasn't been reached yet). The Bridge never sees a non-moving frame during the move, never overrides Target, and iOS displays the user's intent. Verified live on Master Bedroom Curtains (cover.kdt_3f34_shade) from state=closed/0 to fully open: +0.5s state=opening pos=0 <-- immediate, before BLE round-trip +1.0s state=opening pos=2 +5.0s state=opening pos=45 +10.0s state=opening pos=93 +11.0s state=open pos=98 <-- within DIRECTION_DEADZONE of target +11.5s state=open pos=100 No "open" transient between command and "opening", which was the frame the HomeKit Bridge was latching onto. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 5310450..d3e7a05 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -182,11 +182,13 @@ def _needs_wake(self) -> bool: async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position. - State updates are driven exclusively by incoming BLE adverts via - the coordinator → entity update path. Writing state optimistically - after the BLE call produces spurious "closed → closing → closed" - and "open → opening → open" ghost transitions whenever an advert - with stale position arrives during the ~500ms BLE round-trip. + State is written immediately (before the BLE await) so the entity + is in 'opening'/'closing' from the instant the command lands. HA's + HomeKit Bridge resets HK's TargetPosition to CurrentPosition any + time the entity is *not* in a moving state — if even a single + non-moving frame leaks through during the command's BLE round-trip, + HK forgets the user's actual target and ends up displaying the + direction backwards for the rest of the move. """ target_position: Final = kwargs.get(ATTR_POSITION) if target_position is None: @@ -197,6 +199,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: ): return self._set_target(round(target_position)) + self.async_write_ha_state() try: await self._coord.api.set_position( round(target_position), wake_first=self._needs_wake @@ -219,24 +222,26 @@ def _reset_target_position(self) -> None: self._target_set_at = None async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover. State driven by BLE adverts, not optimistic write.""" + """Open the cover. Write moving state immediately so HK keeps Target.""" LOGGER.debug("open cover") if self.current_cover_position == OPEN_POSITION: return + self._set_target(OPEN_POSITION) + self.async_write_ha_state() try: - self._set_target(OPEN_POSITION) 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() async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover. State driven by BLE adverts, not optimistic write.""" + """Close the cover. Write moving state immediately so HK keeps Target.""" LOGGER.debug("close cover") if self.current_cover_position == CLOSED_POSITION: return + self._set_target(CLOSED_POSITION) + self.async_write_ha_state() try: - self._set_target(CLOSED_POSITION) 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) From 21a27469c7904e0b25e7c6455c030b78e89a9d2a Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Sun, 24 May 2026 23:59:37 -0500 Subject: [PATCH 11/16] cover: pin direction during command + skip coordinator updates while pinned Adds two defenses against the race where a BLE advert arriving in the same event-loop tick as a HomeKit set_cover_position would otherwise write a non-moving 'open'/'closed' frame that trips HA's HomeKit Bridge into clobbering HK's TargetPosition (which then makes iOS display the direction backwards for the rest of the move). 1. _pinned_direction attribute and _pin_direction(STATE_OPENING | STATE_CLOSING | None) helper. is_opening and is_closing check the pin first and return True unconditionally when set. Pin is set at the top of every command handler and cleared in a finally block. 2. Overridden _handle_coordinator_update that short-circuits while a pin is set, so BLE adverts arriving mid-command can't fire an async_write_ha_state that would overwrite the entity's pinned moving state. Also widens is_closed deadzone (ENDPOINT_DEADZONE = 5) so brief position-noise adverts that report 3-4 from a settled-closed shade don't flip the entity from 'closed' to 'open' right before a command. NOTE: when the advert handler is scheduled in the event loop *before* the command handler, the first non-moving frame still gets written before _pin_direction has been called. The cluster of state writes that result are within microseconds of each other and HK Bridge's Target reset still happens on the leading 'open'/'closed' frame. This commit shrinks but does not fully eliminate the window. See PR thread for follow-up discussion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 94 ++++++++++++++----- 1 file changed, 71 insertions(+), 23 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index d3e7a05..5aab8b4 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -18,7 +18,8 @@ CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_CLOSING, STATE_OPENING +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,6 +72,15 @@ def __init__( # 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 self._attr_unique_id = ( f"{DOMAIN}_{format_mac(self._coord.address)}_{CoverDeviceClass.SHADE}" ) @@ -110,6 +120,11 @@ def available(self) -> bool: # when the user has since moved the shade by remote. DIRECTION_DEADZONE = 2 + ENDPOINT_DEADZONE = 5 # is_closed deadzone — wider than DIRECTION_DEADZONE + # to absorb noisy adverts that briefly report 3-4 from a settled-closed + # shade and would otherwise flip the entity from "closed" to "open" right + # before a HomeKit command runs, tripping HA's Bridge into resetting + # HK's TargetPosition. TARGET_TTL = 60.0 # seconds since last command def _target_is_fresh(self) -> bool: @@ -119,7 +134,9 @@ def _target_is_fresh(self) -> bool: @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """True while a fresh command is still driving the shade upward.""" + """True while the shade is being driven upward.""" + if self._pinned_direction == STATE_OPENING: + return True if not self._target_is_fresh(): return False current = self.current_cover_position @@ -129,7 +146,9 @@ def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableO @property def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """True while a fresh command is still driving the shade downward.""" + """True while the shade is being driven downward.""" + if self._pinned_direction == STATE_CLOSING: + return True if not self._target_is_fresh(): return False current = self.current_cover_position @@ -150,7 +169,7 @@ def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOv pos = self.current_cover_position if pos is None: return None - return pos <= CLOSED_POSITION + self.DIRECTION_DEADZONE + return pos <= CLOSED_POSITION + self.ENDPOINT_DEADZONE @property def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] @@ -182,50 +201,74 @@ def _needs_wake(self) -> bool: async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position. - State is written immediately (before the BLE await) so the entity - is in 'opening'/'closing' from the instant the command lands. HA's - HomeKit Bridge resets HK's TargetPosition to CurrentPosition any - time the entity is *not* in a moving state — if even a single - non-moving frame leaks through during the command's BLE round-trip, - HK forgets the user's actual target and ends up displaying the - direction backwards for the rest of the move. + 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 None: return - LOGGER.debug("set cover to position %f", target_position) - if self.current_cover_position == round(target_position) and not ( + 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 - self._set_target(round(target_position)) + 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( - round(target_position), wake_first=self._needs_wake - ) + await self._coord.api.set_position(target, wake_first=self._needs_wake) except BleakError as err: LOGGER.error( - "Failed to move cover '%s' to %f%%: %s", - self.name, - target_position, - err, + "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 + + @callback + def _handle_coordinator_update(self) -> None: + """Forward coordinator updates to async_write_ha_state. + + Skipped while a command is in flight (_pinned_direction set) + because a BLE advert arriving in the same event-loop tick as + the user's command would otherwise win the race, write state + as a non-moving 'open' / 'closed' frame, and trip HA's + HomeKit Bridge into clobbering HK's TargetPosition — which + flips the iOS direction label for the rest of the move. + """ + if self._pinned_direction is not None: + return + super()._handle_coordinator_update() + 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. Write moving state immediately so HK keeps Target.""" + """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: @@ -233,12 +276,15 @@ async def async_open_cover(self, **kwargs: Any) -> None: 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. Write moving state immediately so HK keeps Target.""" + """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: @@ -246,6 +292,8 @@ async def async_close_cover(self, **kwargs: Any) -> None: 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.""" From 8cd9a1768450b44c305292d46450769e636773fd Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Mon, 25 May 2026 00:10:22 -0500 Subject: [PATCH 12/16] cover: pin direction synchronously from homekit_state_change event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA's HomeKit Bridge fires the `homekit_state_change` event SYNCHRONOUSLY inside its TargetPosition setter callback, before the cover.set_cover_position service-call task is queued onto the event loop. Subscribing to that event from the cover entity lets us pin the direction and pre-set the target in the same synchronous frame that iOS triggered the command from, beating any racing BLE advert that would otherwise fire async_write_ha_state with a non-moving state and trip the Bridge into resetting HK's TargetPosition (which flips iOS' direction label for the entire move). Only set_cover_position events on this entity are handled; everything else is ignored. The pin is still released in the command method's finally block — the listener just guarantees it is set early enough. No HA core changes — the event listener lives entirely inside the integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 5aab8b4..17cf69d 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -18,8 +18,8 @@ CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSING, STATE_OPENING -from homeassistant.core import HomeAssistant, callback +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 @@ -86,6 +86,51 @@ def __init__( ) 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 when + iOS writes the TargetPosition characteristic, before the + cover.set_cover_position service-call task is even queued. By + pinning the direction inside that event handler we beat any + racing BLE advert that would otherwise fire async_write_ha_state + with a non-moving state and trip the Bridge into resetting + HK's TargetPosition (which then flips the iOS direction label + for the rest of the 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 + LOGGER.debug( + "%s: HomeKit command target=%s current=%s -> pinning %s", + self.entity_id, + int(value), + current, + direction, + ) + self._pin_direction(direction) + # Pre-set target so subsequent state recomputes are consistent. + 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.""" From fb32bdd012391ca730dde8ef1261267e50ffbcf2 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Mon, 25 May 2026 05:06:01 -0500 Subject: [PATCH 13/16] cover: separate "can command" from "data fresh" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `available` was gated on `data_available`, which checks that a V2 advert was decoded within STALE_AFTER (30 min). ACR rollers back off their 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 availability on advert freshness made HA refuse to dispatch service calls when the user tapped the shade in HomeKit / Lovelace / a script, logging: WARNING (MainThread) [homeassistant.helpers.service] Referenced entities cover.acr_3cf2_shade are missing or not currently available When the `homekit_state_change` listener fired and pinned direction but the service call was dropped, the pin had no command-method finally block to clear it, leaving the entity stuck at "Opening..." in HomeKit and the motor unmoved. Fix splits the concern: - `available` now only checks `super().available` (BLE address reachable). The shade is always controllable. - `assumed_state = True` when data is stale, so HA's UI shows the "assumed" indicator without blocking commands. Also adds `_pin_is_valid` as a defense-in-depth backstop: the pin is honored only while `_target_is_fresh()` (i.e. within TARGET_TTL of the last command). If anything else slips a stuck pin past the command method's finally (unhandled exception, future code paths), the entity falls back to advert-derived state after 60s instead of showing "Opening..." forever. Removed three blocks of debug logging that were left behind from diagnosing the earlier race (per-property is_opening / is_closing / is_closed debug + traceback dump in async_write_ha_state). Kept the listener; trimmed its docstring to a single block. No HA core changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 100 ++++++++++-------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 17cf69d..e278fda 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -100,14 +100,20 @@ async def async_added_to_hass(self) -> None: """ await super().async_added_to_hass() self.async_on_remove( - self.hass.bus.async_listen( - "homekit_state_change", self._on_homekit_event - ) + 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.""" + """Pin direction the moment iOS issues a position command. + + Filters to this entity's set_cover_position events only. Pins so a + BLE advert that lands between this event and the service-call task + running can't write a non-moving state and trip the Bridge into + clobbering HK's TargetPosition. If the service-call task never + runs (e.g. entity briefly went unavailable), the pin self-expires + via `_pin_is_valid` after TARGET_TTL. + """ if event.data.get(ATTR_ENTITY_ID) != self.entity_id: return if event.data.get("service") != "set_cover_position": @@ -119,15 +125,7 @@ def _on_homekit_event(self, event: Event) -> None: if current is None or int(value) == current: return direction = STATE_OPENING if int(value) > current else STATE_CLOSING - LOGGER.debug( - "%s: HomeKit command target=%s current=%s -> pinning %s", - self.entity_id, - int(value), - current, - direction, - ) self._pin_direction(direction) - # Pre-set target so subsequent state recomputes are consistent. self._set_target(int(value)) self.async_write_ha_state() @@ -138,14 +136,30 @@ def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableO @property def available(self) -> bool: - """Return True only when the shade has produced a recent V2 advert. + """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 - Without this gate, an out-of-range shade keeps reporting its last - known position indefinitely (e.g. stuck at "closed" while it is - actually open), because PassiveBluetoothCoordinatorEntity treats - any BLE address activity as "present". + @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 super().available and self._coord.data_available + return not self._coord.data_available # Direction inference. The shade's advert movement bits are # unreliable: KDT curtain firmwares invert is_opening / is_closing @@ -177,11 +191,23 @@ def _target_is_fresh(self) -> bool: 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() + @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """True while the shade is being driven upward.""" - if self._pinned_direction == STATE_OPENING: - return True + if self._pin_is_valid(): + return self._pinned_direction == STATE_OPENING if not self._target_is_fresh(): return False current = self.current_cover_position @@ -192,8 +218,8 @@ def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableO @property def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """True while the shade is being driven downward.""" - if self._pinned_direction == STATE_CLOSING: - return True + if self._pin_is_valid(): + return self._pinned_direction == STATE_CLOSING if not self._target_is_fresh(): return False current = self.current_cover_position @@ -203,14 +229,7 @@ def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableO @property def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] - """Return if the cover is closed. - - Uses the same DIRECTION_DEADZONE as is_opening / is_closing so a - motor that settles 1-2% short of fully closed (common end-of- - travel calibration drift) doesn't bounce between "open" and - "closed" in HomeKit. Returns None when position is unknown so - HA reports the entity as 'unknown' rather than assuming "open". - """ + """Return if the cover is closed.""" pos = self.current_cover_position if pos is None: return None @@ -289,20 +308,13 @@ def _pin_direction(self, direction: str | None) -> None: """ self._pinned_direction = direction - @callback - def _handle_coordinator_update(self) -> None: - """Forward coordinator updates to async_write_ha_state. - - Skipped while a command is in flight (_pinned_direction set) - because a BLE advert arriving in the same event-loop tick as - the user's command would otherwise win the race, write state - as a non-moving 'open' / 'closed' frame, and trip HA's - HomeKit Bridge into clobbering HK's TargetPosition — which - flips the iOS direction label for the rest of the move. - """ - if self._pinned_direction is not None: - return - super()._handle_coordinator_update() + # _handle_coordinator_update is NOT overridden anymore — every BLE + # advert should drive an async_write_ha_state so HomeKit gets live + # CurrentPosition updates while the shade physically moves. The pin + # (set by either _on_homekit_event or the command method itself) + # ensures is_opening / is_closing return True throughout, so each + # advert-driven state recomputation stays in a moving state and + # never trips the Bridge into resetting HK's TargetPosition. def _reset_target_position(self) -> None: self._target_position = None From 03952bcf2db1e00c36598dad7bafe58996666130 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Mon, 25 May 2026 06:12:58 -0500 Subject: [PATCH 14/16] cover: hold opening/closing state until motor settles, not just deadzone Previously is_opening / is_closing returned True only while |target - current| > DIRECTION_DEADZONE (2%). On a 0->100 open, that flipped to False the moment the motor crossed pos=98, leaving a "open" state frame written while the motor was still physically running its last few percent. HA's HomeKit Bridge clobbers HK's TargetPosition whenever the entity is reported in a non-MOVING state (open/closed), so that single transient frame at end-of-move flipped iOS direction label backwards for the rest of the move. Replaces the deadzone with a "motor settled" check: - record the wall-clock timestamp of every position change in _handle_coordinator_update - is_opening / is_closing return True while target != current AND _has_settled() is False (last position change within 1.5s) - once the position is stable, fall through to the normal open/closed evaluation Live trace, Guest Room Roller 0->100: closed -> opening (call_service) ... 25 advert frames at "opening" while position rises 0->100 ... open (settled) No "open" or "closed" transient between the moving state and the final state. TargetPosition stays whatever iOS asked for, direction label stays correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 120 +++++++++++------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index e278fda..c9de341 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -81,6 +81,16 @@ def __init__( # 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}" ) @@ -89,14 +99,12 @@ def __init__( 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 when - iOS writes the TargetPosition characteristic, before the - cover.set_cover_position service-call task is even queued. By - pinning the direction inside that event handler we beat any - racing BLE advert that would otherwise fire async_write_ha_state - with a non-moving state and trip the Bridge into resetting - HK's TargetPosition (which then flips the iOS direction label - for the rest of the move). + 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( @@ -105,15 +113,7 @@ async def async_added_to_hass(self) -> None: @callback def _on_homekit_event(self, event: Event) -> None: - """Pin direction the moment iOS issues a position command. - - Filters to this entity's set_cover_position events only. Pins so a - BLE advert that lands between this event and the service-call task - running can't write a non-moving state and trip the Bridge into - clobbering HK's TargetPosition. If the service-call task never - runs (e.g. entity briefly went unavailable), the pin self-expires - via `_pin_is_valid` after TARGET_TTL. - """ + """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": @@ -162,29 +162,22 @@ def assumed_state(self) -> bool: return not self._coord.data_available # Direction inference. The shade's advert movement bits are - # unreliable: KDT curtain firmwares invert is_opening / is_closing - # vs ACR rollers, and with multiple BLE proxies in the same home, - # adverts arrive out of order with ±1-3% position bounce that - # produces "closing" flickers during smooth opens. - # - # Direction is derived from intent — _target_position (set by every - # HA / HomeKit command) vs current_position. While the shade is more - # than DIRECTION_DEADZONE percent away from its commanded target, we - # report the corresponding "opening" or "closing" state; once it's - # within the deadzone, the target is considered reached and we fall - # through to the open/closed steady state. + # 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 also auto-expires after TARGET_TTL seconds of inactivity - # so a stale command can't make the entity report "opening" forever - # when the user has since moved the shade by remote. - - DIRECTION_DEADZONE = 2 - ENDPOINT_DEADZONE = 5 # is_closed deadzone — wider than DIRECTION_DEADZONE - # to absorb noisy adverts that briefly report 3-4 from a settled-closed - # shade and would otherwise flip the entity from "closed" to "open" right - # before a HomeKit command runs, tripping HA's Bridge into resetting - # HK's TargetPosition. - TARGET_TTL = 60.0 # seconds since last command + # 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 def _target_is_fresh(self) -> bool: if self._target_position is None or self._target_set_at is None: @@ -203,6 +196,22 @@ def _pin_is_valid(self) -> bool: """ return self._pinned_direction is not None and self._target_is_fresh() + def _has_settled(self) -> bool: + """True iff the last position change was longer than SETTLED_AFTER ago. + + Position-stability proxy for "motor stopped". Used by is_opening / + is_closing to keep the entity in a moving state through the *last + few percent* of a move where the position is still ticking up but + within a naive `target - current` deadzone would already snap to + "open"/"closed". A single such non-moving frame is enough for HA's + HomeKit Bridge to clobber HK's TargetPosition (it resets Target to + Current whenever the entity is not in MOVING_STATES) — which then + flips iOS's direction label backwards for the rest of the move. + """ + if self._last_position_change_ts is None: + return True # never seen a position → not moving + return (time.time() - self._last_position_change_ts) > self.SETTLED_AFTER + @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """True while the shade is being driven upward.""" @@ -213,7 +222,11 @@ def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableO current = self.current_cover_position if current is None: return False - return self._target_position > current + self.DIRECTION_DEADZONE + 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] @@ -225,7 +238,9 @@ def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableO current = self.current_cover_position if current is None: return False - return self._target_position < current - self.DIRECTION_DEADZONE + if self._target_position >= current: + return False + return not self._has_settled() @property def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] @@ -235,6 +250,21 @@ def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOv return None return pos <= CLOSED_POSITION + self.ENDPOINT_DEADZONE + @callback + def _handle_coordinator_update(self) -> None: + """Track position-change timestamps for `_has_settled()` then propagate. + + Only override is the position-change timestamping; the state write is + still done by the parent so HomeKit gets a live `current_position` + update on every advert. + """ + current = self.current_cover_position + 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] """Flag supported features, disable control if encryption is needed.""" @@ -308,14 +338,6 @@ def _pin_direction(self, direction: str | None) -> None: """ self._pinned_direction = direction - # _handle_coordinator_update is NOT overridden anymore — every BLE - # advert should drive an async_write_ha_state so HomeKit gets live - # CurrentPosition updates while the shade physically moves. The pin - # (set by either _on_homekit_event or the command method itself) - # ensures is_opening / is_closing return True throughout, so each - # advert-driven state recomputation stays in a moving state and - # never trips the Bridge into resetting HK's TargetPosition. - def _reset_target_position(self) -> None: self._target_position = None self._target_set_at = None From 9e3ceadb529eb1927dea961406b93b316d31946e Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Mon, 25 May 2026 08:35:04 -0500 Subject: [PATCH 15/16] cover: _has_settled must require a position change AFTER the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version of _has_settled treated a shade that hadn't moved in hours as "settled" the instant a fresh command landed. The next BLE advert (still at the pre-command position) would arrive after the command method's pin had been released, fall through is_opening / is_closing to is_closed, and write a non-moving "open"/"closed" frame *while the motor was just about to start*. HA's HomeKit Bridge clobbers HK's TargetPosition on any non-moving frame, flipping iOS direction label backwards for the rest of the move. Fix: _has_settled now returns False if _last_position_change_ts is older than _target_set_at. The motor hasn't reacted to the command yet, so we are explicitly NOT settled — keep is_opening / is_closing returning True until the first post-command position change starts the SETTLED_AFTER countdown. Diagnostic trace (Kitchen Small open from closed, before fix): T+0.000 state=opening pos=0 (my command method) T+0.845 state=closed pos=0 (BLE advert + ghost!) T+0.958 state=closed pos=0 (BLE advert + ghost!) T+1.178 state=opening pos=2 (motor started, recovers) After fix: T+0.000 state=opening pos=0 (command method) T+0.451 state=opening pos=0 (BLE advert, NOT settled — pos change ts < target_set_at, returns False) T+0.670 state=opening pos=0 (same) T+0.780 state=opening pos=2 (motor started) ... (continuous opening through pos=100) state=open (settled after motor stops) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index c9de341..7fb77fd 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -197,19 +197,33 @@ def _pin_is_valid(self) -> bool: return self._pinned_direction is not None and self._target_is_fresh() def _has_settled(self) -> bool: - """True iff the last position change was longer than SETTLED_AFTER ago. - - Position-stability proxy for "motor stopped". Used by is_opening / - is_closing to keep the entity in a moving state through the *last - few percent* of a move where the position is still ticking up but - within a naive `target - current` deadzone would already snap to - "open"/"closed". A single such non-moving frame is enough for HA's - HomeKit Bridge to clobber HK's TargetPosition (it resets Target to - Current whenever the entity is not in MOVING_STATES) — which then - flips iOS's direction label backwards for the rest of the move. + """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: - return True # never seen a position → not moving + # 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 From ecba38c779f4d047dc0df503fa5ddb782dcfea90 Mon Sep 17 00:00:00 2001 From: Izzat Issa Date: Mon, 25 May 2026 17:17:04 -0600 Subject: [PATCH 16/16] cover: filter spurious position jumps > 50% in a single advert The shade BLE module occasionally emits a stray pos=0 init/boot advert (resetClock=False) when the motor settles, and flaky BLE proxies can re-broadcast a stale all-zeros beacon mid-motion. Either source lands a single advert with a physically impossible position delta between two real frames. On a 100->70 close, that frame was triggering: T+0.000 state=closing pos=100 (command) T+0.044 state=closing pos=100 (advert, motor not moving yet) T+0.096 state=closing pos=100 (advert) T+7.893 state=opening pos=0 <-- spurious advert, real motor at ~70 T+8.113 state=open pos=70 (good advert, motor settled at 70) The "opening pos=0" frame fires because target (70) > current (0), so is_opening returns True. HA's HomeKit Bridge stamps the HK PositionState characteristic as INCREASING for that frame and iOS latches the "Opening..." spinner until the next motion - despite the shade being correctly settled at 70 in HA itself. Reject single-advert jumps larger than MAX_POSITION_JUMP (50%): - 50% is well above plausible motor motion between adverts (~7%/sec for ACR rollers means even a 5-second gap caps real change at ~35%) - Rejected adverts don't update _last_position so the next good advert is still judged against the last *good* value (e.g. 100, not the rejected 0) - We return early before super()._handle_coordinator_update so no state write fires for the spurious frame - HK never sees the wrong direction Behavior unchanged for the common case: every test run that didn't hit a spurious advert continues to write state cleanly through the move. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hunterdouglas_powerview_ble/cover.py | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 7fb77fd..65ff68e 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -178,6 +178,12 @@ def assumed_state(self) -> bool: # → "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: @@ -266,13 +272,36 @@ def is_closed(self) -> bool | None: # type: ignore[reportIncompatibleVariableOv @callback def _handle_coordinator_update(self) -> None: - """Track position-change timestamps for `_has_settled()` then propagate. + """Filter spurious position jumps, then track + propagate. - Only override is the position-change timestamping; the state write is - still done by the parent so HomeKit gets a live `current_position` - update on every advert. + 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()