From 31185a4446a8cc7db0c59988b76d8997f4cbd753 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 09:07:16 +1000 Subject: [PATCH 01/37] Improve config flow UX for multi-shade setups Reuse the home key from already-configured shades so adding subsequent shades skips the key step. Show human-readable shade names from the hub in the device picker. Allow selecting multiple shades at once instead of repeating the flow for each one. Default to hub fetch as the key method. --- .../hunterdouglas_powerview_ble/api.py | 5 + .../config_flow.py | 430 ++++++++++++++++-- .../hunterdouglas_powerview_ble/const.py | 5 +- .../coordinator.py | 6 +- .../hunterdouglas_powerview_ble/cover.py | 4 +- .../hunterdouglas_powerview_ble/manifest.json | 2 +- .../hunterdouglas_powerview_ble/strings.json | 49 ++ .../translations/en.json | 59 ++- scripts/extract_gateway3_homekey.py | 25 +- 9 files changed, 539 insertions(+), 46 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5a6cc97..5feda53 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -134,6 +134,11 @@ def encrypted(self) -> bool: def encrypted(self, value: bool) -> None: self._is_encrypted = value + @property + def has_key(self) -> bool: + """Return True if a valid homekey was provided.""" + return self._cipher is not None + @property def info(self) -> PVDeviceInfo: """Return device information, e.g. SW version.""" diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 7a38b35..fa5a21c 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -1,8 +1,12 @@ """Config flow for BLE Battery Management System integration.""" +import asyncio +import base64 +import struct from dataclasses import dataclass from typing import Any +import aiohttp import voluptuous as vol from homeassistant import config_entries @@ -12,21 +16,121 @@ ) from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from .api import UUID_COV_SERVICE as UUID -from .const import DOMAIN, LOGGER, MFCT_ID +from .const import CONF_HOME_KEY, DOMAIN, LOGGER, MFCT_ID + + +def _needs_encryption(manufacturer_data_hex: str) -> bool: + """Return True if the BLE advertisement indicates encryption (home_id != 0).""" + data = bytearray.fromhex(manufacturer_data_hex) + if len(data) < 2: + return False + home_id = int.from_bytes(data[0:2], byteorder="little") + return home_id != 0 + + +@dataclass +class HubShadeInfo: + """Shade metadata from the PowerView hub.""" + + name: str # Human-readable name (decoded from base64) + ble_name: str # BLE advertisement name, e.g. "DUE:94ED" + + +async def _fetch_key_and_shades_from_hub( + hass: HomeAssistant, hub_url: str +) -> tuple[bytes, list[HubShadeInfo]]: + """Fetch 16-byte homekey and shade list from a PowerView G3 hub. + + Returns (key, shade_list). The key is network-wide so any reachable shade + returns the same value. The shade list contains human-readable names that + can be used to label BLE-discovered devices. + + Raises ValueError on protocol/key errors. + Raises aiohttp.ClientError on network errors. + Raises asyncio.TimeoutError on timeout. + """ + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=10) + + # Get list of shades from hub + async with session.get(f"{hub_url}/home/shades", timeout=timeout) as resp: + resp.raise_for_status() + shades = await resp.json(content_type=None) + + if not shades: + raise ValueError("No shades found on the hub") + + # Parse shade metadata (name is base64-encoded on the hub) + hub_shades: list[HubShadeInfo] = [] + for shade in shades: + ble_name = shade.get("bleName", "") + if not ble_name: + continue + name_b64 = shade.get("name", "") + try: + name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name + except Exception: # noqa: BLE001 + name = ble_name + hub_shades.append(HubShadeInfo(name=name, ble_name=ble_name)) + + # GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0 + request_frame = struct.pack(" None: self._discovered_device: ConfigFlow.DiscoveredDevice | None = None self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} + self._manufacturer_data_hex: str = "" + self._device_name: str = "" + self._home_key: str = "" + self._hub_shades: list[HubShadeInfo] = [] + + def _create_entry(self) -> ConfigFlowResult: + """Create the config entry with collected data.""" + return self.async_create_entry( + title=self._device_name, + data={ + "manufacturer_data": self._manufacturer_data_hex, + CONF_HOME_KEY: self._home_key, + }, + ) async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -64,14 +182,16 @@ async def async_step_bluetooth_confirm( 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, - data={ - "manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[ - MFCT_ID - ].hex() - }, + self._manufacturer_data_hex = ( + self._discovered_device.discovery_info.manufacturer_data[MFCT_ID].hex() ) + self._device_name = self._discovered_device.name + + # Unencrypted shades can skip the homekey step entirely + if not _needs_encryption(self._manufacturer_data_hex): + return self._create_entry() + + return await self.async_step_homekey_bluetooth() self._set_confirm_only() @@ -80,28 +200,167 @@ async def async_step_bluetooth_confirm( description_placeholders={"name": self._discovered_device.name}, ) + async def async_step_homekey_bluetooth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure homekey for a shade discovered via Bluetooth.""" + # Reuse an existing key if another shade was already configured + existing = self._existing_home_key() + if existing and user_input is None: + self._home_key = existing + return self._create_entry() + + errors: dict[str, str] = {} + + if user_input is not None: + method = user_input.get("key_method", "skip") + + if method == "skip": + self._home_key = "" + return self._create_entry() + + elif method == "manual": + raw = user_input.get("home_key", "").strip() + if "\\x" in raw: + raw = raw.replace("\\x", "") + if len(raw) != 32: + errors["home_key"] = "invalid_key_length" + else: + try: + bytes.fromhex(raw) + except ValueError: + errors["home_key"] = "invalid_key_format" + else: + self._home_key = raw.lower() + return self._create_entry() + + elif method == "hub": + hub_url = user_input.get("hub_url", "").rstrip("/") + try: + key, hub_shades = await _fetch_key_and_shades_from_hub( + self.hass, hub_url + ) + self._home_key = key.hex() + # Use hub name for the entry title if available + for hs in hub_shades: + if hs.ble_name == self._device_name: + self._device_name = hs.name + break + return self._create_entry() + except aiohttp.ClientResponseError: + errors["hub_url"] = "hub_http_error" + except aiohttp.ClientConnectionError: + errors["hub_url"] = "hub_connection_error" + except (asyncio.TimeoutError, TimeoutError): + errors["hub_url"] = "hub_timeout" + except ValueError: + errors["hub_url"] = "hub_protocol_error" + + return self.async_show_form( + step_id="homekey_bluetooth", + data_schema=vol.Schema( + { + vol.Required("key_method", default="hub"): SelectSelector( + SelectSelectorConfig( + options=[ + { + "value": "hub", + "label": "Fetch automatically from PowerView hub", + }, + { + "value": "manual", + "label": "Enter key manually (32 hex characters)", + }, + { + "value": "skip", + "label": "Skip (no key — controls disabled for encrypted shades)", + }, + ] + ) + ), + vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional("home_key", default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + } + ), + errors=errors, + description_placeholders={"name": self._device_name}, + ) + + def _existing_home_key(self) -> str: + """Return the home_key from any already-configured entry, or ''.""" + for entry in self._async_current_entries(): + key = entry.data.get(CONF_HOME_KEY, "") + if key: + return key + return "" + + def _hub_name_for(self, ble_name: str) -> str | None: + """Return the human-readable hub name for a BLE name, or None.""" + for hs in self._hub_shades: + if hs.ble_name == ble_name: + return hs.name + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" + """Handle the user step — reuse existing key or collect one.""" LOGGER.debug("user step") + existing = self._existing_home_key() + if existing: + self._home_key = existing + return await self.async_step_select_device() + return await self.async_step_homekey() + + async def async_step_select_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Select one or more BLE-discovered shades, or fall through to manual.""" + LOGGER.debug("select_device step") 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] + addresses: list[str] = user_input[CONF_ADDRESS] + if isinstance(addresses, str): + addresses = [addresses] - self.context["title_placeholders"] = {"name": self._discovered_device.name} + # Build entry info for every selected shade + entries: list[dict[str, Any]] = [] + for address in addresses: + device = self._discovered_devices[address] + ble_name = device.name + name = self._hub_name_for(ble_name) or ble_name + mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() + entries.append( + { + "address": address, + "name": name, + "data": { + "manufacturer_data": mfct_hex, + CONF_HOME_KEY: self._home_key, + }, + } + ) - return self.async_create_entry( - title=self._discovered_device.name, - data={ - "manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[ - MFCT_ID - ].hex() - }, - ) + # Kick off auto-add flows for all but the last shade + for info in entries[:-1]: + await self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "auto_add"}, + data=info, + ) + + # Create the final entry normally (ends this flow) + last = entries[-1] + await self.async_set_unique_id(last["address"], raise_on_progress=False) + self._abort_if_unique_id_configured() + self._device_name = last["name"] + self._manufacturer_data_hex = last["data"]["manufacturer_data"] + self.context["title_placeholders"] = {"name": self._device_name} + return self._create_entry() current_addresses = self._async_current_ids() for discovery_info in async_discovered_service_info(self.hass, False): @@ -120,19 +379,136 @@ async def async_step_user( ) if not self._discovered_devices: - return self.async_abort(reason="no_devices_found") + return await self.async_step_manual() titles: list[SelectOptionDict] = [] for address, discovery in self._discovered_devices.items(): - titles.append({"value": address, "label": discovery.name}) + hub_name = self._hub_name_for(discovery.name) + label = f"{hub_name} ({discovery.name})" if hub_name else discovery.name + titles.append({"value": address, "label": label}) return self.async_show_form( - step_id="user", + step_id="select_device", data_schema=vol.Schema( { vol.Required(CONF_ADDRESS): SelectSelector( - SelectSelectorConfig(options=titles) + SelectSelectorConfig(options=titles, multiple=True) + ) + } + ), + ) + + async def async_step_auto_add( + self, data: dict[str, Any] + ) -> ConfigFlowResult: + """Create a config entry for a shade selected via multi-select.""" + await self.async_set_unique_id(data["address"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=data["name"], data=data["data"]) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual entry of a BLE device address and name.""" + if user_input is not None: + address = user_input[CONF_ADDRESS].upper().strip() + self._device_name = user_input["ble_name"].strip() + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self.context["title_placeholders"] = {"name": self._device_name} + self._manufacturer_data_hex = "" + return self._create_entry() + + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Required("ble_name"): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + } + ), + ) + + async def async_step_homekey( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure homekey — collected before device selection.""" + errors: dict[str, str] = {} + + if user_input is not None: + method = user_input.get("key_method", "skip") + + if method == "skip": + self._home_key = "" + return await self.async_step_select_device() + + elif method == "manual": + raw = user_input.get("home_key", "").strip() + # Accept \xNN\xNN... format (e.g. from ESP32 emulator serial log) + if "\\x" in raw: + raw = raw.replace("\\x", "") + if len(raw) != 32: + errors["home_key"] = "invalid_key_length" + else: + try: + bytes.fromhex(raw) + except ValueError: + errors["home_key"] = "invalid_key_format" + else: + self._home_key = raw.lower() + return await self.async_step_select_device() + + elif method == "hub": + hub_url = user_input.get("hub_url", "").rstrip("/") + try: + key, hub_shades = await _fetch_key_and_shades_from_hub( + self.hass, hub_url ) + self._home_key = key.hex() + self._hub_shades = hub_shades + return await self.async_step_select_device() + except aiohttp.ClientResponseError: + errors["hub_url"] = "hub_http_error" + except aiohttp.ClientConnectionError: + errors["hub_url"] = "hub_connection_error" + except (asyncio.TimeoutError, TimeoutError): + errors["hub_url"] = "hub_timeout" + except ValueError: + errors["hub_url"] = "hub_protocol_error" + + return self.async_show_form( + step_id="homekey", + data_schema=vol.Schema( + { + vol.Required("key_method", default="hub"): SelectSelector( + SelectSelectorConfig( + options=[ + { + "value": "hub", + "label": "Fetch automatically from PowerView hub", + }, + { + "value": "manual", + "label": "Enter key manually (32 hex characters)", + }, + { + "value": "skip", + "label": "Skip (no key — controls disabled for encrypted shades)", + }, + ] + ) + ), + vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional("home_key", default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), } ), + errors=errors, ) diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index d723594..0e02520 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -8,10 +8,7 @@ MFCT_ID: Final[int] = 2073 TIMEOUT: Final[int] = 5 -# 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" -HOME_KEY: Final[bytes] = b"" - +CONF_HOME_KEY: Final[str] = "home_key" # attributes (do not change) ATTR_RSSI: Final[str] = "rssi" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 68883cc..ddcd089 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from .api import SHADE_TYPE, PowerViewBLE -from .const import ATTR_RSSI, DOMAIN, HOME_KEY, LOGGER +from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -25,7 +25,9 @@ def __init__( """Initialize BMS data coordinator.""" assert ble_device.name is not None self._mac = ble_device.address - self.api = PowerViewBLE(ble_device, HOME_KEY) + home_key_hex: str = data.get(CONF_HOME_KEY, "") + home_key: bytes = bytes.fromhex(home_key_hex) if len(home_key_hex) == 32 else b"" + self.api = PowerViewBLE(ble_device, home_key) self.data: dict[str, int | float | bool] = {} self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 4c25bf5..9cfe6bd 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import CLOSED_POSITION, OPEN_POSITION -from .const import DOMAIN, HOME_KEY, LOGGER +from .const import DOMAIN, LOGGER from .coordinator import PVCoordinator @@ -107,7 +107,7 @@ def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] """Flag supported features, disable control if encryption is needed.""" if ( - self._coord.data.get("home_id") and len(HOME_KEY) != 16 + self._coord.data.get("home_id") and not self._coord.api.has_key ) or self._coord.data.get("battery_charging"): return CoverEntityFeature(0) diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index eeab944..47a475c 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -16,5 +16,5 @@ "issue_tracker": "https://github.com/patman15/hdpv_ble/issues", "loggers": ["hunterdouglas_powerview_ble"], "requirements": ["cryptography>=43.0.0"], - "version": "0.23" + "version": "0.24" } diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 19601be..aa66787 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -4,8 +4,57 @@ "step": { "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "homekey": { + "title": "Configure HomeKey", + "description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } + }, + "homekey_bluetooth": { + "title": "Configure HomeKey for {name}", + "description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } + }, + "select_device": { + "title": "Select Shades", + "description": "Select the PowerView shades to add via Bluetooth.", + "data": { + "address": "Shades" + } + }, + "manual": { + "title": "Enter Device Details", + "description": "No PowerView shades were found via Bluetooth. Enter the device details manually.", + "data": { + "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", + "ble_name": "BLE device name (e.g. DUE:94ED)" + } } }, + "error": { + "invalid_key_format": "HomeKey must be a valid hexadecimal string", + "invalid_key_length": "HomeKey must be exactly 32 hex characters (16 bytes)", + "hub_connection_error": "Cannot connect to the PowerView hub", + "hub_http_error": "Hub returned an HTTP error", + "hub_timeout": "Connection to hub timed out", + "hub_protocol_error": "Hub returned an unexpected response" + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 528686d..101a408 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -1,15 +1,64 @@ { "config": { - "abort": { - "already_configured": "Device is already configured", - "no_devices_found": "No devices found on the network", - "not_supported": "Device not supported" - }, "flow_title": "Setup {name}", "step": { "bluetooth_confirm": { "description": "Do you want to set up {name}?" + }, + "homekey": { + "title": "Configure HomeKey", + "description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } + }, + "homekey_bluetooth": { + "title": "Configure HomeKey for {name}", + "description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } + }, + "select_device": { + "title": "Select Shades", + "description": "Select the PowerView shades to add via Bluetooth.", + "data": { + "address": "Shades" + } + }, + "manual": { + "title": "Enter Device Details", + "description": "No PowerView shades were found via Bluetooth. Enter the device details manually.", + "data": { + "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", + "ble_name": "BLE device name (e.g. DUE:94ED)" + } } + }, + "error": { + "invalid_key_format": "HomeKey must be a valid hexadecimal string", + "invalid_key_length": "HomeKey must be exactly 32 hex characters (16 bytes)", + "hub_connection_error": "Cannot connect to the PowerView hub", + "hub_http_error": "Hub returned an HTTP error", + "hub_timeout": "Connection to hub timed out", + "hub_protocol_error": "Hub returned an unexpected response" + }, + "abort": { + "already_configured": "Device is already configured", + "no_devices_found": "No devices found on the network", + "not_supported": "Device not supported" } } } diff --git a/scripts/extract_gateway3_homekey.py b/scripts/extract_gateway3_homekey.py index 0058c3d..f7b45bd 100644 --- a/scripts/extract_gateway3_homekey.py +++ b/scripts/extract_gateway3_homekey.py @@ -55,12 +55,13 @@ def get_shade_key(hub: str, ble_name) -> bytes: raise result: dict = json.loads(shades_exec_resp.content) - if result.get("err") != 0 or len(result.get("responses", [])) != 1: - raise OSError("Error when attempting GetShadeKey") - response: Final[bytes] = bytes.fromhex(result["responses"][0]["hex"]) + responses = result.get("responses", []) + if len(responses) != 1 or "hex" not in responses[0]: + raise OSError(f"Error when attempting GetShadeKey: {result}") + response: Final[bytes] = bytes.fromhex(responses[0]["hex"]) dec_resp: Final[dict[str, Any]] = decode_response(response) if dec_resp["errorCode"] != 0: - raise ValueError("BLE errorCode is not 0") + raise ValueError(f"BLE errorCode={dec_resp['errorCode']} data={dec_resp['data'].hex()}") if len(dec_resp["data"]) != 16: raise ValueError("Expected 16 byte homekey") return dec_resp["data"] @@ -79,9 +80,23 @@ def main(hub: str) -> int: shades = json.loads(shades_resp.content) print(f"Found {len(shades)} shades, interrogating") + network_key: bytes | None = None for shade in shades: name: str = base64.b64decode(shade["name"]).decode("utf-8") - key: bytes = get_shade_key(hub, shade["bleName"]) + try: + key: bytes = get_shade_key(hub, shade["bleName"]) + network_key = key + except (OSError, ValueError) as ex: + if network_key is not None: + key = network_key + print(f"Shade '{name}':") + print(f"\tBLE name: '{shade['bleName']}'") + print(f"\tHomeKey: {key.hex()} (shade unreachable, using network key)") + else: + print(f"Shade '{name}':") + print(f"\tBLE name: '{shade['bleName']}'") + print(f"\tHomeKey: ERROR - {ex}") + continue print(f"Shade '{name}':") print(f"\tBLE name: '{shade['bleName']}'") From 317b4507026ad979d0c34ce684f2f36877f671fd Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 11:51:01 +1000 Subject: [PATCH 02/37] fix: handle stale ble devices --- .../hunterdouglas_powerview_ble/api.py | 17 +++++++---------- .../hunterdouglas_powerview_ble/coordinator.py | 1 + 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5feda53..49aeb0a 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -97,18 +97,10 @@ class PowerViewBLE: def __init__(self, ble_device: BLEDevice, home_key: bytes = b"") -> None: """Initialize device API via Bluetooth.""" - self._ble_device: Final[BLEDevice] = ble_device + self._ble_device: BLEDevice = ble_device self.name: Final[str] = self._ble_device.name or "unknown" self._seqcnt: int = 1 - self._client: BleakClient = BleakClient( - self._ble_device, - disconnected_callback=self._on_disconnect, - services=[ - UUID_COV_SERVICE, - UUID_DEV_SERVICE, - # self.UUID_BAT_SERVICE, - ], - ) + self._client: BleakClient = BleakClient(self._ble_device) self._data_event = asyncio.Event() self._data: bytes = b"" self._info: PVDeviceInfo = PVDeviceInfo() @@ -125,6 +117,10 @@ async def _wait_event(self) -> None: await self._data_event.wait() self._data_event.clear() + def set_ble_device(self, ble_device: BLEDevice) -> None: + """Update the BLE device reference (e.g. when proxy details change).""" + self._ble_device = ble_device + @property def encrypted(self) -> bool: """Return whether communication with this shade is encrypted.""" @@ -360,6 +356,7 @@ async def _connect(self) -> None: self._ble_device, self.name, disconnected_callback=self._on_disconnect, + ble_device_callback=lambda: self._ble_device, services=[ UUID_COV_SERVICE, UUID_DEV_SERVICE, diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index ddcd089..ba621d4 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -99,6 +99,7 @@ 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.api.set_ble_device(service_info.device) self.data = {ATTR_RSSI: service_info.rssi} if change == bluetooth.BluetoothChange.ADVERTISEMENT: self.data.update( From 894580c20bd467d9289aa451c9c0a65af38904a1 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 12:17:19 +1000 Subject: [PATCH 03/37] fix: device and entity naming --- .../hunterdouglas_powerview_ble/__init__.py | 2 +- .../config_flow.py | 335 +++++++++--------- .../coordinator.py | 16 +- .../hunterdouglas_powerview_ble/cover.py | 2 +- 4 files changed, 175 insertions(+), 180 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index dd594f2..c66d7ec 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -42,7 +42,7 @@ 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()) + coordinator = PVCoordinator(hass, ble_device, entry.data.copy(), entry.title) try: await coordinator.query_dev_info() except BleakError as err: diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index fa5a21c..63a8f4f 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -48,31 +48,24 @@ class HubShadeInfo: ble_name: str # BLE advertisement name, e.g. "DUE:94ED" -async def _fetch_key_and_shades_from_hub( +async def _fetch_shades_from_hub( hass: HomeAssistant, hub_url: str -) -> tuple[bytes, list[HubShadeInfo]]: - """Fetch 16-byte homekey and shade list from a PowerView G3 hub. - - Returns (key, shade_list). The key is network-wide so any reachable shade - returns the same value. The shade list contains human-readable names that - can be used to label BLE-discovered devices. +) -> list[HubShadeInfo]: + """Fetch shade list with human-readable names from a PowerView G3 hub. - Raises ValueError on protocol/key errors. Raises aiohttp.ClientError on network errors. Raises asyncio.TimeoutError on timeout. """ session = async_get_clientsession(hass) timeout = aiohttp.ClientTimeout(total=10) - # Get list of shades from hub async with session.get(f"{hub_url}/home/shades", timeout=timeout) as resp: resp.raise_for_status() shades = await resp.json(content_type=None) if not shades: - raise ValueError("No shades found on the hub") + return [] - # Parse shade metadata (name is base64-encoded on the hub) hub_shades: list[HubShadeInfo] = [] for shade in shades: ble_name = shade.get("bleName", "") @@ -84,19 +77,38 @@ async def _fetch_key_and_shades_from_hub( except Exception: # noqa: BLE001 name = ble_name hub_shades.append(HubShadeInfo(name=name, ble_name=ble_name)) + return hub_shades + + +async def _fetch_key_and_shades_from_hub( + hass: HomeAssistant, hub_url: str +) -> tuple[bytes, list[HubShadeInfo]]: + """Fetch 16-byte homekey and shade list from a PowerView G3 hub. + + Returns (key, shade_list). The key is network-wide so any reachable shade + returns the same value. The shade list contains human-readable names that + can be used to label BLE-discovered devices. + + Raises ValueError on protocol/key errors. + Raises aiohttp.ClientError on network errors. + Raises asyncio.TimeoutError on timeout. + """ + hub_shades = await _fetch_shades_from_hub(hass, hub_url) + if not hub_shades: + raise ValueError("No shades found on the hub") + + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=10) # GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0 request_frame = struct.pack(" None: self._manufacturer_data_hex: str = "" self._device_name: str = "" self._home_key: str = "" + self._hub_url: str = "" self._hub_shades: list[HubShadeInfo] = [] def _create_entry(self) -> ConfigFlowResult: """Create the config entry with collected data.""" + data: dict[str, str] = { + "manufacturer_data": self._manufacturer_data_hex, + CONF_HOME_KEY: self._home_key, + } + if self._hub_url: + data["hub_url"] = self._hub_url return self.async_create_entry( title=self._device_name, - data={ - "manufacturer_data": self._manufacturer_data_hex, - CONF_HOME_KEY: self._home_key, - }, + data=data, ) + async def _validate_homekey_input( + self, user_input: dict[str, Any], errors: dict[str, str] + ) -> bool: + """Parse and validate homekey user_input, populating self state. + + Returns True on success, False on validation error (errors dict is populated). + On skip, self._home_key is set to "". + """ + method = user_input.get("key_method", "skip") + + if method == "skip": + self._home_key = "" + return True + + if method == "manual": + raw = user_input.get("home_key", "").strip() + if "\\x" in raw: + raw = raw.replace("\\x", "") + if len(raw) != 32: + errors["home_key"] = "invalid_key_length" + return False + try: + bytes.fromhex(raw) + except ValueError: + errors["home_key"] = "invalid_key_format" + return False + self._home_key = raw.lower() + return True + + if method == "hub": + hub_url = user_input.get("hub_url", "").rstrip("/") + try: + key, hub_shades = await _fetch_key_and_shades_from_hub( + self.hass, hub_url + ) + self._home_key = key.hex() + self._hub_url = hub_url + self._hub_shades = hub_shades + return True + except aiohttp.ClientResponseError: + errors["hub_url"] = "hub_http_error" + except aiohttp.ClientConnectionError: + errors["hub_url"] = "hub_connection_error" + except (asyncio.TimeoutError, TimeoutError): + errors["hub_url"] = "hub_timeout" + except ValueError: + errors["hub_url"] = "hub_protocol_error" + + return False + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -189,6 +285,7 @@ async def async_step_bluetooth_confirm( # Unencrypted shades can skip the homekey step entirely if not _needs_encryption(self._manufacturer_data_hex): + await self._resolve_friendly_name() return self._create_entry() return await self.async_step_homekey_bluetooth() @@ -208,96 +305,53 @@ async def async_step_homekey_bluetooth( existing = self._existing_home_key() if existing and user_input is None: self._home_key = existing + await self._resolve_friendly_name() return self._create_entry() errors: dict[str, str] = {} if user_input is not None: - method = user_input.get("key_method", "skip") - - if method == "skip": - self._home_key = "" + if await self._validate_homekey_input(user_input, errors): + # Use hub name for the entry title if available + friendly = self._hub_name_for(self._device_name) + if friendly: + self._device_name = friendly return self._create_entry() - elif method == "manual": - raw = user_input.get("home_key", "").strip() - if "\\x" in raw: - raw = raw.replace("\\x", "") - if len(raw) != 32: - errors["home_key"] = "invalid_key_length" - else: - try: - bytes.fromhex(raw) - except ValueError: - errors["home_key"] = "invalid_key_format" - else: - self._home_key = raw.lower() - return self._create_entry() - - elif method == "hub": - hub_url = user_input.get("hub_url", "").rstrip("/") - try: - key, hub_shades = await _fetch_key_and_shades_from_hub( - self.hass, hub_url - ) - self._home_key = key.hex() - # Use hub name for the entry title if available - for hs in hub_shades: - if hs.ble_name == self._device_name: - self._device_name = hs.name - break - return self._create_entry() - except aiohttp.ClientResponseError: - errors["hub_url"] = "hub_http_error" - except aiohttp.ClientConnectionError: - errors["hub_url"] = "hub_connection_error" - except (asyncio.TimeoutError, TimeoutError): - errors["hub_url"] = "hub_timeout" - except ValueError: - errors["hub_url"] = "hub_protocol_error" - return self.async_show_form( step_id="homekey_bluetooth", - data_schema=vol.Schema( - { - vol.Required("key_method", default="hub"): SelectSelector( - SelectSelectorConfig( - options=[ - { - "value": "hub", - "label": "Fetch automatically from PowerView hub", - }, - { - "value": "manual", - "label": "Enter key manually (32 hex characters)", - }, - { - "value": "skip", - "label": "Skip (no key — controls disabled for encrypted shades)", - }, - ] - ) - ), - vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector( - TextSelectorConfig(type=TextSelectorType.URL) - ), - vol.Optional("home_key", default=""): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - } - ), + data_schema=_HOMEKEY_SCHEMA, errors=errors, description_placeholders={"name": self._device_name}, ) - def _existing_home_key(self) -> str: - """Return the home_key from any already-configured entry, or ''.""" + def _existing_entry_value(self, key: str) -> str: + """Return the first non-empty value for *key* across configured entries.""" for entry in self._async_current_entries(): - key = entry.data.get(CONF_HOME_KEY, "") - if key: - return key + if value := entry.data.get(key, ""): + return value return "" + def _existing_home_key(self) -> str: + """Return the home_key from any already-configured entry, or ''.""" + return self._existing_entry_value(CONF_HOME_KEY) + + async def _resolve_friendly_name(self) -> None: + """Try to resolve BLE device name to hub friendly name.""" + hub_url = self._hub_url or self._existing_entry_value("hub_url") + if not hub_url: + return + try: + shades = await _fetch_shades_from_hub(self.hass, hub_url) + for hs in shades: + if hs.ble_name == self._device_name: + self._device_name = hs.name + break + if not self._hub_url: + self._hub_url = hub_url + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError): + pass + def _hub_name_for(self, ble_name: str) -> str | None: """Return the human-readable hub name for a BLE name, or None.""" for hs in self._hub_shades: @@ -334,24 +388,29 @@ async def async_step_select_device( ble_name = device.name name = self._hub_name_for(ble_name) or ble_name mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() + entry_data: dict[str, str] = { + "manufacturer_data": mfct_hex, + CONF_HOME_KEY: self._home_key, + } + if self._hub_url: + entry_data["hub_url"] = self._hub_url entries.append( { "address": address, "name": name, - "data": { - "manufacturer_data": mfct_hex, - CONF_HOME_KEY: self._home_key, - }, + "data": entry_data, } ) # Kick off auto-add flows for all but the last shade - for info in entries[:-1]: - await self.hass.config_entries.flow.async_init( + await asyncio.gather(*( + self.hass.config_entries.flow.async_init( DOMAIN, context={"source": "auto_add"}, data=info, ) + for info in entries[:-1] + )) # Create the final entry normally (ends this flow) last = entries[-1] @@ -399,12 +458,14 @@ async def async_step_select_device( ) async def async_step_auto_add( - self, data: dict[str, Any] + self, user_input: dict[str, Any] ) -> ConfigFlowResult: """Create a config entry for a shade selected via multi-select.""" - await self.async_set_unique_id(data["address"]) + await self.async_set_unique_id(user_input["address"]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=data["name"], data=data["data"]) + return self.async_create_entry( + title=user_input["name"], data=user_input["data"] + ) async def async_step_manual( self, user_input: dict[str, Any] | None = None @@ -440,75 +501,11 @@ async def async_step_homekey( errors: dict[str, str] = {} if user_input is not None: - method = user_input.get("key_method", "skip") - - if method == "skip": - self._home_key = "" + if await self._validate_homekey_input(user_input, errors): return await self.async_step_select_device() - elif method == "manual": - raw = user_input.get("home_key", "").strip() - # Accept \xNN\xNN... format (e.g. from ESP32 emulator serial log) - if "\\x" in raw: - raw = raw.replace("\\x", "") - if len(raw) != 32: - errors["home_key"] = "invalid_key_length" - else: - try: - bytes.fromhex(raw) - except ValueError: - errors["home_key"] = "invalid_key_format" - else: - self._home_key = raw.lower() - return await self.async_step_select_device() - - elif method == "hub": - hub_url = user_input.get("hub_url", "").rstrip("/") - try: - key, hub_shades = await _fetch_key_and_shades_from_hub( - self.hass, hub_url - ) - self._home_key = key.hex() - self._hub_shades = hub_shades - return await self.async_step_select_device() - except aiohttp.ClientResponseError: - errors["hub_url"] = "hub_http_error" - except aiohttp.ClientConnectionError: - errors["hub_url"] = "hub_connection_error" - except (asyncio.TimeoutError, TimeoutError): - errors["hub_url"] = "hub_timeout" - except ValueError: - errors["hub_url"] = "hub_protocol_error" - return self.async_show_form( step_id="homekey", - data_schema=vol.Schema( - { - vol.Required("key_method", default="hub"): SelectSelector( - SelectSelectorConfig( - options=[ - { - "value": "hub", - "label": "Fetch automatically from PowerView hub", - }, - { - "value": "manual", - "label": "Enter key manually (32 hex characters)", - }, - { - "value": "skip", - "label": "Skip (no key — controls disabled for encrypted shades)", - }, - ] - ) - ), - vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector( - TextSelectorConfig(type=TextSelectorType.URL) - ), - vol.Optional("home_key", default=""): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - } - ), + data_schema=_HOMEKEY_SCHEMA, errors=errors, ) diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index ba621d4..568275c 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -20,11 +20,13 @@ 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], + friendly_name: str | None = None, ) -> None: """Initialize BMS data coordinator.""" assert ble_device.name is not None self._mac = ble_device.address + self._friendly_name = friendly_name or ble_device.name home_key_hex: str = data.get(CONF_HOME_KEY, "") home_key: bytes = bytes.fromhex(home_key_hex) if len(home_key_hex) == 32 else b"" self.api = PowerViewBLE(ble_device, home_key) @@ -34,7 +36,7 @@ def __init__( LOGGER.debug( "Initializing coordinator for %s (%s)", - ble_device.name, + self._friendly_name, ble_device.address, ) super().__init__( @@ -52,16 +54,15 @@ async def query_dev_info(self) -> None: @property def device_info(self) -> DeviceInfo: """Return detailed device information for GUI.""" - LOGGER.debug("%s: device_info, %s", self.name, self.dev_details) + LOGGER.debug("%s: device_info, %s", self._friendly_name, self.dev_details) return DeviceInfo( identifiers={ - (DOMAIN, self.name), + (DOMAIN, self.address), (BLUETOOTH_DOMAIN, self.address), }, connections={(CONNECTION_BLUETOOTH, self.address)}, - name=self.name, + name=self._friendly_name, configuration_url=None, - # properties used in GUI: manufacturer="Hunter Douglas", model=( str(SHADE_TYPE.get(int(bytes.fromhex(self._manuf_dat)[2]), "unknown")) @@ -95,9 +96,6 @@ def _async_handle_bluetooth_event( ) -> None: """Handle a Bluetooth event.""" - # if not self.dev_details: - # self.hass.async_create_task(self._get_device_info()) - LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data) self.api.set_ble_device(service_info.device) self.data = {ATTR_RSSI: service_info.rssi} diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 9cfe6bd..428baba 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -62,7 +62,7 @@ def __init__( ) -> None: """Initialize the shade.""" LOGGER.debug("%s: init() PowerViewCover", coordinator.name) - self._attr_name = CoverDeviceClass.SHADE + self._attr_name = None self._coord: PVCoordinator = coordinator self._attr_device_info = self._coord.device_info self._target_position: int | None = round( From af08d18d6211164a8677b56338a3116fc385a2eb Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 15:01:52 +1000 Subject: [PATCH 04/37] fix: area assignment for multiple devices --- .../config_flow.py | 28 +++++++++++++++---- .../hunterdouglas_powerview_ble/strings.json | 3 ++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 63a8f4f..a784383 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -458,13 +458,31 @@ async def async_step_select_device( ) async def async_step_auto_add( - self, user_input: dict[str, Any] + self, discovery_info: dict[str, Any] ) -> ConfigFlowResult: - """Create a config entry for a shade selected via multi-select.""" - await self.async_set_unique_id(user_input["address"]) + """Handle a shade queued from multi-select for individual setup.""" + await self.async_set_unique_id(discovery_info["address"]) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input["name"], data=user_input["data"] + + self._device_name = discovery_info["name"] + self._manufacturer_data_hex = discovery_info["data"]["manufacturer_data"] + self._home_key = discovery_info["data"].get(CONF_HOME_KEY, "") + self._hub_url = discovery_info["data"].get("hub_url", "") + + self.context["title_placeholders"] = {"name": self._device_name} + return await self.async_step_auto_add_confirm() + + async def async_step_auto_add_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm adding a shade discovered via multi-select.""" + if user_input is not None: + return self._create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="auto_add_confirm", + description_placeholders={"name": self._device_name}, ) async def async_step_manual( diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index aa66787..617cc2e 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -31,6 +31,9 @@ "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } }, + "auto_add_confirm": { + "description": "Do you want to set up {name}?" + }, "select_device": { "title": "Select Shades", "description": "Select the PowerView shades to add via Bluetooth.", From 652337e32c02ca7bca494229fe30efe0c7221377 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 15:13:21 +1000 Subject: [PATCH 05/37] fix: linting and formatting --- .../hunterdouglas_powerview_ble/api.py | 2 +- .../binary_sensor.py | 4 +- .../config_flow.py | 203 ++++++++++-------- .../coordinator.py | 9 +- .../hunterdouglas_powerview_ble/cover.py | 4 +- scripts/extract_gateway3_homekey.py | 4 +- 6 files changed, 127 insertions(+), 99 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 49aeb0a..50003a2 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -228,7 +228,7 @@ async def set_position( await self._cmd( ( ShadeCmd.SET_POSITION, - int.to_bytes(pos1*100, 2, byteorder="little") + 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") diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index dda9127..7749300 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -40,7 +40,9 @@ async def async_setup_entry( ) -class PVBinarySensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], BinarySensorEntity): # type: ignore[reportIncompatibleMethodOverride] +class PVBinarySensor( + PassiveBluetoothCoordinatorEntity[PVCoordinator], BinarySensorEntity +): # type: ignore[reportIncompatibleMethodOverride] """The generic PV binary sensor implementation.""" def __init__( diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index a784383..60e3f8d 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -2,8 +2,8 @@ import asyncio import base64 -import struct from dataclasses import dataclass +import struct from typing import Any import aiohttp @@ -114,26 +114,26 @@ async def _fetch_key_and_shades_from_hub( ) as resp: resp.raise_for_status() result = await resp.json(content_type=None) + except (TimeoutError, aiohttp.ClientError) as ex: + last_error = ex + continue - responses = result.get("responses", []) - if len(responses) != 1 or "hex" not in responses[0]: - continue + responses = result.get("responses", []) + if len(responses) != 1 or "hex" not in responses[0]: + continue - response_bytes = bytes.fromhex(responses[0]["hex"]) - if len(response_bytes) < 5: - continue - _s, _c, _q, length = struct.unpack(" ConfigFlowResult: data=data, ) + def _validate_manual_key( + self, user_input: dict[str, Any], errors: dict[str, str] + ) -> bool: + """Validate a manually entered hex key and store it. + + Returns True on success, False on validation error. + """ + raw = user_input.get("home_key", "").strip() + if "\\x" in raw: + raw = raw.replace("\\x", "") + if len(raw) != 32: + errors["home_key"] = "invalid_key_length" + return False + try: + bytes.fromhex(raw) + except ValueError: + errors["home_key"] = "invalid_key_format" + return False + self._home_key = raw.lower() + return True + async def _validate_homekey_input( self, user_input: dict[str, Any], errors: dict[str, str] ) -> bool: @@ -220,40 +241,28 @@ async def _validate_homekey_input( return True if method == "manual": - raw = user_input.get("home_key", "").strip() - if "\\x" in raw: - raw = raw.replace("\\x", "") - if len(raw) != 32: - errors["home_key"] = "invalid_key_length" - return False - try: - bytes.fromhex(raw) - except ValueError: - errors["home_key"] = "invalid_key_format" - return False - self._home_key = raw.lower() - return True + return self._validate_manual_key(user_input, errors) - if method == "hub": - hub_url = user_input.get("hub_url", "").rstrip("/") - try: - key, hub_shades = await _fetch_key_and_shades_from_hub( - self.hass, hub_url - ) - self._home_key = key.hex() - self._hub_url = hub_url - self._hub_shades = hub_shades - return True - except aiohttp.ClientResponseError: - errors["hub_url"] = "hub_http_error" - except aiohttp.ClientConnectionError: - errors["hub_url"] = "hub_connection_error" - except (asyncio.TimeoutError, TimeoutError): - errors["hub_url"] = "hub_timeout" - except ValueError: - errors["hub_url"] = "hub_protocol_error" + if method != "hub": + return False - return False + hub_url = user_input.get("hub_url", "").rstrip("/") + _HUB_ERROR_MAP: dict[type[Exception], str] = { + aiohttp.ClientResponseError: "hub_http_error", + aiohttp.ClientConnectionError: "hub_connection_error", + TimeoutError: "hub_timeout", + ValueError: "hub_protocol_error", + } + try: + key, hub_shades = await _fetch_key_and_shades_from_hub(self.hass, hub_url) + except tuple(_HUB_ERROR_MAP) as ex: + errors["hub_url"] = _HUB_ERROR_MAP[type(ex)] + return False + + self._home_key = key.hex() + self._hub_url = hub_url + self._hub_shades = hub_shades + return True async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -310,13 +319,14 @@ async def async_step_homekey_bluetooth( errors: dict[str, str] = {} - if user_input is not None: - if await self._validate_homekey_input(user_input, errors): - # Use hub name for the entry title if available - friendly = self._hub_name_for(self._device_name) - if friendly: - self._device_name = friendly - return self._create_entry() + if user_input is not None and await self._validate_homekey_input( + user_input, errors + ): + # Use hub name for the entry title if available + friendly = self._hub_name_for(self._device_name) + if friendly: + self._device_name = friendly + return self._create_entry() return self.async_show_form( step_id="homekey_bluetooth", @@ -349,7 +359,7 @@ async def _resolve_friendly_name(self) -> None: break if not self._hub_url: self._hub_url = hub_url - except (aiohttp.ClientError, asyncio.TimeoutError, ValueError): + except (TimeoutError, aiohttp.ClientError, ValueError): pass def _hub_name_for(self, ble_name: str) -> str | None: @@ -370,6 +380,35 @@ async def async_step_user( return await self.async_step_select_device() return await self.async_step_homekey() + def _build_selected_entries( + self, user_input: dict[str, Any] + ) -> list[dict[str, Any]]: + """Build config entry data for each selected shade address.""" + addresses: list[str] = user_input[CONF_ADDRESS] + if isinstance(addresses, str): + addresses = [addresses] + + entries: list[dict[str, Any]] = [] + for address in addresses: + device = self._discovered_devices[address] + ble_name = device.name + name = self._hub_name_for(ble_name) or ble_name + mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() + entry_data: dict[str, str] = { + "manufacturer_data": mfct_hex, + CONF_HOME_KEY: self._home_key, + } + if self._hub_url: + entry_data["hub_url"] = self._hub_url + entries.append( + { + "address": address, + "name": name, + "data": entry_data, + } + ) + return entries + async def async_step_select_device( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -377,40 +416,19 @@ async def async_step_select_device( LOGGER.debug("select_device step") if user_input is not None: - addresses: list[str] = user_input[CONF_ADDRESS] - if isinstance(addresses, str): - addresses = [addresses] - - # Build entry info for every selected shade - entries: list[dict[str, Any]] = [] - for address in addresses: - device = self._discovered_devices[address] - ble_name = device.name - name = self._hub_name_for(ble_name) or ble_name - mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() - entry_data: dict[str, str] = { - "manufacturer_data": mfct_hex, - CONF_HOME_KEY: self._home_key, - } - if self._hub_url: - entry_data["hub_url"] = self._hub_url - entries.append( - { - "address": address, - "name": name, - "data": entry_data, - } - ) + entries = self._build_selected_entries(user_input) # Kick off auto-add flows for all but the last shade - await asyncio.gather(*( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "auto_add"}, - data=info, + await asyncio.gather( + *( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "auto_add"}, + data=info, + ) + for info in entries[:-1] ) - for info in entries[:-1] - )) + ) # Create the final entry normally (ends this flow) last = entries[-1] @@ -518,9 +536,10 @@ async def async_step_homekey( """Configure homekey — collected before device selection.""" errors: dict[str, str] = {} - if user_input is not None: - if await self._validate_homekey_input(user_input, errors): - return await self.async_step_select_device() + if user_input is not None and await self._validate_homekey_input( + user_input, errors + ): + return await self.async_step_select_device() return self.async_show_form( step_id="homekey", diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 568275c..a869498 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -20,7 +20,10 @@ 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], friendly_name: str | None = None, ) -> None: """Initialize BMS data coordinator.""" @@ -28,7 +31,9 @@ def __init__( self._mac = ble_device.address self._friendly_name = friendly_name or ble_device.name home_key_hex: str = data.get(CONF_HOME_KEY, "") - home_key: bytes = bytes.fromhex(home_key_hex) if len(home_key_hex) == 32 else b"" + home_key: bytes = ( + bytes.fromhex(home_key_hex) if len(home_key_hex) == 32 else b"" + ) self.api = PowerViewBLE(ble_device, home_key) self.data: dict[str, int | float | bool] = {} self._manuf_dat = data.get("manufacturer_data") diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 428baba..80cadb1 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -34,9 +34,9 @@ async def async_setup_entry( """Set up the demo cover platform.""" coordinator: PVCoordinator = config_entry.runtime_data - model: Final[str|None] = coordinator.dev_details.get("model") + model: Final[str | None] = coordinator.dev_details.get("model") entities: list[PowerViewCover] = [] - if model in ["39"]: + if model == "39": entities.append(PowerViewCoverTiltOnly(coordinator)) else: entities.append(PowerViewCover(coordinator)) diff --git a/scripts/extract_gateway3_homekey.py b/scripts/extract_gateway3_homekey.py index f7b45bd..a90ecc5 100644 --- a/scripts/extract_gateway3_homekey.py +++ b/scripts/extract_gateway3_homekey.py @@ -61,7 +61,9 @@ def get_shade_key(hub: str, ble_name) -> bytes: response: Final[bytes] = bytes.fromhex(responses[0]["hex"]) dec_resp: Final[dict[str, Any]] = decode_response(response) if dec_resp["errorCode"] != 0: - raise ValueError(f"BLE errorCode={dec_resp['errorCode']} data={dec_resp['data'].hex()}") + raise ValueError( + f"BLE errorCode={dec_resp['errorCode']} data={dec_resp['data'].hex()}" + ) if len(dec_resp["data"]) != 16: raise ValueError("Expected 16 byte homekey") return dec_resp["data"] From abb0a3e8a315633b68f7d729a1d13d610ceb6837 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 7 Apr 2026 08:11:59 +1000 Subject: [PATCH 06/37] fix: allow option to setup found devices or add a new device plus updated descriptions --- .../hunterdouglas_powerview_ble/config_flow.py | 16 ++++++++++++++-- .../hunterdouglas_powerview_ble/strings.json | 13 ++++++++++++- .../translations/en.json | 13 ++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 60e3f8d..cac7dc8 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -2,6 +2,7 @@ import asyncio import base64 +import contextlib from dataclasses import dataclass import struct from typing import Any @@ -372,12 +373,23 @@ def _hub_name_for(self, ble_name: str) -> str | None: async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step — reuse existing key or collect one.""" + """Handle the user step — reuse existing key or offer a menu.""" LOGGER.debug("user step") existing = self._existing_home_key() if existing: self._home_key = existing - return await self.async_step_select_device() + self._hub_url = self._hub_url or self._existing_entry_value("hub_url") + if self._hub_url and not self._hub_shades: + with contextlib.suppress( + TimeoutError, aiohttp.ClientError, ValueError + ): + self._hub_shades = await _fetch_shades_from_hub( + self.hass, self._hub_url + ) + return self.async_show_menu( + step_id="user", + menu_options=["select_device", "manual"], + ) return await self.async_step_homekey() def _build_selected_entries( diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 617cc2e..b638f96 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -2,6 +2,17 @@ "config": { "flow_title": "Setup {name}", "step": { + "user": { + "title": "Add PowerView Shade", + "menu_options": { + "select_device": "Select from discovered shades", + "manual": "Enter device details manually" + }, + "menu_option_descriptions": { + "select_device": "Choose from shades detected via Bluetooth nearby.", + "manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery." + } + }, "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, @@ -43,7 +54,7 @@ }, "manual": { "title": "Enter Device Details", - "description": "No PowerView shades were found via Bluetooth. Enter the device details manually.", + "description": "Enter the device details manually.", "data": { "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", "ble_name": "BLE device name (e.g. DUE:94ED)" diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 101a408..5164bc8 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -2,6 +2,17 @@ "config": { "flow_title": "Setup {name}", "step": { + "user": { + "title": "Add PowerView Shade", + "menu_options": { + "select_device": "Select from discovered shades", + "manual": "Enter device details manually" + }, + "menu_option_descriptions": { + "select_device": "Choose from shades detected via Bluetooth nearby.", + "manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery." + } + }, "bluetooth_confirm": { "description": "Do you want to set up {name}?" }, @@ -40,7 +51,7 @@ }, "manual": { "title": "Enter Device Details", - "description": "No PowerView shades were found via Bluetooth. Enter the device details manually.", + "description": "Enter the device details manually.", "data": { "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", "ble_name": "BLE device name (e.g. DUE:94ED)" From f26041667661f9323357709ed41a6b04db238eae Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Thu, 9 Apr 2026 07:18:54 +1000 Subject: [PATCH 07/37] fix: linting and hass validation issues --- .../config_flow.py | 32 +++++++++++-------- .../hunterdouglas_powerview_ble/strings.json | 4 +-- .../translations/en.json | 4 +-- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index cac7dc8..6c88d50 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -144,18 +144,18 @@ async def _fetch_key_and_shades_from_hub( vol.Required("key_method", default="hub"): SelectSelector( SelectSelectorConfig( options=[ - { - "value": "hub", - "label": "Fetch automatically from PowerView hub", - }, - { - "value": "manual", - "label": "Enter key manually (32 hex characters)", - }, - { - "value": "skip", - "label": "Skip (no key — controls disabled for encrypted shades)", - }, + SelectOptionDict( + value="hub", + label="Fetch automatically from PowerView hub", + ), + SelectOptionDict( + value="manual", + label="Enter key manually (32 hex characters)", + ), + SelectOptionDict( + value="skip", + label="Skip (no key — controls disabled for encrypted shades)", + ), ] ) ), @@ -333,7 +333,10 @@ async def async_step_homekey_bluetooth( step_id="homekey_bluetooth", data_schema=_HOMEKEY_SCHEMA, errors=errors, - description_placeholders={"name": self._device_name}, + description_placeholders={ + "name": self._device_name, + "hub_url_example": "http://powerview-g3.local", + }, ) def _existing_entry_value(self, key: str) -> str: @@ -557,4 +560,7 @@ async def async_step_homekey( step_id="homekey", data_schema=_HOMEKEY_SCHEMA, errors=errors, + description_placeholders={ + "hub_url_example": "http://powerview-g3.local", + }, ) diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index b638f96..5e3e3e7 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -25,7 +25,7 @@ "home_key": "HomeKey (32 hex characters or \\xNN format)" }, "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } }, @@ -38,7 +38,7 @@ "home_key": "HomeKey (32 hex characters or \\xNN format)" }, "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } }, diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 5164bc8..7830431 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -25,7 +25,7 @@ "home_key": "HomeKey (32 hex characters or \\xNN format)" }, "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } }, @@ -38,7 +38,7 @@ "home_key": "HomeKey (32 hex characters or \\xNN format)" }, "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. http://powerview-g3.local", + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } }, From 7a2bf2193a41e411c49f711abf9de58ba9d6a18e Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Wed, 8 Apr 2026 15:52:46 +1000 Subject: [PATCH 08/37] feat: add shade capabilities, velocity control, and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ShadeCapability lookup to replace hardcoded model string checks for tilt/tilt-only selection. Add velocity number entity (0–100) and pass velocity through all movement paths including open/close. Remove redundant device_info property overrides and deduplicate hex parsing. --- .../hunterdouglas_powerview_ble/__init__.py | 3 +- .../hunterdouglas_powerview_ble/api.py | 35 ++++++++-- .../coordinator.py | 21 ++++-- .../hunterdouglas_powerview_ble/cover.py | 34 ++++----- .../hunterdouglas_powerview_ble/number.py | 69 +++++++++++++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 custom_components/hunterdouglas_powerview_ble/number.py diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index c66d7ec..c3a6d2f 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -18,9 +18,10 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.COVER, + Platform.NUMBER, Platform.SENSOR, - Platform.BUTTON, ] type ConfigEntryType = ConfigEntry[PVCoordinator] diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 50003a2..65242cb 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import Enum import time -from typing import Final +from typing import Final, NamedTuple from bleak import BleakClient from bleak.backends.device import BLEDevice @@ -58,6 +58,31 @@ 62: "Venetian, Tilt Anywhere", } +class ShadeCapability(NamedTuple): + """Capability flags for a shade type.""" + + has_tilt: bool = False + tilt_only: bool = False + + +SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { + # tilt anywhere (position + tilt) + 51: ShadeCapability(has_tilt=True), + 62: ShadeCapability(has_tilt=True), + # tilt only (no position movement) + 39: ShadeCapability(has_tilt=True, tilt_only=True), +} + +_DEFAULT_CAPABILITY: Final[ShadeCapability] = ShadeCapability() + + +def get_shade_capabilities(type_id: int | None) -> ShadeCapability: + """Return shade capabilities for a given type_id.""" + if type_id is None: + return _DEFAULT_CAPABILITY + return SHADE_CAPABILITIES.get(type_id, _DEFAULT_CAPABILITY) + + OPEN_POSITION: Final[int] = 100 CLOSED_POSITION: Final[int] = 0 @@ -237,20 +262,20 @@ async def set_position( disconnect, ) - async def open(self) -> None: + async def open(self, velocity: int = 0x0) -> None: """Fully open cover.""" LOGGER.debug("%s open", self.name) - await self.set_position(OPEN_POSITION, disconnect=False) + await self.set_position(OPEN_POSITION, velocity=velocity, disconnect=False) 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, velocity: int = 0x0) -> None: """Fully close cover.""" LOGGER.debug("%s close", self.name) - await self.set_position(CLOSED_POSITION, disconnect=False) + await self.set_position(CLOSED_POSITION, velocity=velocity, disconnect=False) # uint8_t scene#, uint8_t unknown # open: scene 2 diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index a869498..2d8ae91 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from .api import SHADE_TYPE, PowerViewBLE +from .api import SHADE_TYPE, ShadeCapability, PowerViewBLE, get_shade_capabilities from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER @@ -38,6 +38,7 @@ def __init__( self.data: dict[str, int | float | bool] = {} self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} + self.velocity: int = 0 LOGGER.debug( "Initializing coordinator for %s (%s)", @@ -51,6 +52,18 @@ def __init__( bluetooth.BluetoothScanningMode.ACTIVE, ) + @property + def type_id(self) -> int | None: + """Return the shade type ID from manufacturer data.""" + if self._manuf_dat: + return int(bytes.fromhex(self._manuf_dat)[2]) + return None + + @property + def shade_capabilities(self) -> ShadeCapability: + """Return the shade capabilities based on type ID.""" + return get_shade_capabilities(self.type_id) + async def query_dev_info(self) -> None: """Receive detailed information from device.""" LOGGER.debug("%s: querying device info", self.name) @@ -70,12 +83,12 @@ def device_info(self) -> DeviceInfo: configuration_url=None, manufacturer="Hunter Douglas", model=( - str(SHADE_TYPE.get(int(bytes.fromhex(self._manuf_dat)[2]), "unknown")) - if self._manuf_dat + str(SHADE_TYPE.get(self.type_id, "unknown")) + if self.type_id is not None else None ), model_id=( - str(bytes.fromhex(self._manuf_dat)[2]) if self._manuf_dat else None + str(self.type_id) if self.type_id is not None else None ), serial_number=self.dev_details.get("serial_nr"), sw_version=self.dev_details.get("sw_rev"), diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 80cadb1..3d463d7 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -18,7 +18,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import CLOSED_POSITION, OPEN_POSITION @@ -31,15 +31,17 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the demo cover platform.""" + """Set up the cover platform.""" coordinator: PVCoordinator = config_entry.runtime_data - model: Final[str | None] = coordinator.dev_details.get("model") - entities: list[PowerViewCover] = [] - if model == "39": - entities.append(PowerViewCoverTiltOnly(coordinator)) + caps = coordinator.shade_capabilities + + if caps.tilt_only: + entities: list[PowerViewCover] = [PowerViewCoverTiltOnly(coordinator)] + elif caps.has_tilt: + entities = [PowerViewCoverTilt(coordinator)] else: - entities.append(PowerViewCover(coordinator)) + entities = [PowerViewCover(coordinator)] async_add_entities(entities) @@ -73,11 +75,6 @@ def __init__( ) super().__init__(coordinator) - @property - def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableOverride] - """Return the device_info of the device.""" - return self._coord.device_info - @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Return if the cover is opening or not.""" @@ -133,7 +130,10 @@ 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), + velocity=self._coord.velocity, + ) self.async_write_ha_state() except BleakError as err: LOGGER.error( @@ -153,7 +153,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(velocity=self._coord.velocity) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to open cover '%s': %s", self.name, err) @@ -166,7 +166,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(velocity=self._coord.velocity) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to close cover '%s': %s", self.name, err) @@ -227,7 +227,9 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: try: await self._coord.api.set_position( - self.current_cover_position, tilt=target_position + self.current_cover_position, + tilt=target_position, + velocity=self._coord.velocity, ) self.async_write_ha_state() except BleakError as err: diff --git a/custom_components/hunterdouglas_powerview_ble/number.py b/custom_components/hunterdouglas_powerview_ble/number.py new file mode 100644 index 0000000..1d76d95 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/number.py @@ -0,0 +1,69 @@ +"""Hunter Douglas PowerView velocity control.""" + +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothCoordinatorEntity, +) +from homeassistant.components.number import NumberMode, RestoreNumber +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ConfigEntryType +from .const import DOMAIN, LOGGER +from .coordinator import PVCoordinator + + +async def async_setup_entry( + _hass: HomeAssistant, + config_entry: ConfigEntryType, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the velocity number entity.""" + + coordinator: PVCoordinator = config_entry.runtime_data + async_add_entities([PowerViewVelocity(coordinator)]) + + +class PowerViewVelocity( + PassiveBluetoothCoordinatorEntity[PVCoordinator], RestoreNumber +): # type: ignore[reportIncompatibleVariableOverride] + """Number entity to control shade movement velocity.""" + + _attr_has_entity_name = True + _attr_name = "Velocity" + _attr_icon = "mdi:speedometer" + _attr_mode = NumberMode.SLIDER + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_native_step = 1 + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator: PVCoordinator) -> None: + """Initialize the velocity entity.""" + self._coord = coordinator + self._attr_device_info = self._coord.device_info + self._attr_unique_id = ( + f"{DOMAIN}_{format_mac(self._coord.address)}_velocity" + ) + super().__init__(coordinator) + + @property + def native_value(self) -> int: + """Return the current velocity value.""" + return self._coord.velocity + + async def async_added_to_hass(self) -> None: + """Restore last known velocity on startup.""" + await super().async_added_to_hass() + last_data = await self.async_get_last_number_data() + if last_data and last_data.native_value is not None: + self._coord.velocity = int(last_data.native_value) + LOGGER.debug( + "%s: restored velocity to %s", self._coord.name, self._coord.velocity + ) + + async def async_set_native_value(self, value: float) -> None: + """Set the velocity value.""" + self._coord.velocity = int(value) + self.async_write_ha_state() From acb2c5ff52aa688286cf00a98531be6ae69b451e Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Thu, 9 Apr 2026 08:20:01 +1000 Subject: [PATCH 09/37] fix: linting --- custom_components/hunterdouglas_powerview_ble/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 2d8ae91..10aabb8 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from .api import SHADE_TYPE, ShadeCapability, PowerViewBLE, get_shade_capabilities +from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER From e2bb3b559207a96484bba0a269b0755bb7873fad Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Thu, 9 Apr 2026 11:03:33 +1000 Subject: [PATCH 10/37] feat: refactor to a hub model --- .../hunterdouglas_powerview_ble/__init__.py | 202 ++++++-- .../binary_sensor.py | 23 +- .../hunterdouglas_powerview_ble/button.py | 22 +- .../config_flow.py | 487 ++++-------------- .../hunterdouglas_powerview_ble/const.py | 4 + .../coordinator.py | 5 +- .../hunterdouglas_powerview_ble/cover.py | 21 +- .../hunterdouglas_powerview_ble/manifest.json | 2 +- .../hunterdouglas_powerview_ble/number.py | 15 +- .../hunterdouglas_powerview_ble/sensor.py | 23 +- .../hunterdouglas_powerview_ble/strings.json | 54 +- .../translations/en.json | 51 +- 12 files changed, 355 insertions(+), 554 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index c3a6d2f..f0da803 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -4,16 +4,33 @@ @license: Apache-2.0 license """ +import base64 +from collections.abc import Callable + +import aiohttp from bleak.backends.device import BLEDevice from bleak.exc import BleakError -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_discovered_service_info, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady - -from .const import LOGGER +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .api import UUID_COV_SERVICE as UUID +from .const import CONF_HUB_URL, LOGGER, MFCT_ID, SIGNAL_NEW_SHADE from .coordinator import PVCoordinator PLATFORMS: list[Platform] = [ @@ -24,34 +41,157 @@ Platform.SENSOR, ] -type ConfigEntryType = ConfigEntry[PVCoordinator] +type HubRuntimeData = dict[str, PVCoordinator] +type ConfigEntryType = ConfigEntry[HubRuntimeData] +type AddEntitiesFn = Callable[[PVCoordinator, AddEntitiesCallback], None] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: - """Set up BT Battery Management System from a config entry.""" - LOGGER.debug("Setup of %s", repr(entry)) - if entry.unique_id is None: - raise ConfigEntryError("Missing unique ID for device.") +def async_setup_shade_platform( + hass: HomeAssistant, + config_entry: ConfigEntryType, + async_add_entities: AddEntitiesCallback, + add_fn: AddEntitiesFn, +) -> None: + """Set up a platform for all current and future shades.""" + for coordinator in config_entry.runtime_data.values(): + add_fn(coordinator, async_add_entities) - ble_device: BLEDevice | None = async_ble_device_from_address( - hass=hass, address=entry.unique_id, connectable=True + @callback + def _async_new_shade(coordinator: PVCoordinator) -> None: + add_fn(coordinator, async_add_entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_NEW_SHADE.format(entry_id=config_entry.entry_id), + _async_new_shade, + ) ) + +async def _fetch_shade_names( + hass: HomeAssistant, hub_url: str +) -> dict[str, str]: + """Fetch BLE name -> friendly name mapping from the hub. + + Returns empty dict on failure. + """ + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=10) + try: + async with session.get(f"{hub_url}/home/shades", timeout=timeout) as resp: + resp.raise_for_status() + shades = await resp.json(content_type=None) + except (TimeoutError, aiohttp.ClientError, ValueError): + return {} + + names: dict[str, str] = {} + for shade in shades or []: + ble_name = shade.get("bleName", "") + if not ble_name: + continue + name_b64 = shade.get("name", "") + try: + name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name + except Exception: # noqa: BLE001 + name = ble_name + names[ble_name] = name + return names + + +async def _async_setup_shade( + hass: HomeAssistant, + entry: ConfigEntryType, + service_info: BluetoothServiceInfoBleak, + shade_names: dict[str, str], +) -> None: + """Create a coordinator for a newly discovered shade.""" + address = service_info.address + + if address in entry.runtime_data: + return + + ble_device: BLEDevice | None = async_ble_device_from_address( + hass=hass, address=address, connectable=True + ) if not ble_device: - raise ConfigEntryNotReady( - f"Could not find PowerView device ({entry.unique_id}) via Bluetooth" - ) + LOGGER.debug("BLE device %s not connectable, skipping", address) + return + + friendly_name = shade_names.get(service_info.name, service_info.name) - coordinator = PVCoordinator(hass, ble_device, entry.data.copy(), entry.title) + coordinator = PVCoordinator( + hass, ble_device, entry.data.copy(), friendly_name + ) + + entry.runtime_data[address] = coordinator + entry.async_on_unload(coordinator.async_start()) + + async_dispatcher_send( + hass, + SIGNAL_NEW_SHADE.format(entry_id=entry.entry_id), + coordinator, + ) + + # Query device info in background — don't block entry setup try: await coordinator.query_dev_info() - except BleakError as err: - raise ConfigEntryNotReady("Unable to query device info.") from err + except BleakError: + LOGGER.warning( + "Could not query device info for %s (%s)", + friendly_name, + address, + ) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: + """Set up PowerView Home from a config entry.""" + LOGGER.debug("Setup of %s", repr(entry)) + + entry.runtime_data = {} + + # Resolve shade friendly names from hub if available + hub_url = entry.data.get(CONF_HUB_URL, "") + shade_names: dict[str, str] = {} + if hub_url: + shade_names = await _fetch_shade_names(hass, hub_url) - entry.runtime_data = coordinator + # Forward platforms first so dispatched entities have their setup ready await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(coordinator.async_start()) + + # Kick off shade setup for already-discovered BLE devices (non-blocking) + for service_info in async_discovered_service_info(hass, connectable=True): + if ( + MFCT_ID in service_info.manufacturer_data + and UUID in service_info.service_uuids + ): + hass.async_create_task( + _async_setup_shade(hass, entry, service_info, shade_names) + ) + + # Register for future BLE discoveries + def _async_discovered_device( + service_info: BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + if service_info.address not in entry.runtime_data: + hass.async_create_task( + _async_setup_shade(hass, entry, service_info, shade_names) + ) + + entry.async_on_unload( + bluetooth.async_register_callback( + hass, + _async_discovered_device, + BluetoothCallbackMatcher( + service_uuid=UUID, + manufacturer_id=MFCT_ID, + ), + BluetoothScanningMode.ACTIVE, + ) + ) + return True @@ -59,20 +199,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntryType) -> boo """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + for coordinator in entry.runtime_data.values(): + coordinator._async_stop() # noqa: SLF001 + entry.runtime_data.clear() + LOGGER.debug("Unloaded config entry: %s, ok? %s!", entry.unique_id, str(unload_ok)) return unload_ok - - -async def async_migrate_entry( - _hass: HomeAssistant, config_entry: ConfigEntryType -) -> bool: - """Migrate old entry.""" - - if config_entry.version > 1: - # This means the user has downgraded from a future version - LOGGER.debug("Cannot downgrade from version %s", config_entry.version) - return False - - LOGGER.debug("Migrating from version %s", config_entry.version) - - return False diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index 7749300..e487b67 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ConfigEntryType +from . import ConfigEntryType, async_setup_shade_platform from .const import DOMAIN from .coordinator import PVCoordinator @@ -26,18 +26,25 @@ ] +def _add_entities( + coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback +) -> None: + """Create binary sensor entities for a single shade coordinator.""" + async_add_entities( + [ + PVBinarySensor(coordinator, descr, format_mac(coordinator.address)) + for descr in BINARY_SENSOR_TYPES + ] + ) + + async def async_setup_entry( - _hass: HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntryType, async_add_entities: AddEntitiesCallback, ) -> None: """Add sensors for passed config_entry in Home Assistant.""" - - coord: PVCoordinator = config_entry.runtime_data - for descr in BINARY_SENSOR_TYPES: - async_add_entities( - [PVBinarySensor(coord, descr, format_mac(config_entry.unique_id))] - ) + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) class PVBinarySensor( diff --git a/custom_components/hunterdouglas_powerview_ble/button.py b/custom_components/hunterdouglas_powerview_ble/button.py index bf7649b..6826126 100644 --- a/custom_components/hunterdouglas_powerview_ble/button.py +++ b/custom_components/hunterdouglas_powerview_ble/button.py @@ -10,12 +10,12 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ConfigEntryType, async_setup_shade_platform from .const import DOMAIN, LOGGER from .coordinator import PVCoordinator @@ -28,16 +28,22 @@ ] +def _add_entities( + coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback +) -> None: + """Create button entities for a single shade coordinator.""" + async_add_entities( + [PowerViewButton(coordinator, descr) for descr in BUTTONS_SHADE] + ) + + async def async_setup_entry( - _hass: HomeAssistant, - config_entry: ConfigEntry, + hass: HomeAssistant, + config_entry: ConfigEntryType, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the demo cover platform.""" - - coordinator: PVCoordinator = config_entry.runtime_data - for descr in BUTTONS_SHADE: - async_add_entities([PowerViewButton(coordinator, descr)]) + """Set up the button platform.""" + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEntity): # type: ignore[reportIncompatibleVariableOverride] diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 6c88d50..a0116c9 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -1,9 +1,6 @@ -"""Config flow for BLE Battery Management System integration.""" +"""Config flow for Hunter Douglas PowerView BLE integration.""" -import asyncio -import base64 -import contextlib -from dataclasses import dataclass +import hashlib import struct from typing import Any @@ -11,12 +8,8 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.bluetooth import ( - BluetoothServiceInfoBleak, - async_discovered_service_info, -) +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.config_entries import ConfigFlowResult -from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -28,32 +21,78 @@ TextSelectorType, ) -from .api import UUID_COV_SERVICE as UUID -from .const import CONF_HOME_KEY, DOMAIN, LOGGER, MFCT_ID +from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, LOGGER + +def _hub_unique_id(home_key: str) -> str: + """Derive a stable unique ID for a hub entry from the home key.""" + if home_key: + digest = hashlib.sha256(home_key.encode()).hexdigest()[:16] + return f"pvhome_{digest}" + return "pvhome_unencrypted" -def _needs_encryption(manufacturer_data_hex: str) -> bool: - """Return True if the BLE advertisement indicates encryption (home_id != 0).""" - data = bytearray.fromhex(manufacturer_data_hex) - if len(data) < 2: - return False - home_id = int.from_bytes(data[0:2], byteorder="little") - return home_id != 0 +def _parse_key_response(ble_name: str, result: dict) -> bytes | None: # noqa: PLR0911 + """Parse a shade exec response and return the 16-byte key, or None.""" + if result.get("err"): + err_msg = (result.get("responses") or [{}])[0].get("errMsg", "unknown") + LOGGER.warning( + "Shade %s: hub BLE command failed (err=%s: %s)", + ble_name, + result["err"], + err_msg, + ) + return None -@dataclass -class HubShadeInfo: - """Shade metadata from the PowerView hub.""" + responses = result.get("responses", []) + if len(responses) != 1 or "hex" not in responses[0]: + LOGGER.warning( + "Shade %s returned unexpected response structure: %s", + ble_name, + result, + ) + return None - name: str # Human-readable name (decoded from base64) - ble_name: str # BLE advertisement name, e.g. "DUE:94ED" + response_bytes = bytes.fromhex(responses[0]["hex"]) + if len(response_bytes) < 5: + LOGGER.warning( + "Shade %s response too short (%d bytes)", ble_name, len(response_bytes) + ) + return None + _s, _c, _q, length = struct.unpack(" list[HubShadeInfo]: - """Fetch shade list with human-readable names from a PowerView G3 hub. +) -> bytes: + """Fetch 16-byte homekey from a PowerView G3 hub. + Tries each shade on the hub until one returns a valid key. + The key is network-wide so any reachable shade returns the same value. + + Raises ValueError on protocol/key errors. Raises aiohttp.ClientError on network errors. Raises asyncio.TimeoutError on timeout. """ @@ -65,76 +104,33 @@ async def _fetch_shades_from_hub( shades = await resp.json(content_type=None) if not shades: - return [] - - hub_shades: list[HubShadeInfo] = [] - for shade in shades: - ble_name = shade.get("bleName", "") - if not ble_name: - continue - name_b64 = shade.get("name", "") - try: - name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name - except Exception: # noqa: BLE001 - name = ble_name - hub_shades.append(HubShadeInfo(name=name, ble_name=ble_name)) - return hub_shades - - -async def _fetch_key_and_shades_from_hub( - hass: HomeAssistant, hub_url: str -) -> tuple[bytes, list[HubShadeInfo]]: - """Fetch 16-byte homekey and shade list from a PowerView G3 hub. - - Returns (key, shade_list). The key is network-wide so any reachable shade - returns the same value. The shade list contains human-readable names that - can be used to label BLE-discovered devices. - - Raises ValueError on protocol/key errors. - Raises aiohttp.ClientError on network errors. - Raises asyncio.TimeoutError on timeout. - """ - hub_shades = await _fetch_shades_from_hub(hass, hub_url) - if not hub_shades: raise ValueError("No shades found on the hub") - session = async_get_clientsession(hass) - timeout = aiohttp.ClientTimeout(total=10) + ble_names = [s.get("bleName", "") for s in shades if s.get("bleName")] + if not ble_names: + raise ValueError("No BLE-capable shades found on the hub") # GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0 request_frame = struct.pack(" None: """Initialize the config flow.""" - - self._discovered_device: ConfigFlow.DiscoveredDevice | None = None - self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} - self._manufacturer_data_hex: str = "" - self._device_name: str = "" self._home_key: str = "" self._hub_url: str = "" - self._hub_shades: list[HubShadeInfo] = [] def _create_entry(self) -> ConfigFlowResult: - """Create the config entry with collected data.""" - data: dict[str, str] = { - "manufacturer_data": self._manufacturer_data_hex, - CONF_HOME_KEY: self._home_key, - } + """Create the hub config entry.""" + data: dict[str, str] = {CONF_HOME_KEY: self._home_key} if self._hub_url: - data["hub_url"] = self._hub_url - return self.async_create_entry( - title=self._device_name, - data=data, - ) + data[CONF_HUB_URL] = self._hub_url + return self.async_create_entry(title="PowerView Home", data=data) def _validate_manual_key( self, user_input: dict[str, Any], errors: dict[str, str] ) -> bool: - """Validate a manually entered hex key and store it. + """Validate a manually entered hex key. Returns True on success, False on validation error. """ @@ -233,7 +210,6 @@ async def _validate_homekey_input( """Parse and validate homekey user_input, populating self state. Returns True on success, False on validation error (errors dict is populated). - On skip, self._home_key is set to "". """ method = user_input.get("key_method", "skip") @@ -247,7 +223,7 @@ async def _validate_homekey_input( if method != "hub": return False - hub_url = user_input.get("hub_url", "").rstrip("/") + hub_url = user_input.get(CONF_HUB_URL, "").rstrip("/") _HUB_ERROR_MAP: dict[type[Exception], str] = { aiohttp.ClientResponseError: "hub_http_error", aiohttp.ClientConnectionError: "hub_connection_error", @@ -255,14 +231,14 @@ async def _validate_homekey_input( ValueError: "hub_protocol_error", } try: - key, hub_shades = await _fetch_key_and_shades_from_hub(self.hass, hub_url) + key = await _fetch_key_from_hub(self.hass, hub_url) except tuple(_HUB_ERROR_MAP) as ex: - errors["hub_url"] = _HUB_ERROR_MAP[type(ex)] + LOGGER.warning("Hub key fetch failed: %s", ex) + errors[CONF_HUB_URL] = _HUB_ERROR_MAP[type(ex)] return False self._home_key = key.hex() self._hub_url = hub_url - self._hub_shades = hub_shades return True async def async_step_bluetooth( @@ -271,296 +247,45 @@ async def async_step_bluetooth( """Handle a flow initialized by Bluetooth discovery.""" LOGGER.debug("Bluetooth device detected: %s", discovery_info) + # Tag the flow with this address so HA deduplicates future + # discovery flows for the same device 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 - ) - self.context["title_placeholders"] = {"name": self._discovered_device.name} - return await self.async_step_bluetooth_confirm() - - async def async_step_bluetooth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm bluetooth device discovery.""" - assert self._discovered_device is not None - LOGGER.debug("confirm step for %s", self._discovered_device.name) - if user_input is not None: - self._manufacturer_data_hex = ( - self._discovered_device.discovery_info.manufacturer_data[MFCT_ID].hex() - ) - self._device_name = self._discovered_device.name - - # Unencrypted shades can skip the homekey step entirely - if not _needs_encryption(self._manufacturer_data_hex): - await self._resolve_friendly_name() - return self._create_entry() - - return await self.async_step_homekey_bluetooth() - - self._set_confirm_only() - - return self.async_show_form( - step_id="bluetooth_confirm", - description_placeholders={"name": self._discovered_device.name}, - ) - - async def async_step_homekey_bluetooth( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure homekey for a shade discovered via Bluetooth.""" - # Reuse an existing key if another shade was already configured - existing = self._existing_home_key() - if existing and user_input is None: - self._home_key = existing - await self._resolve_friendly_name() - return self._create_entry() - - errors: dict[str, str] = {} - - if user_input is not None and await self._validate_homekey_input( - user_input, errors - ): - # Use hub name for the entry title if available - friendly = self._hub_name_for(self._device_name) - if friendly: - self._device_name = friendly - return self._create_entry() - - return self.async_show_form( - step_id="homekey_bluetooth", - data_schema=_HOMEKEY_SCHEMA, - errors=errors, - description_placeholders={ - "name": self._device_name, - "hub_url_example": "http://powerview-g3.local", - }, - ) - - def _existing_entry_value(self, key: str) -> str: - """Return the first non-empty value for *key* across configured entries.""" + # If a hub entry already exists, shades are auto-discovered for entry in self._async_current_entries(): - if value := entry.data.get(key, ""): - return value - return "" - - def _existing_home_key(self) -> str: - """Return the home_key from any already-configured entry, or ''.""" - return self._existing_entry_value(CONF_HOME_KEY) - - async def _resolve_friendly_name(self) -> None: - """Try to resolve BLE device name to hub friendly name.""" - hub_url = self._hub_url or self._existing_entry_value("hub_url") - if not hub_url: - return - try: - shades = await _fetch_shades_from_hub(self.hass, hub_url) - for hs in shades: - if hs.ble_name == self._device_name: - self._device_name = hs.name - break - if not self._hub_url: - self._hub_url = hub_url - except (TimeoutError, aiohttp.ClientError, ValueError): - pass - - def _hub_name_for(self, ble_name: str) -> str | None: - """Return the human-readable hub name for a BLE name, or None.""" - for hs in self._hub_shades: - if hs.ble_name == ble_name: - return hs.name - return None + if entry.version >= 2: + return self.async_abort(reason="already_configured") + + # No hub entry yet — redirect to user setup + return await self.async_step_user() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step — reuse existing key or offer a menu.""" + """Handle the user step — create a hub entry.""" LOGGER.debug("user step") - existing = self._existing_home_key() - if existing: - self._home_key = existing - self._hub_url = self._hub_url or self._existing_entry_value("hub_url") - if self._hub_url and not self._hub_shades: - with contextlib.suppress( - TimeoutError, aiohttp.ClientError, ValueError - ): - self._hub_shades = await _fetch_shades_from_hub( - self.hass, self._hub_url - ) - return self.async_show_menu( - step_id="user", - menu_options=["select_device", "manual"], - ) - return await self.async_step_homekey() - - def _build_selected_entries( - self, user_input: dict[str, Any] - ) -> list[dict[str, Any]]: - """Build config entry data for each selected shade address.""" - addresses: list[str] = user_input[CONF_ADDRESS] - if isinstance(addresses, str): - addresses = [addresses] - - entries: list[dict[str, Any]] = [] - for address in addresses: - device = self._discovered_devices[address] - ble_name = device.name - name = self._hub_name_for(ble_name) or ble_name - mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() - entry_data: dict[str, str] = { - "manufacturer_data": mfct_hex, - CONF_HOME_KEY: self._home_key, - } - if self._hub_url: - entry_data["hub_url"] = self._hub_url - entries.append( - { - "address": address, - "name": name, - "data": entry_data, - } - ) - return entries - - async def async_step_select_device( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Select one or more BLE-discovered shades, or fall through to manual.""" - LOGGER.debug("select_device step") - - if user_input is not None: - entries = self._build_selected_entries(user_input) - - # Kick off auto-add flows for all but the last shade - await asyncio.gather( - *( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "auto_add"}, - data=info, - ) - for info in entries[:-1] - ) - ) - - # Create the final entry normally (ends this flow) - last = entries[-1] - await self.async_set_unique_id(last["address"], raise_on_progress=False) - self._abort_if_unique_id_configured() - self._device_name = last["name"] - self._manufacturer_data_hex = last["data"]["manufacturer_data"] - self.context["title_placeholders"] = {"name": self._device_name} - return self._create_entry() - - current_addresses = self._async_current_ids() - for discovery_info in async_discovered_service_info(self.hass, False): - 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 - ) - - if not self._discovered_devices: - return await self.async_step_manual() - titles: list[SelectOptionDict] = [] - for address, discovery in self._discovered_devices.items(): - hub_name = self._hub_name_for(discovery.name) - label = f"{hub_name} ({discovery.name})" if hub_name else discovery.name - titles.append({"value": address, "label": label}) - - return self.async_show_form( - step_id="select_device", - data_schema=vol.Schema( - { - vol.Required(CONF_ADDRESS): SelectSelector( - SelectSelectorConfig(options=titles, multiple=True) - ) - } - ), - ) - - async def async_step_auto_add( - self, discovery_info: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a shade queued from multi-select for individual setup.""" - await self.async_set_unique_id(discovery_info["address"]) - self._abort_if_unique_id_configured() - - self._device_name = discovery_info["name"] - self._manufacturer_data_hex = discovery_info["data"]["manufacturer_data"] - self._home_key = discovery_info["data"].get(CONF_HOME_KEY, "") - self._hub_url = discovery_info["data"].get("hub_url", "") - - self.context["title_placeholders"] = {"name": self._device_name} - return await self.async_step_auto_add_confirm() - - async def async_step_auto_add_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm adding a shade discovered via multi-select.""" - if user_input is not None: - return self._create_entry() - - self._set_confirm_only() - return self.async_show_form( - step_id="auto_add_confirm", - description_placeholders={"name": self._device_name}, - ) - - async def async_step_manual( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle manual entry of a BLE device address and name.""" - if user_input is not None: - address = user_input[CONF_ADDRESS].upper().strip() - self._device_name = user_input["ble_name"].strip() - await self.async_set_unique_id(address, raise_on_progress=False) - self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"name": self._device_name} - self._manufacturer_data_hex = "" - return self._create_entry() - - return self.async_show_form( - step_id="manual", - data_schema=vol.Schema( - { - vol.Required(CONF_ADDRESS): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - vol.Required("ble_name"): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - } - ), - ) + # Only one hub entry allowed (per key, but for simplicity one total) + for entry in self._async_current_entries(): + if entry.version >= 2: + return self.async_abort(reason="single_instance_allowed") - async def async_step_homekey( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure homekey — collected before device selection.""" errors: dict[str, str] = {} if user_input is not None and await self._validate_homekey_input( user_input, errors ): - return await self.async_step_select_device() + unique_id = _hub_unique_id(self._home_key) + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self._create_entry() return self.async_show_form( - step_id="homekey", + step_id="user", data_schema=_HOMEKEY_SCHEMA, errors=errors, description_placeholders={ "hub_url_example": "http://powerview-g3.local", }, ) + diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index 0e02520..f8b0c1b 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -9,6 +9,10 @@ TIMEOUT: Final[int] = 5 CONF_HOME_KEY: Final[str] = "home_key" +CONF_HUB_URL: Final[str] = "hub_url" + +# dispatcher signal for newly discovered shades (format with entry_id) +SIGNAL_NEW_SHADE: Final[str] = f"{DOMAIN}_new_shade_{{entry_id}}" # attributes (do not change) ATTR_RSSI: Final[str] = "rssi" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 10aabb8..0fb07d5 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -54,10 +54,11 @@ def __init__( @property def type_id(self) -> int | None: - """Return the shade type ID from manufacturer data.""" + """Return the shade type ID from manufacturer data or live BLE data.""" if self._manuf_dat: return int(bytes.fromhex(self._manuf_dat)[2]) - return None + live = self.data.get("type_id") + return int(live) if live is not None else None @property def shade_capabilities(self) -> ShadeCapability: diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 3d463d7..6cd8591 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -16,24 +16,20 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ConfigEntryType, async_setup_shade_platform from .api import CLOSED_POSITION, OPEN_POSITION from .const import DOMAIN, LOGGER from .coordinator import PVCoordinator -async def async_setup_entry( - _hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, +def _add_entities( + coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the cover platform.""" - - coordinator: PVCoordinator = config_entry.runtime_data + """Create cover entities for a single shade coordinator.""" caps = coordinator.shade_capabilities if caps.tilt_only: @@ -46,6 +42,15 @@ async def async_setup_entry( async_add_entities(entities) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntryType, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the cover platform.""" + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) + + class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride] """Representation of a PowerView shade with Up/Down functionality only.""" diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index 47a475c..9680d6b 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -11,7 +11,7 @@ "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"], diff --git a/custom_components/hunterdouglas_powerview_ble/number.py b/custom_components/hunterdouglas_powerview_ble/number.py index 1d76d95..b92310c 100644 --- a/custom_components/hunterdouglas_powerview_ble/number.py +++ b/custom_components/hunterdouglas_powerview_ble/number.py @@ -9,20 +9,25 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ConfigEntryType +from . import ConfigEntryType, async_setup_shade_platform from .const import DOMAIN, LOGGER from .coordinator import PVCoordinator +def _add_entities( + coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback +) -> None: + """Create velocity number entity for a single shade coordinator.""" + async_add_entities([PowerViewVelocity(coordinator)]) + + async def async_setup_entry( - _hass: HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntryType, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the velocity number entity.""" - - coordinator: PVCoordinator = config_entry.runtime_data - async_add_entities([PowerViewVelocity(coordinator)]) + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) class PowerViewVelocity( diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index d19333d..124fc56 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ConfigEntryType +from . import ConfigEntryType, async_setup_shade_platform from .const import ATTR_RSSI, DOMAIN from .coordinator import PVCoordinator @@ -39,18 +39,25 @@ ] +def _add_entities( + coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback +) -> None: + """Create sensor entities for a single shade coordinator.""" + async_add_entities( + [ + PVSensor(coordinator, descr, format_mac(coordinator.address)) + for descr in SENSOR_TYPES + ] + ) + + async def async_setup_entry( - _hass: HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntryType, async_add_entities: AddEntitiesCallback, ) -> None: """Add sensors for passed config_entry in Home Assistant.""" - - pv_dev: PVCoordinator = config_entry.runtime_data - for descr in SENSOR_TYPES: - async_add_entities( - [PVSensor(pv_dev, descr, format_mac(config_entry.unique_id))] - ) + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) class PVSensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], SensorEntity): # type: ignore[reportIncompatibleMethodOverride] diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 5e3e3e7..0fccf5e 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -1,24 +1,10 @@ { "config": { - "flow_title": "Setup {name}", + "flow_title": "PowerView Home Setup", "step": { "user": { - "title": "Add PowerView Shade", - "menu_options": { - "select_device": "Select from discovered shades", - "manual": "Enter device details manually" - }, - "menu_option_descriptions": { - "select_device": "Choose from shades detected via Bluetooth nearby.", - "manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery." - } - }, - "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" - }, - "homekey": { - "title": "Configure HomeKey", - "description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.", + "title": "Set up PowerView Home", + "description": "Configure your PowerView encryption key. All shades on your network will be discovered automatically via Bluetooth.", "data": { "key_method": "Key source", "hub_url": "PowerView hub URL", @@ -28,37 +14,6 @@ "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } - }, - "homekey_bluetooth": { - "title": "Configure HomeKey for {name}", - "description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).", - "data": { - "key_method": "Key source", - "hub_url": "PowerView hub URL", - "home_key": "HomeKey (32 hex characters or \\xNN format)" - }, - "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", - "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" - } - }, - "auto_add_confirm": { - "description": "Do you want to set up {name}?" - }, - "select_device": { - "title": "Select Shades", - "description": "Select the PowerView shades to add via Bluetooth.", - "data": { - "address": "Shades" - } - }, - "manual": { - "title": "Enter Device Details", - "description": "Enter the device details manually.", - "data": { - "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", - "ble_name": "BLE device name (e.g. DUE:94ED)" - } } }, "error": { @@ -71,8 +26,7 @@ }, "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" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 7830431..4551c16 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -1,24 +1,10 @@ { "config": { - "flow_title": "Setup {name}", + "flow_title": "PowerView Home Setup", "step": { "user": { - "title": "Add PowerView Shade", - "menu_options": { - "select_device": "Select from discovered shades", - "manual": "Enter device details manually" - }, - "menu_option_descriptions": { - "select_device": "Choose from shades detected via Bluetooth nearby.", - "manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery." - } - }, - "bluetooth_confirm": { - "description": "Do you want to set up {name}?" - }, - "homekey": { - "title": "Configure HomeKey", - "description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.", + "title": "Set up PowerView Home", + "description": "Configure your PowerView encryption key. All shades on your network will be discovered automatically via Bluetooth.", "data": { "key_method": "Key source", "hub_url": "PowerView hub URL", @@ -28,34 +14,6 @@ "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } - }, - "homekey_bluetooth": { - "title": "Configure HomeKey for {name}", - "description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).", - "data": { - "key_method": "Key source", - "hub_url": "PowerView hub URL", - "home_key": "HomeKey (32 hex characters or \\xNN format)" - }, - "data_description": { - "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", - "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" - } - }, - "select_device": { - "title": "Select Shades", - "description": "Select the PowerView shades to add via Bluetooth.", - "data": { - "address": "Shades" - } - }, - "manual": { - "title": "Enter Device Details", - "description": "Enter the device details manually.", - "data": { - "address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)", - "ble_name": "BLE device name (e.g. DUE:94ED)" - } } }, "error": { @@ -68,8 +26,7 @@ }, "abort": { "already_configured": "Device is already configured", - "no_devices_found": "No devices found on the network", - "not_supported": "Device not supported" + "single_instance_allowed": "Already configured. Only a single configuration possible." } } } From 87bac49529744b2a088bf60d35acaed3db62b9e1 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 16:18:51 +1000 Subject: [PATCH 11/37] feat: add shade capability detection for top-down, TDBU, and duolite types --- custom_components/hunterdouglas_powerview_ble/api.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 65242cb..5368941 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -63,6 +63,9 @@ class ShadeCapability(NamedTuple): has_tilt: bool = False tilt_only: bool = False + is_top_down: bool = False # position logic is inverted (SkyLift style) + is_tdbu: bool = False # dual-rail Top Down Bottom Up (needs two entities) + is_duolite: bool = False # dual-fabric sheer+opaque (needs three entities) SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { @@ -71,6 +74,14 @@ class ShadeCapability(NamedTuple): 62: ShadeCapability(has_tilt=True), # tilt only (no position movement) 39: ShadeCapability(has_tilt=True, tilt_only=True), + # top-down only (inverted position: 100 = fully raised, 0 = fully lowered) + 10: ShadeCapability(is_top_down=True), + # dual-rail top-down/bottom-up (two independent rails → two entities) + 8: ShadeCapability(is_tdbu=True), + 33: ShadeCapability(is_tdbu=True), + 47: ShadeCapability(is_tdbu=True), + # duolite top-down/bottom-up (sheer front + opaque rear → three entities) + 9: ShadeCapability(is_tdbu=True, is_duolite=True), } _DEFAULT_CAPABILITY: Final[ShadeCapability] = ShadeCapability() From ce04907d589a944cc86dd34f8b8f8c08e47ec6ca Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 16:21:19 +1000 Subject: [PATCH 12/37] feat: add top-down cover entity with inverted position logic --- .../hunterdouglas_powerview_ble/cover.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index 6cd8591..3c6f283 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -36,6 +36,8 @@ def _add_entities( entities: list[PowerViewCover] = [PowerViewCoverTiltOnly(coordinator)] elif caps.has_tilt: entities = [PowerViewCoverTilt(coordinator)] + elif caps.is_top_down: + entities = [PowerViewCoverTopDown(coordinator)] else: entities = [PowerViewCover(coordinator)] @@ -262,6 +264,72 @@ async def async_close_cover_tilt(self, **kwargs: Any) -> None: await self.async_set_cover_tilt_position(**_kwargs) +class PowerViewCoverTopDown(PowerViewCover): + """Representation of a top-down PowerView shade. + + The device position axis is inverted: device 0 = open (fabric retracted), + device 100 = closed (fabric fully extended). We translate at the boundary + so HA's standard 0=closed / 100=open convention is preserved. + """ + + @property + def current_cover_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] + """Return current position, inverting the device axis.""" + pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) + return OPEN_POSITION - round(pos) if pos is not None else None + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position, inverting for the device.""" + target_position: Final = kwargs.get(ATTR_POSITION) + if target_position is not None: + inverted = OPEN_POSITION - round(target_position) + LOGGER.debug("set top-down cover to position %f (device %i)", target_position, inverted) + 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( + inverted, + velocity=self._coord.velocity, + ) + self.async_write_ha_state() + except BleakError as err: + LOGGER.error( + "Failed to move cover '%s' to %f%%: %s", + self.name, + target_position, + err, + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover (send device position 0).""" + LOGGER.debug("open top-down cover") + if self.current_cover_position == OPEN_POSITION: + return + try: + self._target_position = OPEN_POSITION + await self._coord.api.set_position(CLOSED_POSITION, velocity=self._coord.velocity) + 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 (send device position 100).""" + LOGGER.debug("close top-down cover") + if self.current_cover_position == CLOSED_POSITION: + return + try: + self._target_position = CLOSED_POSITION + await self._coord.api.set_position(OPEN_POSITION, velocity=self._coord.velocity) + self.async_write_ha_state() + except BleakError as err: + LOGGER.error("Failed to close cover '%s': %s", self.name, err) + self._reset_target_position() + + class PowerViewCoverTiltOnly(PowerViewCoverTilt): """Representation of a PowerView shade with additional tilt functionality.""" From 789f7167075a6fd6bc2ee6ef41da8a2cd4a3c4b4 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 16:36:29 +1000 Subject: [PATCH 13/37] fix: remove redundant code and some bug fixes --- .../hunterdouglas_powerview_ble/__init__.py | 2 -- .../hunterdouglas_powerview_ble/api.py | 32 +++++++++---------- .../hunterdouglas_powerview_ble/button.py | 8 +---- .../coordinator.py | 12 ++++--- .../hunterdouglas_powerview_ble/cover.py | 2 +- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index f0da803..fef2f61 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -200,8 +200,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntryType) -> boo unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - for coordinator in entry.runtime_data.values(): - coordinator._async_stop() # noqa: SLF001 entry.runtime_data.clear() LOGGER.debug("Unloaded config entry: %s, ok? %s!", entry.unique_id, str(unload_ok)) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5368941..58a361f 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -219,27 +219,27 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No raise @staticmethod - def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]: + def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: """Decode manufacturer data from BLE advertisement V2.""" if len(data) != 9: LOGGER.debug("not a V2 record!") - return [] + return {} pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4) - return [ - (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), - ("position2", pos2 >> 2), - ("position3", int(data[6])), - (ATTR_CURRENT_TILT_POSITION, int(data[7])), - ("home_id", int.from_bytes(data[0:2], byteorder="little")), - ("type_id", int(data[2])), - ("is_opening", bool(pos & 0x3 == 0x2)), - ("is_closing", bool(pos & 0x3 == 0x1)), - ("battery_charging", bool(pos & 0x3 == 0x3)), # observed - ("battery_level", POWER_LEVELS[(data[8] >> 6)]), # cannot hit 4 - ("resetMode", bool(data[8] & 0x1)), - ("resetClock", bool(data[8] & 0x2)), - ] + return { + ATTR_CURRENT_POSITION: (pos >> 2) / 10, + "position2": pos2 >> 2, + "position3": int(data[6]), + ATTR_CURRENT_TILT_POSITION: int(data[7]), + "home_id": int.from_bytes(data[0:2], byteorder="little"), + "type_id": int(data[2]), + "is_opening": bool(pos & 0x3 == 0x2), + "is_closing": bool(pos & 0x3 == 0x1), + "battery_charging": bool(pos & 0x3 == 0x3), # observed + "battery_level": POWER_LEVELS[(data[8] >> 6)], # cannot hit 4 + "resetMode": bool(data[8] & 0x1), + "resetClock": bool(data[8] & 0x2), + } # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity async def set_position( diff --git a/custom_components/hunterdouglas_powerview_ble/button.py b/custom_components/hunterdouglas_powerview_ble/button.py index 6826126..fcf1a3d 100644 --- a/custom_components/hunterdouglas_powerview_ble/button.py +++ b/custom_components/hunterdouglas_powerview_ble/button.py @@ -12,7 +12,7 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ConfigEntryType, async_setup_shade_platform @@ -50,7 +50,6 @@ class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEn """Representation of a powerview shade.""" _attr_has_entity_name = True - _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( self, @@ -66,11 +65,6 @@ def __init__( ) super().__init__(coordinator) - @property - def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableOverride] - """Return the device_info of the device.""" - return self._coord.device_info - async def async_press(self) -> None: """Handle the button press.""" LOGGER.debug("identify cover") diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 0fb07d5..e6e7a67 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -28,7 +28,6 @@ def __init__( ) -> None: """Initialize BMS data coordinator.""" assert ble_device.name is not None - self._mac = ble_device.address self._friendly_name = friendly_name or ble_device.name home_key_hex: str = data.get(CONF_HOME_KEY, "") home_key: bytes = ( @@ -99,7 +98,7 @@ def device_info(self) -> DeviceInfo: @property def device_present(self) -> bool: """Check if a device is present.""" - return bluetooth.async_address_present(self.hass, self._mac, connectable=True) + return bluetooth.async_address_present(self.hass, self.address, connectable=True) def _async_stop(self) -> None: """Shutdown coordinator and any connection.""" @@ -117,14 +116,17 @@ def _async_handle_bluetooth_event( LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data) self.api.set_ble_device(service_info.device) - self.data = {ATTR_RSSI: service_info.rssi} + new_data: dict[str, int | float | bool] = {ATTR_RSSI: service_info.rssi} if change == bluetooth.BluetoothChange.ADVERTISEMENT: - self.data.update( + new_data.update( self.api.dec_manufacturer_data( bytearray(service_info.manufacturer_data.get(2073, b"")) ) ) - self.api.encrypted = bool(self.data.get("home_id")) + self.api.encrypted = bool(new_data.get("home_id")) + if new_data == self.data: + return + self.data = new_data 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 3c6f283..c47af27 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -249,7 +249,7 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.async_stop_cover(kwargs=kwargs) + await self.async_stop_cover(**kwargs) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" From 0efca4ff52f181330731dab451cc2e3ee2396b0a Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:16:37 +1000 Subject: [PATCH 14/37] fix: just do a single discovery notification for all blinds --- .../config_flow.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index a0116c9..c217f61 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -21,7 +21,7 @@ TextSelectorType, ) -from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, LOGGER +from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, LOGGER, MFCT_ID def _hub_unique_id(home_key: str) -> str: @@ -92,6 +92,11 @@ async def _fetch_key_from_hub( Tries each shade on the hub until one returns a valid key. The key is network-wide so any reachable shade returns the same value. + The hub must establish a BLE connection to each shade before it can proxy + the key request. On the first pass that connection is often not yet open, + so the hub returns an error immediately. A second pass (after a short + pause to let the hub complete its BLE connections) reliably succeeds. + Raises ValueError on protocol/key errors. Raises aiohttp.ClientError on network errors. Raises asyncio.TimeoutError on timeout. @@ -247,11 +252,25 @@ async def async_step_bluetooth( """Handle a flow initialized by Bluetooth discovery.""" LOGGER.debug("Bluetooth device detected: %s", discovery_info) - # Tag the flow with this address so HA deduplicates future - # discovery flows for the same device - await self.async_set_unique_id(discovery_info.address) + # Derive a home-wide unique ID from the home_id embedded in the BLE + # advertisement (bytes 0-1 of the manufacturer payload). All shades on + # the same network share the same home_id, so HA deduplicates every + # subsequent shade discovery into this single flow via + # "already_in_progress" rather than spawning one notification per shade. + mfr_data = bytearray( + discovery_info.manufacturer_data.get(MFCT_ID, b"") + ) + if len(mfr_data) >= 2: + home_id = int.from_bytes(mfr_data[0:2], byteorder="little") + unique_id = f"pvhome_{home_id}" + else: + unique_id = DOMAIN + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() - # If a hub entry already exists, shades are auto-discovered + # If a hub entry already exists (unique_id may differ), shades are + # auto-discovered internally — nothing more for the user to do. for entry in self._async_current_entries(): if entry.version >= 2: return self.async_abort(reason="already_configured") From 8a6a17b767882d8ce3adc256119e583d59d2f385 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:25:33 +1000 Subject: [PATCH 15/37] fix: try the shade with the strongest signal first for the key --- custom_components/hunterdouglas_powerview_ble/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index c217f61..e26881b 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -111,7 +111,10 @@ async def _fetch_key_from_hub( if not shades: raise ValueError("No shades found on the hub") - ble_names = [s.get("bleName", "") for s in shades if s.get("bleName")] + # Sort by signal strength (strongest first) — a stronger signal means the + # hub is more likely to have an active BLE connection to that shade. + shades.sort(key=lambda s: s.get("signalStrength", -100), reverse=True) + ble_names = [s["bleName"] for s in shades if s.get("bleName")] if not ble_names: raise ValueError("No BLE-capable shades found on the hub") From 1b9aed4f89562bad6178b2bccbcb9633f84172ee Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:40:35 +1000 Subject: [PATCH 16/37] fix: add type 54 vertical blind support and remove spurious battery_charging control lock --- custom_components/hunterdouglas_powerview_ble/api.py | 4 +++- custom_components/hunterdouglas_powerview_ble/cover.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 58a361f..4922713 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -53,8 +53,9 @@ 33: "Duette Architella, Top Down Bottom Up", 39: "Parkland", 47: "Pleated, Top Down Bottom Up", - # top down, tilt anywhere + # tilt anywhere (position + tilt) 51: "Venetian, Tilt Anywhere", + 54: "Vertical Blind, Tilt", 62: "Venetian, Tilt Anywhere", } @@ -71,6 +72,7 @@ class ShadeCapability(NamedTuple): SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { # tilt anywhere (position + tilt) 51: ShadeCapability(has_tilt=True), + 54: ShadeCapability(has_tilt=True), 62: ShadeCapability(has_tilt=True), # tilt only (no position movement) 39: ShadeCapability(has_tilt=True, tilt_only=True), diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index c47af27..a39d968 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -110,9 +110,7 @@ def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] @property def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] """Flag supported features, disable control if encryption is needed.""" - if ( - self._coord.data.get("home_id") and not self._coord.api.has_key - ) or self._coord.data.get("battery_charging"): + if self._coord.data.get("home_id") and not self._coord.api.has_key: return CoverEntityFeature(0) return super().supported_features From 7936a4fb245e14e6722a7f8a384068d7fba874ad Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:48:54 +1000 Subject: [PATCH 17/37] fix: correct position bit extraction to prevent pos2 contamination on TDBU shades --- .../hunterdouglas_powerview_ble/api.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 4922713..aef46b9 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -226,18 +226,25 @@ def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: if len(data) != 9: LOGGER.debug("not a V2 record!") return {} - pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") + # data[3] lower 2 bits are status flags; pos is in bits 2-7 of data[3] + # and bits 0-3 of data[4]. Read flags before extracting position so + # the masking below doesn't accidentally overwrite them. + flags: Final[int] = data[3] & 0x3 + # Mask pos2 bits (upper nibble of data[4]) out before forming the + # 10-bit position value, otherwise a non-zero top-rail position on + # TDBU shades contaminates the bottom-rail reading. + pos: Final[int] = ((data[4] & 0x0F) << 6) | ((data[3] >> 2) & 0x3F) pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4) return { - ATTR_CURRENT_POSITION: (pos >> 2) / 10, + ATTR_CURRENT_POSITION: pos / 10, "position2": pos2 >> 2, "position3": int(data[6]), ATTR_CURRENT_TILT_POSITION: int(data[7]), "home_id": int.from_bytes(data[0:2], byteorder="little"), "type_id": int(data[2]), - "is_opening": bool(pos & 0x3 == 0x2), - "is_closing": bool(pos & 0x3 == 0x1), - "battery_charging": bool(pos & 0x3 == 0x3), # observed + "is_opening": bool(flags == 0x2), + "is_closing": bool(flags == 0x1), + "battery_charging": bool(flags == 0x3), # observed "battery_level": POWER_LEVELS[(data[8] >> 6)], # cannot hit 4 "resetMode": bool(data[8] & 0x1), "resetClock": bool(data[8] & 0x2), From 354d06b468b1ccde7c9b0053805a7926f2ec421a Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:54:11 +1000 Subject: [PATCH 18/37] feat: add missing shade types and capabilities from official aio-powerview-api --- .../hunterdouglas_powerview_ble/api.py | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index aef46b9..a0cc553 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -40,23 +40,38 @@ 6: "Duette", 10: "Duette and Applause SkyLift", 19: "Provenance Woven Wood", + 26: "Vertical", 31: "Vignette", 32: "Vignette", 42: "M25T Roller Blind", 49: "AC Roller", 52: "Banded Shades", 53: "Sonnette", + 57: "Carole Roman Shades", 84: "Vignette", - # top down bottom up + # top down (single rail, inverted position) + 7: "Top Down", + # top down bottom up (dual rail) 8: "Duette, Top Down Bottom Up", 9: "Duette DuoLite, Top Down Bottom Up", 33: "Duette Architella, Top Down Bottom Up", - 39: "Parkland", 47: "Pleated, Top Down Bottom Up", + # tilt only (no position movement) + 39: "Parkland", + 40: "Everwood Alternative Wood Blinds", + # tilt on closed + 18: "Bottom Up, Tilt on Closed 90°", + 44: "Twist", # tilt anywhere (position + tilt) 51: "Venetian, Tilt Anywhere", - 54: "Vertical Blind, Tilt", + 54: "Vertical Slats, Left Stack", + 55: "Vertical Slats, Right Stack", + 56: "Vertical Slats, Split Stack", 62: "Venetian, Tilt Anywhere", + # duolite (dual overlapping fabrics) + 38: "Dual Overlapped, Tilt 90°", + 65: "Dual Overlapped", + 95: "Dual Overlapped Illuminated", } class ShadeCapability(NamedTuple): @@ -73,17 +88,27 @@ class ShadeCapability(NamedTuple): # tilt anywhere (position + tilt) 51: ShadeCapability(has_tilt=True), 54: ShadeCapability(has_tilt=True), + 55: ShadeCapability(has_tilt=True), + 56: ShadeCapability(has_tilt=True), 62: ShadeCapability(has_tilt=True), # tilt only (no position movement) 39: ShadeCapability(has_tilt=True, tilt_only=True), - # top-down only (inverted position: 100 = fully raised, 0 = fully lowered) + 40: ShadeCapability(has_tilt=True, tilt_only=True), + # tilt on closed (tilt only available at fully closed position) + 18: ShadeCapability(has_tilt=True), + 44: ShadeCapability(has_tilt=True), + # top-down only (single rail, inverted position) + 7: ShadeCapability(is_top_down=True), 10: ShadeCapability(is_top_down=True), # dual-rail top-down/bottom-up (two independent rails → two entities) 8: ShadeCapability(is_tdbu=True), 33: ShadeCapability(is_tdbu=True), 47: ShadeCapability(is_tdbu=True), - # duolite top-down/bottom-up (sheer front + opaque rear → three entities) + # duolite (dual overlapping fabrics → three entities) 9: ShadeCapability(is_tdbu=True, is_duolite=True), + 38: ShadeCapability(is_duolite=True), + 65: ShadeCapability(is_duolite=True), + 95: ShadeCapability(is_duolite=True), } _DEFAULT_CAPABILITY: Final[ShadeCapability] = ShadeCapability() From deee1baad520b07b96caf1daa2e3e152808301d7 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sat, 11 Apr 2026 17:57:17 +1000 Subject: [PATCH 19/37] feat: add tilt-on-closed cover entity for types 18 and 44 --- .../hunterdouglas_powerview_ble/api.py | 5 +-- .../hunterdouglas_powerview_ble/cover.py | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index a0cc553..e11f755 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -79,6 +79,7 @@ class ShadeCapability(NamedTuple): has_tilt: bool = False tilt_only: bool = False + is_tilt_on_closed: bool = False # tilt only available when fully closed is_top_down: bool = False # position logic is inverted (SkyLift style) is_tdbu: bool = False # dual-rail Top Down Bottom Up (needs two entities) is_duolite: bool = False # dual-fabric sheer+opaque (needs three entities) @@ -95,8 +96,8 @@ class ShadeCapability(NamedTuple): 39: ShadeCapability(has_tilt=True, tilt_only=True), 40: ShadeCapability(has_tilt=True, tilt_only=True), # tilt on closed (tilt only available at fully closed position) - 18: ShadeCapability(has_tilt=True), - 44: ShadeCapability(has_tilt=True), + 18: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), + 44: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), # top-down only (single rail, inverted position) 7: ShadeCapability(is_top_down=True), 10: ShadeCapability(is_top_down=True), diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index a39d968..a37e40e 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -34,6 +34,8 @@ def _add_entities( if caps.tilt_only: entities: list[PowerViewCover] = [PowerViewCoverTiltOnly(coordinator)] + elif caps.is_tilt_on_closed: + entities = [PowerViewCoverTiltOnClosed(coordinator)] elif caps.has_tilt: entities = [PowerViewCoverTilt(coordinator)] elif caps.is_top_down: @@ -262,6 +264,35 @@ async def async_close_cover_tilt(self, **kwargs: Any) -> None: await self.async_set_cover_tilt_position(**_kwargs) +class PowerViewCoverTiltOnClosed(PowerViewCoverTilt): + """Representation of a PowerView shade whose tilt is only available when closed. + + Examples: Bottom Up 90° (type 18), Twist (type 44). + + If a tilt command arrives while the shade is open, the shade is closed first + so the tilt mechanism is engaged before the command is sent. + """ + + def __init__(self, coordinator: PVCoordinator) -> None: + """Initialize the shade.""" + LOGGER.debug("%s: init() PowerViewCoverTiltOnClosed", coordinator.name) + super().__init__(coordinator) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the tilt to a specific position, closing first if needed.""" + if self.current_cover_position != CLOSED_POSITION: + LOGGER.debug("tilt-on-closed: closing shade before tilting") + try: + self._target_position = CLOSED_POSITION + await self._coord.api.close(velocity=self._coord.velocity) + self.async_write_ha_state() + except BleakError as err: + LOGGER.error("Failed to close cover '%s' before tilt: %s", self.name, err) + self._reset_target_position() + return + await super().async_set_cover_tilt_position(**kwargs) + + class PowerViewCoverTopDown(PowerViewCover): """Representation of a top-down PowerView shade. From 548381a3db772e1525ad8f8933337b1634cdb864 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 19 Apr 2026 14:47:20 +1000 Subject: [PATCH 20/37] feat: create shade report script to dump more details on bytes --- scripts/shade_report.py | 412 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 scripts/shade_report.py diff --git a/scripts/shade_report.py b/scripts/shade_report.py new file mode 100644 index 0000000..6d6e7c6 --- /dev/null +++ b/scripts/shade_report.py @@ -0,0 +1,412 @@ +"""Standalone debug report for PowerView BLE shade(s). + +Talks directly to the shade over BLE (AES-CTR + GATT), with no coupling +to the Home Assistant integration code. Fetches the shade's homekey +from a G3 Gateway, scans for the BLE advertisement, then dumps: + 1. BLE advertisement (raw + decoded) + 2. Standard GATT device-info characteristics + 3. Read-only PowerView queries: + - 0xF1DD product info + - 0xFFDD HW diagnostics (contains serial / firmware / type / model) + - 0xFFDE power status (battery vs. hardwired) + +Dependencies: bleak, bleak-retry-connector, cryptography, requests (no HA) + +SAFETY: only the three read-only opcodes above are ever sent. No +set/move/scene/rekey/factory-reset opcodes are implemented here. + +Usage: + python scripts/shade_report.py --ble-name PV-XXXXXX + python scripts/shade_report.py --all + python scripts/shade_report.py --all --hub http://hub.local -v +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +# Allow "python scripts/shade_report.py" (the documented invocation) to resolve +# the sibling package import below: we prepend the project root to sys.path so +# `scripts.extract_gateway3_homekey` is importable regardless of CWD. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import argparse +import asyncio +import base64 +import contextlib +import json +import logging +import struct +from typing import Any, Self + +from bleak import BleakClient, BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from bleak.uuids import normalize_uuid_str +from bleak_retry_connector import establish_connection +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import requests + +from scripts.extract_gateway3_homekey import HUB, get_shade_key + +# --- PowerView BLE protocol --------------------------------------- +MFCT_ID = 2073 # Hunter Douglas BLE manufacturer identifier +UUID_TX = "cafe1001-c0ff-ee01-8000-a110ca7ab1e0" +UUID_COV_SERVICE = normalize_uuid_str("fdc1") +UUID_DEV_SERVICE = normalize_uuid_str("180a") + +# Standard GATT Device Information Service characteristics +GATT_DEV_INFO: list[tuple[str, str]] = [ + ("manufacturer", "2a29"), + ("model", "2a24"), + ("serial_nr", "2a25"), + ("hw_rev", "2a27"), + ("fw_rev", "2a26"), + ("sw_rev", "2a28"), +] + +# Read-only protocol queries ONLY. Never add opcodes that mutate state +# (e.g. 0x01F7 move, 0xFA5A set-scene, 0xFB02 rekey, 0xFFDF set-power- +# type, 0xFFEE factory-reset). +# +# Observed (fw_rev=22, Duette type 6, hardwired): +# 0xF1DD → 1-byte `04` payload. Possibly erroring (err code 4 = +# Invalid Length) or this firmware genuinely returns a stub. +# 0xFFDD → 1-byte `04` payload. Same caveat as F1DD. +# 0xFFDE → 8-byte payload `XX 01 pp ?? ?? ?? tt 00` where: +# byte 0 (XX) = power type — 0 = hardwired (confirmed); +# 1 = battery, 2 = rechargeable (hypothesis, +# unconfirmed — file firmware lacks a +# non-hardwired shade to sample). +# byte 2 (pp) = position % +# byte 6 (tt) = shade type_id (matches advertisement byte 2) +# 0xFFDE is the reliable source for battery-vs-hardwired +# detection. The advertisement's 2-bit level field maxes at +# 3 ("100% or hardwired") and cannot disambiguate the two. +GET_QUERIES: list[tuple[int, str]] = [ + (0xF1DD, "product info"), + (0xFFDD, "HW diagnostics"), + (0xFFDE, "power status"), +] + +POWER_LEVELS = {4: 100, 3: 100, 2: 50, 1: 20, 0: 0} + +SCAN_TIMEOUT = 30.0 +CMD_TIMEOUT = 30.0 +GATEWAY_TIMEOUT = 30 + + +# --- Gateway shade list -------------------------------------------- +def fetch_shade_list(hub: str) -> list[dict[str, Any]]: + """Return the list of shades registered with the G3 gateway.""" + resp = requests.get(f"{hub}/home/shades", timeout=GATEWAY_TIMEOUT) + resp.raise_for_status() + return json.loads(resp.content) + + +# --- BLE advertisement decode -------------------------------------- +def decode_adv(manuf: bytes) -> dict[str, Any] | None: + """Decode the 9-byte V2 advertisement payload.""" + if len(manuf) != 9: + return None + flags = manuf[3] & 0x3 + pos = ((manuf[4] & 0x0F) << 6) | ((manuf[3] >> 2) & 0x3F) + pos2 = (manuf[5] << 4) + (manuf[4] >> 4) + level_bits = (manuf[8] >> 6) & 0x3 + return { + "home_id": int.from_bytes(manuf[0:2], "little"), + "type_id": manuf[2], + "position": pos / 10, + "position2": pos2 >> 2, + "position3": manuf[6], + "tilt": manuf[7], + "opening": flags == 0x2, + "closing": flags == 0x1, + "charging_flag": flags == 0x3, + "level_bits": level_bits, + "level_pct": POWER_LEVELS.get(level_bits, "?"), + } + + +# --- BLE scanner helper -------------------------------------------- +async def find_shades( + names: set[str], timeout: float +) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Scan once, return the first advertisement seen for each named shade.""" + loop = asyncio.get_running_loop() + results: dict[str, tuple[BLEDevice, AdvertisementData]] = {} + done = asyncio.Event() + + def detected(dev: BLEDevice, adv: AdvertisementData) -> None: + if dev.name in names and dev.name not in results: + results[dev.name] = (dev, adv) + if len(results) == len(names): + loop.call_soon_threadsafe(done.set) + + scanner = BleakScanner(detection_callback=detected) + await scanner.start() + try: + await asyncio.wait_for(done.wait(), timeout=timeout) + except TimeoutError: + pass + finally: + await scanner.stop() + return results + + +# --- Minimal PowerView BLE client ---------------------------------- +class PowerViewClient: + """Talks the PowerView BLE protocol. + + Wire format (TX and RX, both encrypted with AES-128-CTR, IV = 16 zero + bytes, key = the shade's homekey): + + byte 0..1 command (little-endian uint16) + byte 2 sequence (TX: incremented; RX: echoed from TX) + byte 3 payload length + byte 4.. payload + + Both write and notify happen on the same UUID_TX characteristic. + """ + + def __init__(self, device: BLEDevice, home_key: bytes) -> None: + """Store connection parameters; actual connect happens in __aenter__.""" + self.device = device + self.client = BleakClient(device) + self.home_key = home_key + self.seq = 1 + self._rx: bytes = b"" + self._rx_event = asyncio.Event() + + async def __aenter__(self) -> Self: + """Connect over BLE and subscribe to the TX characteristic.""" + # establish_connection (bleak_retry_connector) retries automatically on + # transient WinRT errors and weak-signal cancellations, which plain + # BleakClient.connect() surfaces as TimeoutError. The services= filter + # tells the stack which GATT services to enumerate, speeding discovery + # on marginal links. Mirrors the integration's connect path in api.py. + self.client = await establish_connection( + BleakClient, + self.device, + self.device.name or "shade", + max_attempts=3, + services=[UUID_COV_SERVICE, UUID_DEV_SERVICE], + ) + await self.client.start_notify(UUID_TX, self._on_notify) + return self + + async def __aexit__(self, *_: object) -> None: + """Disconnect best-effort; errors here are not actionable.""" + with contextlib.suppress(Exception): + await self.client.disconnect() + + def _aes(self) -> Cipher[modes.CTR]: + return Cipher(algorithms.AES(self.home_key), modes.CTR(bytes(16))) + + def _on_notify(self, _sender: object, data: bytearray) -> None: + dec = self._aes().decryptor() + self._rx = dec.update(bytes(data)) + dec.finalize() + self._rx_event.set() + + async def read_gatt(self, uuid: str) -> bytes: + """Read a standard GATT characteristic by 16-bit UUID.""" + return bytes(await self.client.read_gatt_char(normalize_uuid_str(uuid))) + + async def query(self, cmd: int, data: bytes = b"") -> bytes: + """Send a read-only command, return the response payload.""" + # cmd is packed BIG-endian so the wire bytes appear in the order the + # shade expects: wire byte 0 = high byte of opcode, byte 1 = low byte. + # GET_QUERIES uses the emulator's case-label naming (0xF1DD, 0xFFDD, + # 0xFFDE), where byte 0 = 0xF1/0xFF and byte 1 = 0xDD/0xDE. The + # integration enum (api.py) uses pre-byte-swapped values with + # little-endian packing to reach the same wire bytes; here we keep + # the emulator naming and byte-swap at pack time instead. + tx = struct.pack(">HBB", cmd, self.seq, len(data)) + data + enc = self._aes().encryptor() + tx_enc = enc.update(tx) + enc.finalize() + self.seq = (self.seq + 1) & 0xFF + self._rx_event.clear() + await self.client.write_gatt_char(UUID_TX, tx_enc, response=False) + await asyncio.wait_for(self._rx_event.wait(), timeout=CMD_TIMEOUT) + if len(self._rx) < 4: + raise ValueError(f"Response too short: {self._rx.hex(' ')}") + payload_len = self._rx[3] + return self._rx[4 : 4 + payload_len] + + +# --- Report rendering ---------------------------------------------- +def _section(title: str) -> None: + print(f"\n {title}") + print(f" {'-' * len(title)}") + + +async def report_shade( + friendly_name: str, + ble_name: str, + home_key: bytes, + device: BLEDevice | None, + adv: AdvertisementData | None, +) -> None: + """Print the full report for a single shade: advertisement + GATT + queries.""" + print(f"\n=== '{friendly_name}' (BLE: {ble_name}) ===") + + _section("1. BLE advertisement") + if device is None or adv is None: + print(" not seen on air within scan window — skipping BLE queries") + return + print(f" address: {device.address}") + print(f" rssi: {adv.rssi} dBm") + manuf = adv.manufacturer_data.get(MFCT_ID, b"") + print(f" raw: {manuf.hex(' ') if manuf else '(none)'}") + decoded = decode_adv(manuf) + if decoded: + print(f" home_id: 0x{decoded['home_id']:04x}") + print(f" type_id: {decoded['type_id']}") + print( + f" position: {decoded['position']} " + f"pos2={decoded['position2']} pos3={decoded['position3']} " + f"tilt={decoded['tilt']}" + ) + print( + f" flags: opening={decoded['opening']} " + f"closing={decoded['closing']} " + f"charging_flag={decoded['charging_flag']}" + ) + print( + f" level: bits={decoded['level_bits']} " + f"(→ {decoded['level_pct']}%; cannot report hardwired=4)" + ) + + async with PowerViewClient(device, home_key) as api: + _section("2. GATT device info") + for key, uuid in GATT_DEV_INFO: + try: + value = (await api.read_gatt(uuid)).decode("utf-8", errors="replace") + print(f" {key:13} {value}") + except Exception as ex: # noqa: BLE001 + print(f" {key:13} ") + + _section("3. Protocol queries (read-only)") + for cmd, label in GET_QUERIES: + try: + payload = await api.query(cmd) + print( + f" 0x{cmd:04X} {label:16} " + f"({len(payload):2} B): {payload.hex(' ')}" + ) + except Exception as ex: # noqa: BLE001 + print(f" 0x{cmd:04X} {label:16} FAILED — {ex}") + break + + +# --- Main ---------------------------------------------------------- +PER_SHADE_TIMEOUT = 45.0 # hard cap on total time spent per shade's report + + +async def run( + hub: str, + targets: list[tuple[str, str]] | None, + all_shades: bool, + scan_timeout: float, + verbose: bool, +) -> int: + """Fetch the shade list, homekey, then report on each target.""" + if verbose: + logging.basicConfig(level=logging.DEBUG) + + # Always fetch the hub's full shade list: we need it regardless of + # whether targets came from --all or --ble-name, because the homekey + # fetch should use whichever shade the hub sees strongest, not + # necessarily one the user happens to be targeting locally. + print(f"Fetching shade list from {hub}...") + try: + shades = fetch_shade_list(hub) + except Exception as ex: # noqa: BLE001 + print(f" failed: {ex}") + return 1 + print(f" found {len(shades)} shade(s)") + + if all_shades: + targets = [ + (base64.b64decode(s["name"]).decode("utf-8"), s["bleName"]) + for s in shades + ] + assert targets is not None + + # Homekey is network-wide — fetch once from any shade the hub can + # reach. Try the hub's strongest-signal shades first to avoid + # burning the 10s timeout on one that's currently unreachable. + key_candidates = sorted( + shades, key=lambda s: s.get("signalStrength", -100), reverse=True + ) + print(f"\nFetching homekey from {hub} (one-time for the home)...") + home_key: bytes | None = None + for s in key_candidates: + ble = s["bleName"] + sig = s.get("signalStrength", "?") + try: + home_key = get_shade_key(hub, ble) + print(f" got it via {ble} (hub signal {sig}): {home_key.hex()}") + break + except Exception as ex: # noqa: BLE001 + print(f" {ble} (hub signal {sig}): {ex} — trying next shade...") + if home_key is None: + print("Could not fetch homekey from any shade. Aborting.") + return 1 + + name_set = {ble for _, ble in targets} + print( + f"\nScanning for {len(name_set)} shade(s) " + f"(up to {scan_timeout:.0f}s; tap a hardwired shade if not seen)..." + ) + seen = await find_shades(name_set, scan_timeout) + print(f" {len(seen)}/{len(name_set)} visible on air") + + for friendly, ble in targets: + dev, adv = seen.get(ble, (None, None)) + # Per-shade timeout so one unreachable shade can't stall the whole run. + try: + await asyncio.wait_for( + report_shade(friendly, ble, home_key, dev, adv), + timeout=PER_SHADE_TIMEOUT, + ) + except TimeoutError: + print(f" (shade {ble} exceeded {PER_SHADE_TIMEOUT:.0f}s — skipping)") + return 0 + + +def main() -> int: + """Parse CLI args and dispatch to the async report runner.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--hub", default=HUB, help=f"Gateway URL (default: {HUB})") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--ble-name", + action="append", + help="BLE name of a shade (repeatable), e.g. 'PV-XXXXXX'", + ) + group.add_argument( + "--all", action="store_true", help="Report on every shade known to the gateway" + ) + parser.add_argument( + "--scan-timeout", + type=float, + default=SCAN_TIMEOUT, + help=f"BLE scan timeout in seconds (default: {SCAN_TIMEOUT})", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable debug logging" + ) + args = parser.parse_args() + + targets: list[tuple[str, str]] | None = None + if args.ble_name: + targets = [(ble, ble) for ble in args.ble_name] + return asyncio.run( + run(args.hub, targets, args.all, args.scan_timeout, args.verbose) + ) + + +if __name__ == "__main__": + sys.exit(main()) From e7ee2aac03eb66799b31d99213aedbb328ee8140 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 20 Apr 2026 07:44:32 +1000 Subject: [PATCH 21/37] fix: update the share report script --- scripts/shade_report.py | 269 +++++++++++++++++++++++++++++++++------- 1 file changed, 222 insertions(+), 47 deletions(-) diff --git a/scripts/shade_report.py b/scripts/shade_report.py index 6d6e7c6..cb36d28 100644 --- a/scripts/shade_report.py +++ b/scripts/shade_report.py @@ -71,16 +71,28 @@ # type, 0xFFEE factory-reset). # # Observed (fw_rev=22, Duette type 6, hardwired): -# 0xF1DD → 1-byte `04` payload. Possibly erroring (err code 4 = -# Invalid Length) or this firmware genuinely returns a stub. -# 0xFFDD → 1-byte `04` payload. Same caveat as F1DD. -# 0xFFDE → 8-byte payload `XX 01 pp ?? ?? ?? tt 00` where: +# 0xF1DD → 1-byte `04` payload on len=0 input; 2-byte `8c 00` on len=1; +# `04` again for len=2/4/8. The `8c 00` response is IDENTICAL +# to what 0xFFDD returns at len=1, so it's a shared reject +# path, not real data. Conclusion: F1DD is either disabled or +# gated on specific non-zero parameter bytes we haven't found. +# Standard GATT chars (2a24-2a29) already give us the product +# info this opcode would have returned. +# 0xFFDD → same signature as F1DD: `04` at len∈{0,2,4,8}, `8c 00` at +# len=1. Same conclusion — superseded by GATT 180A service +# (manufacturer/model/serial/hw_rev/fw_rev/sw_rev). +# 0xFFDE → 8-byte payload `XX ?? ?? ?? ?? ?? tt 00` where: # byte 0 (XX) = power type — 0 = hardwired (confirmed); # 1 = battery, 2 = rechargeable (hypothesis, -# unconfirmed — file firmware lacks a -# non-hardwired shade to sample). -# byte 2 (pp) = position % +# unconfirmed — no non-hardwired shade +# sampled yet). # byte 6 (tt) = shade type_id (matches advertisement byte 2) +# Bytes 1-5 and 7 are unidentified. They are NOT the coarse +# battery % — that already lives in the advert's 2-bit level +# field (decoded above into `level_bits`). Candidates for +# the unknown bytes: pack-health %, cycle counter, calibration +# values, firmware constants. Reports from non-hardwired +# shades are needed to pin them down. # 0xFFDE is the reliable source for battery-vs-hardwired # detection. The advertisement's 2-bit level field maxes at # 3 ("100% or hardwired") and cannot disambiguate the two. @@ -92,6 +104,38 @@ POWER_LEVELS = {4: 100, 3: 100, 2: 50, 1: 20, 0: 0} +# PowerView BLE error-response shape (see api.py _verify_response): when a +# query's response has data_len=1, byte 4 is an error code rather than real +# payload. Expand this dict as users report codes from different firmwares. +PV_ERROR_CODES: dict[int, str] = { + 0x04: "invalid length (hypothesis)", +} + +# Observed two-byte error signatures. fw_rev=22 returns `8c 00` for both +# 0xF1DD and 0xFFDD with a 1-byte input — identical bytes across two +# semantically different opcodes, so this is a shared reject path rather +# than opcode-specific data. Suspected meaning: "unsupported / bad +# parameter", but not confirmed. +PV_ERROR_SIGNATURES_2B: dict[bytes, str] = { + b"\x8c\x00": "generic reject (hypothesis; fw_rev=22, F1DD+FFDD)", +} + +# 0xFFDE byte 0 — power type. 0 is confirmed hardwired on fw_rev=22. +# 1/2 are hypotheses pending sample data from battery/rechargeable shades. +POWER_TYPE_LABELS: dict[int, str] = { + 0: "hardwired", + 1: "battery (hypothesis)", + 2: "rechargeable (hypothesis)", +} + +# Emulator-confirmed success-response lengths (see emu/PV_BLE_cover.ino). +# Used to flag a real shade's 1-byte reply as "way shorter than expected". +EXPECTED_QUERY_LEN: dict[int, int] = { + 0xF1DD: 14, + 0xFFDD: 29, + 0xFFDE: 8, +} + SCAN_TIMEOUT = 30.0 CMD_TIMEOUT = 30.0 GATEWAY_TIMEOUT = 30 @@ -241,12 +285,157 @@ def _section(title: str) -> None: print(f" {'-' * len(title)}") +def annotate_query(cmd: int, payload: bytes) -> str | None: + """Return a one-line decode of a query response, or None if unknown. + + Printed beneath the raw hex so reporters see both. Everything + marked "hypothesis" still needs cross-shade confirmation — that's + the whole point of this report. + """ + expected = EXPECTED_QUERY_LEN.get(cmd) + # Shade returned a 1-byte reply where we know a longer one is valid: + # standard PowerView error-response shape — byte is an error code. + if expected and len(payload) == 1 and expected > 1: + err = payload[0] + note = PV_ERROR_CODES.get(err, "unknown error code") + return ( + f"likely error code 0x{err:02X} ({note}); " + f"emulator returns {expected} B on success" + ) + if cmd == 0xFFDE and len(payload) == 8: + pt = payload[0] + label = POWER_TYPE_LABELS.get(pt, f"unknown ({pt})") + # Only byte 0 (power type) and byte 6 (type_id) are identified. + # Battery % already comes from the advert's 2-bit level field, so + # byte 2 is NOT a battery-% duplicate. Bytes 1-5 and 7 remain + # unknown — plausible candidates include pack health %, cycle + # counters, calibration values, or firmware constants. Reports + # from non-hardwired shades should help disambiguate. + return ( + f"power_type={pt} ({label}), type_id={payload[6]} " + f"(cross-check with advert). Other bytes unidentified — " + f"share them verbatim to help decode." + ) + return None + + +# Payload lengths to try when --probe is set. Small, zero-filled inputs +# only — we are varying length to see which values the firmware accepts, +# not guessing parameter content. Kept short because longer sweeps take +# longer to run and each probe is an encrypted BLE round-trip. +PROBE_LENGTHS: list[int] = [0, 1, 2, 4, 8] + +# Opcodes we'll probe. MUST only contain documented read-only GETs; +# never add a SET/move/scene/rekey/reset opcode here (see SAFETY note in +# the module docstring). +PROBE_OPCODES: list[tuple[int, str]] = [ + (0xF1DD, "product info"), + (0xFFDD, "HW diagnostics"), +] + + +async def probe_opcode(api: PowerViewClient, cmd: int, label: str) -> None: + """Sweep zero-filled input payloads across PROBE_LENGTHS. + + Tag policy: `HIT` only when the response length matches what the + emulator returns on success (EXPECTED_QUERY_LEN). Anything shorter + is flagged `short?`; shorter-than-3 is almost certainly an error + shape — fw_rev=22 has been observed returning both 1-byte (`04`) + and 2-byte (`8c 00`) rejections, the latter being the *same* for + different opcodes, so a short payload is not proof of a real hit. + """ + expected = EXPECTED_QUERY_LEN.get(cmd, 0) + print(f" 0x{cmd:04X} {label} (expect {expected} B on success):") + for n in PROBE_LENGTHS: + data = bytes(n) + try: + payload = await api.query(cmd, data) + if expected and len(payload) == expected: + tag = "HIT " + elif len(payload) < 3: + tag = "error?" + else: + tag = "short?" + hex_display = payload.hex(" ") if payload else "(empty)" + print( + f" len={n:2} → " + f"{tag} ({len(payload):2} B): {hex_display}" + ) + except Exception as ex: # noqa: BLE001 + print(f" len={n:2} → FAILED — {ex}") + + +def _print_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: + """Print section 1: the BLE advertisement (raw + decoded).""" + print(f" address: {device.address}") + print(f" rssi: {adv.rssi} dBm") + manuf = adv.manufacturer_data.get(MFCT_ID, b"") + print(f" raw: {manuf.hex(' ') if manuf else '(none)'}") + decoded = decode_adv(manuf) + if not decoded: + return + print(f" home_id: 0x{decoded['home_id']:04x}") + print(f" type_id: {decoded['type_id']}") + print( + f" position: {decoded['position']} " + f"pos2={decoded['position2']} pos3={decoded['position3']} " + f"tilt={decoded['tilt']}" + ) + print( + f" flags: opening={decoded['opening']} " + f"closing={decoded['closing']} " + f"charging_flag={decoded['charging_flag']}" + ) + print( + f" level: bits={decoded['level_bits']} " + f"(→ {decoded['level_pct']}%; cannot report hardwired=4)" + ) + + +async def _print_gatt_info(api: PowerViewClient) -> None: + """Print section 2: standard GATT device-info characteristics.""" + for key, uuid in GATT_DEV_INFO: + try: + value = (await api.read_gatt(uuid)).decode("utf-8", errors="replace") + print(f" {key:13} {value}") + except Exception as ex: # noqa: BLE001 + print(f" {key:13} ") + + +async def _print_queries(api: PowerViewClient) -> None: + """Print section 3: read-only protocol queries.""" + for cmd, label in GET_QUERIES: + try: + payload = await api.query(cmd) + print( + f" 0x{cmd:04X} {label:16} " + f"({len(payload):2} B): {payload.hex(' ')}" + ) + note = annotate_query(cmd, payload) + if note: + print(f" → {note}") + except Exception as ex: # noqa: BLE001 + print(f" 0x{cmd:04X} {label:16} FAILED — {ex}") + break + + +async def _print_probe(api: PowerViewClient) -> None: + """Print section 4: payload-length sweep over read-only probe opcodes.""" + for cmd, label in PROBE_OPCODES: + try: + await probe_opcode(api, cmd, label) + except Exception as ex: # noqa: BLE001 + print(f" 0x{cmd:04X} probe aborted — {ex}") + break + + async def report_shade( friendly_name: str, ble_name: str, home_key: bytes, device: BLEDevice | None, adv: AdvertisementData | None, + probe: bool = False, ) -> None: """Print the full report for a single shade: advertisement + GATT + queries.""" print(f"\n=== '{friendly_name}' (BLE: {ble_name}) ===") @@ -255,49 +444,18 @@ async def report_shade( if device is None or adv is None: print(" not seen on air within scan window — skipping BLE queries") return - print(f" address: {device.address}") - print(f" rssi: {adv.rssi} dBm") - manuf = adv.manufacturer_data.get(MFCT_ID, b"") - print(f" raw: {manuf.hex(' ') if manuf else '(none)'}") - decoded = decode_adv(manuf) - if decoded: - print(f" home_id: 0x{decoded['home_id']:04x}") - print(f" type_id: {decoded['type_id']}") - print( - f" position: {decoded['position']} " - f"pos2={decoded['position2']} pos3={decoded['position3']} " - f"tilt={decoded['tilt']}" - ) - print( - f" flags: opening={decoded['opening']} " - f"closing={decoded['closing']} " - f"charging_flag={decoded['charging_flag']}" - ) - print( - f" level: bits={decoded['level_bits']} " - f"(→ {decoded['level_pct']}%; cannot report hardwired=4)" - ) + _print_advertisement(device, adv) async with PowerViewClient(device, home_key) as api: _section("2. GATT device info") - for key, uuid in GATT_DEV_INFO: - try: - value = (await api.read_gatt(uuid)).decode("utf-8", errors="replace") - print(f" {key:13} {value}") - except Exception as ex: # noqa: BLE001 - print(f" {key:13} ") + await _print_gatt_info(api) _section("3. Protocol queries (read-only)") - for cmd, label in GET_QUERIES: - try: - payload = await api.query(cmd) - print( - f" 0x{cmd:04X} {label:16} " - f"({len(payload):2} B): {payload.hex(' ')}" - ) - except Exception as ex: # noqa: BLE001 - print(f" 0x{cmd:04X} {label:16} FAILED — {ex}") - break + await _print_queries(api) + + if probe: + _section("4. Probe (payload-length sweep, read-only opcodes)") + await _print_probe(api) # --- Main ---------------------------------------------------------- @@ -310,6 +468,7 @@ async def run( all_shades: bool, scan_timeout: float, verbose: bool, + probe: bool = False, ) -> int: """Fetch the shade list, homekey, then report on each target.""" if verbose: @@ -368,7 +527,7 @@ async def run( # Per-shade timeout so one unreachable shade can't stall the whole run. try: await asyncio.wait_for( - report_shade(friendly, ble, home_key, dev, adv), + report_shade(friendly, ble, home_key, dev, adv, probe=probe), timeout=PER_SHADE_TIMEOUT, ) except TimeoutError: @@ -398,13 +557,29 @@ def main() -> int: parser.add_argument( "-v", "--verbose", action="store_true", help="Enable debug logging" ) + parser.add_argument( + "--probe", + action="store_true", + help=( + "Sweep payload lengths for 0xF1DD/0xFFDD to find what the " + "firmware accepts. Read-only; slow (one BLE round-trip per " + "length). Share the output to help decode these opcodes." + ), + ) args = parser.parse_args() targets: list[tuple[str, str]] | None = None if args.ble_name: targets = [(ble, ble) for ble in args.ble_name] return asyncio.run( - run(args.hub, targets, args.all, args.scan_timeout, args.verbose) + run( + args.hub, + targets, + args.all, + args.scan_timeout, + args.verbose, + probe=args.probe, + ) ) From 2d2723a512647c022146ebb889eaf916abefe31d Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 21 Apr 2026 13:45:31 +1000 Subject: [PATCH 22/37] fix: more updates to the share report script --- scripts/shade_report.py | 96 +++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/scripts/shade_report.py b/scripts/shade_report.py index cb36d28..b144ad4 100644 --- a/scripts/shade_report.py +++ b/scripts/shade_report.py @@ -81,28 +81,58 @@ # 0xFFDD → same signature as F1DD: `04` at len∈{0,2,4,8}, `8c 00` at # len=1. Same conclusion — superseded by GATT 180A service # (manufacturer/model/serial/hw_rev/fw_rev/sw_rev). -# 0xFFDE → 8-byte payload `XX ?? ?? ?? ?? ?? tt 00` where: -# byte 0 (XX) = power type — 0 = hardwired (confirmed); -# 1 = battery, 2 = rechargeable (hypothesis, -# unconfirmed — no non-hardwired shade -# sampled yet). -# byte 6 (tt) = shade type_id (matches advertisement byte 2) -# Bytes 1-5 and 7 are unidentified. They are NOT the coarse -# battery % — that already lives in the advert's 2-bit level -# field (decoded above into `level_bits`). Candidates for -# the unknown bytes: pack-health %, cycle counter, calibration -# values, firmware constants. Reports from non-hardwired -# shades are needed to pin them down. -# 0xFFDE is the reliable source for battery-vs-hardwired -# detection. The advertisement's 2-bit level field maxes at -# 3 ("100% or hardwired") and cannot disambiguate the two. +# 0xFFDE → 8-byte payload. Byte-by-byte findings on hardwired Duette +# (type 6, fw_rev=22), verified across four shades and ~10 +# targeted motion/idle experiments on one unit: +# b0 power_type 0=hardwired (confirmed); 1=battery, +# 2=rechargeable (hypothesis — no non- +# hardwired sample yet). +# b1 = 0x01 constant on all hardwired shades seen. +# Likely a power-type subvariant or flags +# byte; pending a non-hardwired sample. +# b2 = 0x64 (100) strong hypothesis: battery/power-level +# %, pegged at 100 on wall power. A +# battery shade should report its real % +# here. NOT a duplicate of the advert's +# 2-bit level field — this is the full +# byte-resolution value. +# b3 live / noisy byte — read-to-read drift +# of 3-9 counts with no physical change. +# Too jittery to be temperature; best +# guess is radio or ADC telemetry. Not +# practically useful without a burst- +# capture characterization. +# b4 = 0x07 constant on all hardwired shades seen. +# Likely capability or power-config flags. +# b5 persistent per-shade state. Observed +# to change exactly once across ~10 +# experiments on one unit (227 -> 205 +# during the first commanded move of a +# session). Each idle shade holds its +# own stable value (114/127/139/205 seen +# at position=100%). Triggers RULED OUT: +# every move, cold-start after 15-min +# idle, short-period timer, distance- +# gated motion snapshot, top & bottom +# end-stop calibration. Remaining +# candidates: boot-triggered state, +# long-period (hours+) timer, or hub- +# initiated refresh. +# b6 type_id matches advertisement byte 2 (confirmed). +# b7 = 0x00 reserved / padding on all observations. +# All b1-b7 findings are fw_rev=22 hardwired-only; re-verify +# on battery / rechargeable hardware before relying on them. +# 0xFFDE remains the only reliable source for battery-vs- +# hardwired detection (the advert's 2-bit level field caps +# at 3 = "100% OR hardwired" and cannot disambiguate). GET_QUERIES: list[tuple[int, str]] = [ (0xF1DD, "product info"), (0xFFDD, "HW diagnostics"), (0xFFDE, "power status"), ] -POWER_LEVELS = {4: 100, 3: 100, 2: 50, 1: 20, 0: 0} +# Advert level field is 2 bits, so only values 0-3 are observable on the wire. +POWER_LEVELS = {3: 100, 2: 50, 1: 20, 0: 0} # PowerView BLE error-response shape (see api.py _verify_response): when a # query's response has data_len=1, byte 4 is an error code rather than real @@ -305,16 +335,29 @@ def annotate_query(cmd: int, payload: bytes) -> str | None: if cmd == 0xFFDE and len(payload) == 8: pt = payload[0] label = POWER_TYPE_LABELS.get(pt, f"unknown ({pt})") - # Only byte 0 (power type) and byte 6 (type_id) are identified. - # Battery % already comes from the advert's 2-bit level field, so - # byte 2 is NOT a battery-% duplicate. Bytes 1-5 and 7 remain - # unknown — plausible candidates include pack health %, cycle - # counters, calibration values, or firmware constants. Reports - # from non-hardwired shades should help disambiguate. + # Per-byte interpretations as of 2026-04-21 investigation (see + # module-level comment for the experiment log). b0 and b6 are + # confirmed; b1/b2/b4/b7 are strong hypotheses based on four + # hardwired shades at fw_rev=22; b3 is a live noisy byte; b5 is + # persistent per-shade state with an unknown refresh trigger. + # 11-space indent on continuation lines aligns with the "→ " + # prefix added by _print_queries (9 spaces + arrow + space). + indent = " " return ( - f"power_type={pt} ({label}), type_id={payload[6]} " - f"(cross-check with advert). Other bytes unidentified — " - f"share them verbatim to help decode." + f"byte 0 = 0x{payload[0]:02X} → power_type={pt} ({label})\n" + f"{indent}byte 1 = 0x{payload[1]:02X} → power-subtype/flags " + f"(const 0x01 on hardwired; hypothesis)\n" + f"{indent}byte 2 = 0x{payload[2]:02X} ({payload[2]}) → " + f"battery/power level % (hypothesis — pegged at 100 on hardwired)\n" + f"{indent}byte 3 = 0x{payload[3]:02X} → live/noisy byte " + f"(drifts between reads; radio or ADC telemetry)\n" + f"{indent}byte 4 = 0x{payload[4]:02X} → capability/config flags " + f"(const 0x07 on hardwired; hypothesis)\n" + f"{indent}byte 5 = 0x{payload[5]:02X} → persistent per-shade state " + f"(rarely updates; refresh trigger unknown)\n" + f"{indent}byte 6 = 0x{payload[6]:02X} → type_id={payload[6]} " + f"(cross-check with advert)\n" + f"{indent}byte 7 = 0x{payload[7]:02X} → reserved/padding" ) return None @@ -387,8 +430,7 @@ def _print_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: f"charging_flag={decoded['charging_flag']}" ) print( - f" level: bits={decoded['level_bits']} " - f"(→ {decoded['level_pct']}%; cannot report hardwired=4)" + f" level: bits={decoded['level_bits']} (→ {decoded['level_pct']}%)" ) From 0d137ea509b04db9b32be2650cc47160c6fe638b Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 21 Apr 2026 18:19:37 +1000 Subject: [PATCH 23/37] feat: hide battery entities on hardwired shades via power_type detection Resolve power_type from the hub /home/shades response when available, with a one-time BLE 0xFFDE fallback for hub-less setups. Result is cached to the config entry so subsequent restarts skip the query. Sensor and binary_sensor platforms suppress battery_level / battery_charging on known-hardwired shades; unknown power_type stays inclusive so real battery data is never silently hidden. --- .../hunterdouglas_powerview_ble/__init__.py | 87 +++++++++++--- .../hunterdouglas_powerview_ble/api.py | 106 ++++++++++++++---- .../binary_sensor.py | 2 + .../hunterdouglas_powerview_ble/button.py | 4 +- .../config_flow.py | 19 ++-- .../hunterdouglas_powerview_ble/const.py | 11 ++ .../coordinator.py | 31 ++++- .../hunterdouglas_powerview_ble/cover.py | 18 ++- .../hunterdouglas_powerview_ble/number.py | 4 +- .../hunterdouglas_powerview_ble/sensor.py | 2 + 10 files changed, 222 insertions(+), 62 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index fef2f61..7db3e3e 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -4,8 +4,10 @@ @license: Apache-2.0 license """ +import asyncio import base64 from collections.abc import Callable +from dataclasses import dataclass import aiohttp from bleak.backends.device import BLEDevice @@ -30,9 +32,33 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import UUID_COV_SERVICE as UUID -from .const import CONF_HUB_URL, LOGGER, MFCT_ID, SIGNAL_NEW_SHADE +from .const import ( + CONF_HUB_URL, + CONF_POWER_TYPES, + LOGGER, + MFCT_ID, + SIGNAL_NEW_SHADE, + PowerType, +) from .coordinator import PVCoordinator +# Hub /home/shades powerType (aiopvapi Gen3: 1=hardwired, 2=battery, +# 3=rechargeable) → our internal PowerType (matches BLE 0xFFDE byte 0). +_HUB_POWERTYPE_TO_INTERNAL: dict[int, PowerType] = { + 1: PowerType.HARDWIRED, + 2: PowerType.BATTERY, + 3: PowerType.RECHARGEABLE, +} + + +@dataclass(frozen=True) +class ShadeMetadata: + """Per-shade metadata sourced from the G3 hub's /home/shades response.""" + + friendly_name: str + power_type: PowerType | None = None + + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -70,10 +96,10 @@ def _async_new_shade(coordinator: PVCoordinator) -> None: ) -async def _fetch_shade_names( +async def _fetch_shade_metadata( hass: HomeAssistant, hub_url: str -) -> dict[str, str]: - """Fetch BLE name -> friendly name mapping from the hub. +) -> dict[str, ShadeMetadata]: + """Fetch per-shade metadata (friendly name, power_type) from the hub. Returns empty dict on failure. """ @@ -86,7 +112,7 @@ async def _fetch_shade_names( except (TimeoutError, aiohttp.ClientError, ValueError): return {} - names: dict[str, str] = {} + metadata: dict[str, ShadeMetadata] = {} for shade in shades or []: ble_name = shade.get("bleName", "") if not ble_name: @@ -96,15 +122,19 @@ async def _fetch_shade_names( name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name except Exception: # noqa: BLE001 name = ble_name - names[ble_name] = name - return names + raw_pt = shade.get("powerType") + power_type = ( + _HUB_POWERTYPE_TO_INTERNAL.get(raw_pt) if isinstance(raw_pt, int) else None + ) + metadata[ble_name] = ShadeMetadata(friendly_name=name, power_type=power_type) + return metadata async def _async_setup_shade( hass: HomeAssistant, entry: ConfigEntryType, service_info: BluetoothServiceInfoBleak, - shade_names: dict[str, str], + shade_metadata: dict[str, ShadeMetadata], ) -> None: """Create a coordinator for a newly discovered shade.""" address = service_info.address @@ -119,15 +149,42 @@ async def _async_setup_shade( LOGGER.debug("BLE device %s not connectable, skipping", address) return - friendly_name = shade_names.get(service_info.name, service_info.name) + meta = shade_metadata.get(service_info.name) + friendly_name = meta.friendly_name if meta else service_info.name + cached_map: dict[str, int] = dict(entry.data.get(CONF_POWER_TYPES, {})) + power_type: PowerType | None = meta.power_type if meta else None + if power_type is None: + cached_value = cached_map.get(address) + if isinstance(cached_value, int): + try: + power_type = PowerType(cached_value) + except ValueError: + power_type = None coordinator = PVCoordinator( - hass, ble_device, entry.data.copy(), friendly_name + hass, ble_device, entry.data.copy(), friendly_name, power_type ) entry.runtime_data[address] = coordinator entry.async_on_unload(coordinator.async_start()) + # BLE fallback for hub-less (manual-key) setups: one-time query so the + # entity platforms (dispatched below) see the resolved value. + if coordinator.power_type is None and coordinator.api.has_key: + try: + await asyncio.wait_for(coordinator.query_power_type(), timeout=10) + except (BleakError, TimeoutError): + LOGGER.debug("power_type BLE fallback failed for %s", address) + + # Persist any newly-resolved power_type so hub-down restarts keep classification. + if coordinator.power_type is not None and cached_map.get(address) != int( + coordinator.power_type + ): + cached_map[address] = int(coordinator.power_type) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_POWER_TYPES: cached_map} + ) + async_dispatcher_send( hass, SIGNAL_NEW_SHADE.format(entry_id=entry.entry_id), @@ -151,11 +208,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool entry.runtime_data = {} - # Resolve shade friendly names from hub if available + # Resolve shade friendly names and power_type from hub if available hub_url = entry.data.get(CONF_HUB_URL, "") - shade_names: dict[str, str] = {} + shade_metadata: dict[str, ShadeMetadata] = {} if hub_url: - shade_names = await _fetch_shade_names(hass, hub_url) + shade_metadata = await _fetch_shade_metadata(hass, hub_url) # Forward platforms first so dispatched entities have their setup ready await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -167,7 +224,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool and UUID in service_info.service_uuids ): hass.async_create_task( - _async_setup_shade(hass, entry, service_info, shade_names) + _async_setup_shade(hass, entry, service_info, shade_metadata) ) # Register for future BLE discoveries @@ -177,7 +234,7 @@ def _async_discovered_device( ) -> None: if service_info.address not in entry.runtime_data: hass.async_create_task( - _async_setup_shade(hass, entry, service_info, shade_names) + _async_setup_shade(hass, entry, service_info, shade_metadata) ) entry.async_on_unload( diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index e11f755..1782769 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -22,7 +22,7 @@ ATTR_CURRENT_TILT_POSITION, ) -from .const import LOGGER, TIMEOUT +from .const import LOGGER, TIMEOUT, PowerType UUID_COV_SERVICE: Final[str] = normalize_uuid_str("fdc1") UUID_TX: Final[str] = "cafe1001-c0ff-ee01-8000-a110ca7ab1e0" @@ -74,6 +74,7 @@ 95: "Dual Overlapped Illuminated", } + class ShadeCapability(NamedTuple): """Capability flags for a shade type.""" @@ -126,8 +127,7 @@ def get_shade_capabilities(type_id: int | None) -> ShadeCapability: CLOSED_POSITION: Final[int] = 0 POWER_LEVELS: Final[dict[int, int]] = { - 4: 100, # 4 is hardwired - 3: 100, # 3 = 100% to 51% power remaining + 3: 100, # 3 = 100% to 51% power remaining (also reported by hardwired) 2: 50, # 2 = 50% to 21% power remaining 1: 20, # 1 = 20% or less power remaining 0: 0, # 0 = No power remaining @@ -141,6 +141,7 @@ class ShadeCmd(Enum): STOP = 0xB8F7 ACTIVATE_SCENE = 0xBAF7 IDENTIFY = 0x11F7 + POWER_STATUS = 0xDEFF @dataclass @@ -209,6 +210,28 @@ def is_connected(self) -> bool: """Return whether remote device is connected.""" return self._client.is_connected + async def _transact(self, cmd: tuple[ShadeCmd, bytes]) -> int: + # Assumes _cmd_lock is held and _connect() has run. Writes a framed + # (optionally encrypted) request and waits for the device's reply; + # caller inspects/validates self._data afterwards. Returns the seq + # number used so callers can verify the echo. + tx_data: bytes = bytes( + int.to_bytes(cmd[0].value, 2, byteorder="little") + + bytes([self._seqcnt, len(cmd[1])]) + + cmd[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) + seq = self._seqcnt + self._seqcnt += 1 + await asyncio.wait_for(self._wait_event(), timeout=TIMEOUT) + return seq + # general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> None: self._cmd_next = cmd @@ -220,23 +243,9 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No 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]) + seq = await self._transact(cmd_run) + self._verify_ack_reply(self._data, seq, cmd_run[0]) except TimeoutError as ex: raise TimeoutError("Device did not send confirmation.") from ex finally: @@ -246,6 +255,22 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No LOGGER.error("Error: %s - %s", type(ex).__name__, ex) raise + async def _query(self, cmd: tuple[ShadeCmd, bytes]) -> bytes: + """Send a read-type opcode and return its payload bytes.""" + async with self._cmd_lock: + await self._connect() + try: + try: + seq = await self._transact(cmd) + except TimeoutError as ex: + raise TimeoutError("Device did not send response.") from ex + if not self._verify_header(self._data, seq, cmd[0]): + raise BleakError("Malformed query response header") + length = int(self._data[3]) + return bytes(self._data[4 : 4 + length]) + finally: + await self._client.disconnect() + @staticmethod def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: """Decode manufacturer data from BLE advertisement V2.""" @@ -271,7 +296,7 @@ def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: "is_opening": bool(flags == 0x2), "is_closing": bool(flags == 0x1), "battery_charging": bool(flags == 0x3), # observed - "battery_level": POWER_LEVELS[(data[8] >> 6)], # cannot hit 4 + "battery_level": POWER_LEVELS[(data[8] >> 6)], "resetMode": bool(data[8] & 0x1), "resetClock": bool(data[8] & 0x2), } @@ -341,8 +366,8 @@ async def identify(self, beeps: int = 0x3) -> None: LOGGER.debug("%s identify (%i)", self.name, beeps) await self._cmd((ShadeCmd.IDENTIFY, bytes([min(beeps, 0xFF)]))) - def _verify_response(self, data: bytes, seq_nr: int, cmd: ShadeCmd) -> bool: - """Verify shade response data.""" + def _verify_header(self, data: bytes, seq_nr: int, cmd: ShadeCmd) -> bool: + """Verify common header fields (length, echoed opcode, seq match).""" if len(data) < 4: LOGGER.error("Response message too short") return False @@ -354,6 +379,12 @@ def _verify_response(self, data: bytes, seq_nr: int, cmd: ShadeCmd) -> bool: "Response sequence id %i wrong, expected %d", int(data[2]), seq_nr ) return False + return True + + def _verify_ack_reply(self, data: bytes, seq_nr: int, cmd: ShadeCmd) -> bool: + """Verify an ack-only reply (1-byte status payload, 0 == success).""" + if not self._verify_header(data, seq_nr, cmd): + return False if int(data[3]) != 1: LOGGER.error("Wrong response data length") return False @@ -393,6 +424,37 @@ async def query_dev_info(self) -> dict[str, str]: LOGGER.debug("%s device data: %s", self.name, data) return data.copy() + async def query_power_type(self) -> PowerType | None: + """Return the shade's power source, or None if unknown/unavailable.""" + if self._cipher is None: + return None + try: + payload = await self._query((ShadeCmd.POWER_STATUS, b"")) + except (BleakError, TimeoutError) as ex: + LOGGER.debug("%s: power_type query failed: %s", self.name, ex) + return None + + # A 1-byte reply is an error code (e.g. 0x04 = invalid length); it + # must NOT be returned as a power_type or it would be misread as + # PowerType.value=4. + if len(payload) == 1: + LOGGER.debug( + "%s: power_type query rejected (err=0x%02X)", self.name, payload[0] + ) + return None + if len(payload) == 8: + try: + return PowerType(payload[0]) + except ValueError: + LOGGER.debug( + "%s: unknown power_type byte 0x%02X", self.name, payload[0] + ) + return None + LOGGER.debug( + "%s: unexpected power_type payload length %d", self.name, len(payload) + ) + return None + def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index e487b67..38b8341 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -30,10 +30,12 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create binary sensor entities for a single shade coordinator.""" + include_battery = coordinator.show_battery_entities async_add_entities( [ PVBinarySensor(coordinator, descr, format_mac(coordinator.address)) for descr in BINARY_SENSOR_TYPES + if include_battery or descr.key != ATTR_BATTERY_CHARGING ] ) diff --git a/custom_components/hunterdouglas_powerview_ble/button.py b/custom_components/hunterdouglas_powerview_ble/button.py index fcf1a3d..b5ee2da 100644 --- a/custom_components/hunterdouglas_powerview_ble/button.py +++ b/custom_components/hunterdouglas_powerview_ble/button.py @@ -32,9 +32,7 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create button entities for a single shade coordinator.""" - async_add_entities( - [PowerViewButton(coordinator, descr) for descr in BUTTONS_SHADE] - ) + async_add_entities([PowerViewButton(coordinator, descr) for descr in BUTTONS_SHADE]) async def async_setup_entry( diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index e26881b..31cb580 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -69,9 +69,7 @@ def _parse_key_response(ble_name: str, result: dict) -> bytes | None: # noqa: P ) return None if response_bytes[4] != 0: - LOGGER.warning( - "Shade %s returned error status %d", ble_name, response_bytes[4] - ) + LOGGER.warning("Shade %s returned error status %d", ble_name, response_bytes[4]) return None key_data = response_bytes[5:] if len(key_data) != 16: @@ -84,9 +82,7 @@ def _parse_key_response(ble_name: str, result: dict) -> bytes | None: # noqa: P return key_data -async def _fetch_key_from_hub( - hass: HomeAssistant, hub_url: str -) -> bytes: +async def _fetch_key_from_hub(hass: HomeAssistant, hub_url: str) -> bytes: """Fetch 16-byte homekey from a PowerView G3 hub. Tries each shade on the hub until one returns a valid key. @@ -226,6 +222,12 @@ async def _validate_homekey_input( return True if method == "manual": + # Still capture a hub URL if the user provided one — the integration + # uses it to fetch shade metadata (friendly names, power_type) even + # when the homekey itself was supplied manually. + hub_url = user_input.get(CONF_HUB_URL, "").rstrip("/") + if hub_url: + self._hub_url = hub_url return self._validate_manual_key(user_input, errors) if method != "hub": @@ -260,9 +262,7 @@ async def async_step_bluetooth( # the same network share the same home_id, so HA deduplicates every # subsequent shade discovery into this single flow via # "already_in_progress" rather than spawning one notification per shade. - mfr_data = bytearray( - discovery_info.manufacturer_data.get(MFCT_ID, b"") - ) + mfr_data = bytearray(discovery_info.manufacturer_data.get(MFCT_ID, b"")) if len(mfr_data) >= 2: home_id = int.from_bytes(mfr_data[0:2], byteorder="little") unique_id = f"pvhome_{home_id}" @@ -310,4 +310,3 @@ async def async_step_user( "hub_url_example": "http://powerview-g3.local", }, ) - diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index f8b0c1b..736e61a 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -1,5 +1,6 @@ """Constants for the BLE Battery Management System integration.""" +from enum import IntEnum import logging from typing import Final @@ -10,6 +11,16 @@ CONF_HOME_KEY: Final[str] = "home_key" CONF_HUB_URL: Final[str] = "hub_url" +CONF_POWER_TYPES: Final[str] = "power_types" + + +class PowerType(IntEnum): + """Shade power source. Values match the BLE 0xFFDE reply's byte 0.""" + + HARDWIRED = 0 + BATTERY = 1 + RECHARGEABLE = 2 + # dispatcher signal for newly discovered shades (format with entry_id) SIGNAL_NEW_SHADE: Final[str] = f"{DOMAIN}_new_shade_{{entry_id}}" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index e6e7a67..01d9f3e 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities -from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER +from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER, PowerType class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -25,6 +25,7 @@ def __init__( ble_device: BLEDevice, data: dict[str, Any], friendly_name: str | None = None, + power_type: PowerType | None = None, ) -> None: """Initialize BMS data coordinator.""" assert ble_device.name is not None @@ -38,6 +39,7 @@ def __init__( self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} self.velocity: int = 0 + self._power_type: PowerType | None = power_type LOGGER.debug( "Initializing coordinator for %s (%s)", @@ -69,6 +71,25 @@ async def query_dev_info(self) -> None: LOGGER.debug("%s: querying device info", self.name) self.dev_details.update(await self.api.query_dev_info()) + @property + def power_type(self) -> PowerType | None: + """Return the shade's power source, or None if unknown.""" + return self._power_type + + @property + def show_battery_entities(self) -> bool: + """Whether battery-related entities should be created for this shade. + + Unknown power_type is treated as battery-ish so real battery data is + never silently hidden on unclassified shades. + """ + return self._power_type != PowerType.HARDWIRED + + async def query_power_type(self) -> None: + """Query the shade's power_type over BLE and cache the result.""" + LOGGER.debug("%s: querying power_type", self.name) + self._power_type = await self.api.query_power_type() + @property def device_info(self) -> DeviceInfo: """Return detailed device information for GUI.""" @@ -87,9 +108,7 @@ def device_info(self) -> DeviceInfo: if self.type_id is not None else None ), - model_id=( - str(self.type_id) if self.type_id is not None else None - ), + model_id=(str(self.type_id) if self.type_id is not None else None), serial_number=self.dev_details.get("serial_nr"), sw_version=self.dev_details.get("sw_rev"), hw_version=self.dev_details.get("hw_rev"), @@ -98,7 +117,9 @@ def device_info(self) -> DeviceInfo: @property def device_present(self) -> bool: """Check if a device is present.""" - return bluetooth.async_address_present(self.hass, self.address, connectable=True) + return bluetooth.async_address_present( + self.hass, self.address, connectable=True + ) def _async_stop(self) -> None: """Shutdown coordinator and any connection.""" diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index a37e40e..d2c30b5 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -287,7 +287,9 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: await self._coord.api.close(velocity=self._coord.velocity) self.async_write_ha_state() except BleakError as err: - LOGGER.error("Failed to close cover '%s' before tilt: %s", self.name, err) + LOGGER.error( + "Failed to close cover '%s' before tilt: %s", self.name, err + ) self._reset_target_position() return await super().async_set_cover_tilt_position(**kwargs) @@ -312,7 +314,11 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: target_position: Final = kwargs.get(ATTR_POSITION) if target_position is not None: inverted = OPEN_POSITION - round(target_position) - LOGGER.debug("set top-down cover to position %f (device %i)", target_position, inverted) + LOGGER.debug( + "set top-down cover to position %f (device %i)", + target_position, + inverted, + ) if self.current_cover_position == round(target_position) and not ( self.is_closing or self.is_opening ): @@ -339,7 +345,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: return try: self._target_position = OPEN_POSITION - await self._coord.api.set_position(CLOSED_POSITION, velocity=self._coord.velocity) + await self._coord.api.set_position( + CLOSED_POSITION, velocity=self._coord.velocity + ) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to open cover '%s': %s", self.name, err) @@ -352,7 +360,9 @@ async def async_close_cover(self, **kwargs: Any) -> None: return try: self._target_position = CLOSED_POSITION - await self._coord.api.set_position(OPEN_POSITION, velocity=self._coord.velocity) + await self._coord.api.set_position( + OPEN_POSITION, velocity=self._coord.velocity + ) self.async_write_ha_state() except BleakError as err: LOGGER.error("Failed to close cover '%s': %s", self.name, err) diff --git a/custom_components/hunterdouglas_powerview_ble/number.py b/custom_components/hunterdouglas_powerview_ble/number.py index b92310c..8e24995 100644 --- a/custom_components/hunterdouglas_powerview_ble/number.py +++ b/custom_components/hunterdouglas_powerview_ble/number.py @@ -48,9 +48,7 @@ def __init__(self, coordinator: PVCoordinator) -> None: """Initialize the velocity entity.""" self._coord = coordinator self._attr_device_info = self._coord.device_info - self._attr_unique_id = ( - f"{DOMAIN}_{format_mac(self._coord.address)}_velocity" - ) + self._attr_unique_id = f"{DOMAIN}_{format_mac(self._coord.address)}_velocity" super().__init__(coordinator) @property diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index 124fc56..0226016 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -43,10 +43,12 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create sensor entities for a single shade coordinator.""" + include_battery = coordinator.show_battery_entities async_add_entities( [ PVSensor(coordinator, descr, format_mac(coordinator.address)) for descr in SENSOR_TYPES + if include_battery or descr.key != ATTR_BATTERY_LEVEL ] ) From 697b12af730dd135af22b0522ced673bbdc0ae66 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Wed, 22 Apr 2026 18:11:31 +1000 Subject: [PATCH 24/37] fix: simplify shade report script --- scripts/shade_report.py | 255 +++++++++++++++++++++------------------- 1 file changed, 137 insertions(+), 118 deletions(-) diff --git a/scripts/shade_report.py b/scripts/shade_report.py index b144ad4..8b10a8f 100644 --- a/scripts/shade_report.py +++ b/scripts/shade_report.py @@ -5,15 +5,15 @@ from a G3 Gateway, scans for the BLE advertisement, then dumps: 1. BLE advertisement (raw + decoded) 2. Standard GATT device-info characteristics - 3. Read-only PowerView queries: - - 0xF1DD product info - - 0xFFDD HW diagnostics (contains serial / firmware / type / model) - - 0xFFDE power status (battery vs. hardwired) + 3. Decoded read-only PowerView queries: + - 0xFFDD sel=4 product info (firm update count, sw_rev) + - 0xFFDD sel=5 extended product info (serial, fw/sw/hw_rev, type) + - 0xFFDE power status (hardwired vs battery, level) Dependencies: bleak, bleak-retry-connector, cryptography, requests (no HA) -SAFETY: only the three read-only opcodes above are ever sent. No -set/move/scene/rekey/factory-reset opcodes are implemented here. +SAFETY: only the read-only opcodes above are ever sent. No set/move/ +scene/rekey/factory-reset opcodes are implemented here. Usage: python scripts/shade_report.py --ble-name PV-XXXXXX @@ -70,17 +70,21 @@ # (e.g. 0x01F7 move, 0xFA5A set-scene, 0xFB02 rekey, 0xFFDF set-power- # type, 0xFFEE factory-reset). # -# Observed (fw_rev=22, Duette type 6, hardwired): -# 0xF1DD → 1-byte `04` payload on len=0 input; 2-byte `8c 00` on len=1; -# `04` again for len=2/4/8. The `8c 00` response is IDENTICAL -# to what 0xFFDD returns at len=1, so it's a shared reject -# path, not real data. Conclusion: F1DD is either disabled or -# gated on specific non-zero parameter bytes we haven't found. -# Standard GATT chars (2a24-2a29) already give us the product -# info this opcode would have returned. -# 0xFFDD → same signature as F1DD: `04` at len∈{0,2,4,8}, `8c 00` at -# len=1. Same conclusion — superseded by GATT 180A service -# (manufacturer/model/serial/hw_rev/fw_rev/sw_rev). +# Decoded queries (fw_rev=22, Duette type 6, hardwired): +# 0xFFDD → product-info opcode gated on a 1-byte selector. sel=4 +# returns a 14-byte page (firm update count u32 LE at bytes +# 2-5, firm current version u16 LE at bytes 6-7 matching +# GATT sw_rev). sel=5 returns a 28-byte extended page with +# every field confirmed against GATT Device Information: +# serial (bytes 2-9, little-endian), fw_rev (10-11), sw_rev +# (14-15), hw_rev (18-21 u32 LE), build/mfg id (22-25), and +# type_id (26) matching the advertisement. Decoded in +# annotate_query. 0xF1DD returns the same sel=4 page and +# a truncated 22-byte sel=5 (no trailing 6 bytes); not +# queried separately since its data is a strict subset. +# The emulator's ret_valFFDD (29 B, labelled "HW +# diagnostics") is actually this extended product info with +# dummy data, off-by-one vs real firmware. # 0xFFDE → 8-byte payload. Byte-by-byte findings on hardwired Duette # (type 6, fw_rev=22), verified across four shades and ~10 # targeted motion/idle experiments on one unit: @@ -125,10 +129,16 @@ # 0xFFDE remains the only reliable source for battery-vs- # hardwired detection (the advert's 2-bit level field caps # at 3 = "100% OR hardwired" and cannot disambiguate). -GET_QUERIES: list[tuple[int, str]] = [ - (0xF1DD, "product info"), - (0xFFDD, "HW diagnostics"), - (0xFFDE, "power status"), +# (cmd, payload, label) — each entry is a query that returns real +# decoded data on fw_rev=22. 0xFFDD's two selectors are complementary: +# sel=4 carries firm update count + firm current version; sel=5 carries +# serial / fw_rev / sw_rev / hw_rev / build id / type_id. 0xF1DD is a +# strict subset of 0xFFDD (same sel=4 bytes; sel=5 drops the trailing 6) +# so it's not queried separately. +GET_QUERIES: list[tuple[int, bytes, str]] = [ + (0xFFDD, b"\x04", "product info (sel=4)"), + (0xFFDD, b"\x05", "ext product info (sel=5)"), + (0xFFDE, b"", "power status"), ] # Advert level field is 2 bits, so only values 0-3 are observable on the wire. @@ -141,15 +151,6 @@ 0x04: "invalid length (hypothesis)", } -# Observed two-byte error signatures. fw_rev=22 returns `8c 00` for both -# 0xF1DD and 0xFFDD with a 1-byte input — identical bytes across two -# semantically different opcodes, so this is a shared reject path rather -# than opcode-specific data. Suspected meaning: "unsupported / bad -# parameter", but not confirmed. -PV_ERROR_SIGNATURES_2B: dict[bytes, str] = { - b"\x8c\x00": "generic reject (hypothesis; fw_rev=22, F1DD+FFDD)", -} - # 0xFFDE byte 0 — power type. 0 is confirmed hardwired on fw_rev=22. # 1/2 are hypotheses pending sample data from battery/rechargeable shades. POWER_TYPE_LABELS: dict[int, str] = { @@ -158,12 +159,18 @@ 2: "rechargeable (hypothesis)", } -# Emulator-confirmed success-response lengths (see emu/PV_BLE_cover.ino). -# Used to flag a real shade's 1-byte reply as "way shorter than expected". -EXPECTED_QUERY_LEN: dict[int, int] = { - 0xF1DD: 14, - 0xFFDD: 29, - 0xFFDE: 8, +# Known-good response lengths per opcode. Used only by annotate_query +# to decide whether a 1-byte reply should be interpreted as an error +# code (i.e. "we expected a longer payload and got a single byte — that +# byte is the error"). 0xFFDD has two valid shapes because its two +# queried selectors return different lengths; 0xF1DD's 14- and 22-byte +# shapes are retained for completeness even though the script no longer +# queries that opcode directly. Emulator's 0xFFDD dummy is 29 B but +# real fw_rev=22 returns 28 B at sel=5 — treat emulator as off-by-one. +EXPECTED_QUERY_LEN: dict[int, set[int]] = { + 0xF1DD: {14, 22}, + 0xFFDD: {14, 28}, + 0xFFDE: {8}, } SCAN_TIMEOUT = 30.0 @@ -322,16 +329,100 @@ def annotate_query(cmd: int, payload: bytes) -> str | None: marked "hypothesis" still needs cross-shade confirmation — that's the whole point of this report. """ - expected = EXPECTED_QUERY_LEN.get(cmd) + expected = EXPECTED_QUERY_LEN.get(cmd, set()) # Shade returned a 1-byte reply where we know a longer one is valid: # standard PowerView error-response shape — byte is an error code. - if expected and len(payload) == 1 and expected > 1: + if expected and len(payload) == 1 and 1 not in expected: err = payload[0] note = PV_ERROR_CODES.get(err, "unknown error code") + good = ", ".join(str(n) for n in sorted(expected)) return ( f"likely error code 0x{err:02X} ({note}); " - f"emulator returns {expected} B on success" + f"known good lengths: {good} B" + ) + # Two-byte `8c XX` rejection: byte 0 is a fixed reject code, byte 1 + # echoes the bad input byte (observed for selector=0 and selector=3 + # on fw_rev=22, where the selector is the first byte of the query + # payload). Decoding this helps distinguish "bad selector" from + # other short error shapes. + if len(payload) == 2 and payload[0] == 0x8C: + return ( + f"rejection (0x8C) — selector 0x{payload[1]:02X} " + f"({payload[1]}) not supported by this opcode" ) + # 14-byte product-info page, returned by 0xF1DD and 0xFFDD when the + # payload's first byte is 0x04. The two opcodes return the same + # bytes here (sel=5 is where they diverge — see below). Confirmed + # on fw_rev=22: byte 1 echoes the selector (0x04), bytes 2-5 are a + # u32 counter (=1 on a freshly-imaged shade), and bytes 6-7 LE are + # the firmware version (matches GATT sw_rev exactly — the emulator + # also embeds SW_VERSION there in its ret_valF1DD dummy). Bytes + # 8-13 are zero on our samples; meaning unknown. + if cmd in (0xF1DD, 0xFFDD) and len(payload) == 14 and payload[1] == 0x04: + counter = int.from_bytes(payload[2:6], "little") + firm_current = int.from_bytes(payload[6:8], "little") + indent = " " + return ( + "14-byte product info (selector=0x04). Fields:\n" + f"{indent}byte 1 = 0x{payload[1]:02X} → echoed selector\n" + f"{indent}bytes 2-5 = {payload[2:6].hex(' ')} → " + f"counter={counter} (u32 LE; hypothesis — firm update count?)\n" + f"{indent}bytes 6-7 = {payload[6:8].hex(' ')} → firm current " + f"version={firm_current} (confirmed; matches GATT sw_rev)\n" + f"{indent}bytes 8-13 = {payload[8:14].hex(' ')} → unknown " + f"(zero on all samples)" + ) + # Extended product info (selector=0x05). 0xF1DD returns a 22-byte + # subset; 0xFFDD returns 28 bytes (same first 22 plus 4-byte build/ + # mfg counter + type_id + model byte). Every field below is + # confirmed against GATT Device Information on fw_rev=22: + # bytes 2-9 = 8-byte serial LE (matches GATT 0x2a25 hex-reversed) + # bytes 10-11 = u16 LE fw_rev (matches GATT 0x2a26) + # bytes 14-15 = u16 LE sw_rev (matches GATT 0x2a28) + # bytes 18-21 = u32 LE hw_rev (matches GATT 0x2a27) + # The FFDD-only tail is still provisional — byte 26 matches the + # advert's type_id; bytes 22-25 look like another 32-bit identifier + # (1015780 on the "Study" shade), and byte 27 is a model/family byte. + if ( + cmd in (0xF1DD, 0xFFDD) + and len(payload) in (22, 28) + and payload[1] == 0x05 + ): + serial_hex = payload[2:10][::-1].hex().upper() + fw_rev = int.from_bytes(payload[10:12], "little") + sw_rev = int.from_bytes(payload[14:16], "little") + hw_rev = int.from_bytes(payload[18:22], "little") + indent = " " + lines = [ + f"{len(payload)}-byte extended product info (selector=0x05). " + "Fields (all confirmed vs GATT):", + f"{indent}byte 1 = 0x{payload[1]:02X} → echoed selector", + f"{indent}bytes 2-9 = {payload[2:10].hex(' ')} → " + f"serial={serial_hex} (LE; matches GATT 0x2a25)", + f"{indent}bytes 10-11 = {payload[10:12].hex(' ')} → " + f"fw_rev={fw_rev} (matches GATT 0x2a26)", + f"{indent}bytes 12-13 = {payload[12:14].hex(' ')} → " + "reserved (zero on samples)", + f"{indent}bytes 14-15 = {payload[14:16].hex(' ')} → " + f"sw_rev={sw_rev} (matches GATT 0x2a28)", + f"{indent}bytes 16-17 = {payload[16:18].hex(' ')} → " + "reserved (zero on samples)", + f"{indent}bytes 18-21 = {payload[18:22].hex(' ')} → " + f"hw_rev={hw_rev} (matches GATT 0x2a27)", + ] + if len(payload) == 28: + build_id = int.from_bytes(payload[22:26], "little") + lines.extend( + [ + f"{indent}bytes 22-25 = {payload[22:26].hex(' ')} → " + f"build/mfg id={build_id} (u32 LE; hypothesis)", + f"{indent}byte 26 = 0x{payload[26]:02X} → " + f"type_id={payload[26]} (matches advert)", + f"{indent}byte 27 = 0x{payload[27]:02X} → " + "model/family byte (hypothesis)", + ] + ) + return "\n".join(lines) if cmd == 0xFFDE and len(payload) == 8: pt = payload[0] label = POWER_TYPE_LABELS.get(pt, f"unknown ({pt})") @@ -362,52 +453,6 @@ def annotate_query(cmd: int, payload: bytes) -> str | None: return None -# Payload lengths to try when --probe is set. Small, zero-filled inputs -# only — we are varying length to see which values the firmware accepts, -# not guessing parameter content. Kept short because longer sweeps take -# longer to run and each probe is an encrypted BLE round-trip. -PROBE_LENGTHS: list[int] = [0, 1, 2, 4, 8] - -# Opcodes we'll probe. MUST only contain documented read-only GETs; -# never add a SET/move/scene/rekey/reset opcode here (see SAFETY note in -# the module docstring). -PROBE_OPCODES: list[tuple[int, str]] = [ - (0xF1DD, "product info"), - (0xFFDD, "HW diagnostics"), -] - - -async def probe_opcode(api: PowerViewClient, cmd: int, label: str) -> None: - """Sweep zero-filled input payloads across PROBE_LENGTHS. - - Tag policy: `HIT` only when the response length matches what the - emulator returns on success (EXPECTED_QUERY_LEN). Anything shorter - is flagged `short?`; shorter-than-3 is almost certainly an error - shape — fw_rev=22 has been observed returning both 1-byte (`04`) - and 2-byte (`8c 00`) rejections, the latter being the *same* for - different opcodes, so a short payload is not proof of a real hit. - """ - expected = EXPECTED_QUERY_LEN.get(cmd, 0) - print(f" 0x{cmd:04X} {label} (expect {expected} B on success):") - for n in PROBE_LENGTHS: - data = bytes(n) - try: - payload = await api.query(cmd, data) - if expected and len(payload) == expected: - tag = "HIT " - elif len(payload) < 3: - tag = "error?" - else: - tag = "short?" - hex_display = payload.hex(" ") if payload else "(empty)" - print( - f" len={n:2} → " - f"{tag} ({len(payload):2} B): {hex_display}" - ) - except Exception as ex: # noqa: BLE001 - print(f" len={n:2} → FAILED — {ex}") - - def _print_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: """Print section 1: the BLE advertisement (raw + decoded).""" print(f" address: {device.address}") @@ -446,28 +491,18 @@ async def _print_gatt_info(api: PowerViewClient) -> None: async def _print_queries(api: PowerViewClient) -> None: """Print section 3: read-only protocol queries.""" - for cmd, label in GET_QUERIES: + for cmd, req, label in GET_QUERIES: try: - payload = await api.query(cmd) + resp = await api.query(cmd, req) print( - f" 0x{cmd:04X} {label:16} " - f"({len(payload):2} B): {payload.hex(' ')}" + f" 0x{cmd:04X} {label:25} " + f"({len(resp):2} B): {resp.hex(' ')}" ) - note = annotate_query(cmd, payload) + note = annotate_query(cmd, resp) if note: print(f" → {note}") except Exception as ex: # noqa: BLE001 - print(f" 0x{cmd:04X} {label:16} FAILED — {ex}") - break - - -async def _print_probe(api: PowerViewClient) -> None: - """Print section 4: payload-length sweep over read-only probe opcodes.""" - for cmd, label in PROBE_OPCODES: - try: - await probe_opcode(api, cmd, label) - except Exception as ex: # noqa: BLE001 - print(f" 0x{cmd:04X} probe aborted — {ex}") + print(f" 0x{cmd:04X} {label:25} FAILED — {ex}") break @@ -477,7 +512,6 @@ async def report_shade( home_key: bytes, device: BLEDevice | None, adv: AdvertisementData | None, - probe: bool = False, ) -> None: """Print the full report for a single shade: advertisement + GATT + queries.""" print(f"\n=== '{friendly_name}' (BLE: {ble_name}) ===") @@ -495,10 +529,6 @@ async def report_shade( _section("3. Protocol queries (read-only)") await _print_queries(api) - if probe: - _section("4. Probe (payload-length sweep, read-only opcodes)") - await _print_probe(api) - # --- Main ---------------------------------------------------------- PER_SHADE_TIMEOUT = 45.0 # hard cap on total time spent per shade's report @@ -510,7 +540,6 @@ async def run( all_shades: bool, scan_timeout: float, verbose: bool, - probe: bool = False, ) -> int: """Fetch the shade list, homekey, then report on each target.""" if verbose: @@ -569,7 +598,7 @@ async def run( # Per-shade timeout so one unreachable shade can't stall the whole run. try: await asyncio.wait_for( - report_shade(friendly, ble, home_key, dev, adv, probe=probe), + report_shade(friendly, ble, home_key, dev, adv), timeout=PER_SHADE_TIMEOUT, ) except TimeoutError: @@ -599,15 +628,6 @@ def main() -> int: parser.add_argument( "-v", "--verbose", action="store_true", help="Enable debug logging" ) - parser.add_argument( - "--probe", - action="store_true", - help=( - "Sweep payload lengths for 0xF1DD/0xFFDD to find what the " - "firmware accepts. Read-only; slow (one BLE round-trip per " - "length). Share the output to help decode these opcodes." - ), - ) args = parser.parse_args() targets: list[tuple[str, str]] | None = None @@ -620,7 +640,6 @@ def main() -> int: args.all, args.scan_timeout, args.verbose, - probe=args.probe, ) ) From 76e0dca0b462ceeac1aaf59123a650212d114be6 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Thu, 23 Apr 2026 07:39:21 +1000 Subject: [PATCH 25/37] fix: restore sw, hw and fw information to the device page --- .../hunterdouglas_powerview_ble/__init__.py | 22 ++++--- .../coordinator.py | 61 ++++++++++++++++++- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index 7db3e3e..c9b5d0b 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -185,22 +185,24 @@ async def _async_setup_shade( entry, data={**entry.data, CONF_POWER_TYPES: cached_map} ) - async_dispatcher_send( - hass, - SIGNAL_NEW_SHADE.format(entry_id=entry.entry_id), - coordinator, - ) - - # Query device info in background — don't block entry setup + # Populate dev_details before entity dispatch so the device registers with + # firmware/serial on first creation — the HA registry doesn't re-read + # DeviceInfo later. Failures are retried from the advertisement handler. try: await coordinator.query_dev_info() - except BleakError: - LOGGER.warning( - "Could not query device info for %s (%s)", + except (BleakError, TimeoutError): + LOGGER.debug( + "Initial device info query failed for %s (%s); will retry via adverts", friendly_name, address, ) + async_dispatcher_send( + hass, + SIGNAL_NEW_SHADE.format(entry_id=entry.entry_id), + coordinator, + ) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: """Set up PowerView Home from a config entry.""" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 01d9f3e..14df890 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -1,8 +1,11 @@ """Home Assistant coordinator for Hunter Douglas PowerView (BLE) integration.""" -from typing import Any +import asyncio +import time +from typing import Any, Final from bleak.backends.device import BLEDevice +from bleak.exc import BleakError from homeassistant.components import bluetooth from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN @@ -10,6 +13,7 @@ PassiveBluetoothDataUpdateCoordinator, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities @@ -19,6 +23,11 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): """Update coordinator for a battery management system.""" + # Firmware isn't in the advert — pull it via GATT. Retry every minute + # while empty, then re-check daily so firmware upgrades eventually surface. + _DEV_INFO_REFRESH_S: Final[float] = 24 * 3600 + _DEV_INFO_RETRY_S: Final[float] = 60 + def __init__( self, hass: HomeAssistant, @@ -40,6 +49,8 @@ def __init__( self.dev_details: dict[str, str] = {} self.velocity: int = 0 self._power_type: PowerType | None = power_type + self._last_dev_info_at: float = 0.0 + self._dev_info_task: asyncio.Task[None] | None = None LOGGER.debug( "Initializing coordinator for %s (%s)", @@ -67,9 +78,50 @@ def shade_capabilities(self) -> ShadeCapability: return get_shade_capabilities(self.type_id) async def query_dev_info(self) -> None: - """Receive detailed information from device.""" + """Fetch device info over GATT and push into the device registry. + + The HA device registry does not re-read DeviceInfo when the underlying + data changes, so we update it explicitly when new details arrive. + """ LOGGER.debug("%s: querying device info", self.name) - self.dev_details.update(await self.api.query_dev_info()) + self._last_dev_info_at = time.monotonic() + new = await self.api.query_dev_info() + if new and new != self.dev_details: + self.dev_details.update(new) + self._push_device_registry_update() + + def _push_device_registry_update(self) -> None: + reg = dr.async_get(self.hass) + device = reg.async_get_device(identifiers={(DOMAIN, self.address)}) + if device is None: + return + reg.async_update_device( + device.id, + sw_version=self.dev_details.get("sw_rev"), + hw_version=self.dev_details.get("hw_rev"), + serial_number=self.dev_details.get("serial_nr"), + ) + + async def _refresh_dev_info_safe(self) -> None: + try: + await self.query_dev_info() + except (BleakError, TimeoutError) as ex: + LOGGER.debug("%s: dev_info refresh failed: %s", self.name, ex) + + def _maybe_refresh_dev_info(self) -> None: + """Schedule a background dev_info refresh if stale.""" + if self._dev_info_task is not None and not self._dev_info_task.done(): + return + interval = ( + self._DEV_INFO_REFRESH_S + if self.dev_details.get("sw_rev") + else self._DEV_INFO_RETRY_S + ) + if time.monotonic() - self._last_dev_info_at < interval: + return + self._dev_info_task = self.hass.async_create_background_task( + self._refresh_dev_info_safe(), name=f"pvble_dev_info_{self.address}" + ) @property def power_type(self) -> PowerType | None: @@ -124,6 +176,8 @@ def device_present(self) -> bool: def _async_stop(self) -> None: """Shutdown coordinator and any connection.""" LOGGER.debug("%s: shutting down BMS device", self.name) + if self._dev_info_task is not None and not self._dev_info_task.done(): + self._dev_info_task.cancel() self.hass.async_create_task(self.api.disconnect()) super()._async_stop() @@ -145,6 +199,7 @@ def _async_handle_bluetooth_event( ) ) self.api.encrypted = bool(new_data.get("home_id")) + self._maybe_refresh_dev_info() if new_data == self.data: return From 8ed3596ab2120f7fb530b81ec584365d4c3bd938 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 3 May 2026 12:47:09 +1000 Subject: [PATCH 26/37] fix: persist friendly names if hub offline and update names if hub names change --- .../hunterdouglas_powerview_ble/__init__.py | 91 +++++++++++++++++-- .../hunterdouglas_powerview_ble/const.py | 1 + 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index c9b5d0b..7a67436 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -24,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -33,8 +34,10 @@ from .api import UUID_COV_SERVICE as UUID from .const import ( + CONF_FRIENDLY_NAMES, CONF_HUB_URL, CONF_POWER_TYPES, + DOMAIN, LOGGER, MFCT_ID, SIGNAL_NEW_SHADE, @@ -130,6 +133,50 @@ async def _fetch_shade_metadata( return metadata +def _persist_cache_entry( + hass: HomeAssistant, + entry: ConfigEntryType, + key: str, + address: str, + value: object, +) -> None: + """Read-modify-write a per-address value into a cache stored on entry.data. + + HA replaces entry.data atomically, so concurrent setup tasks each pull + the latest snapshot before merging — the no-op guard prevents writes + that would just notify listeners with unchanged data. + """ + cache = dict(entry.data.get(key, {})) + if cache.get(address) == value: + return + cache[address] = value + hass.config_entries.async_update_entry(entry, data={**entry.data, key: cache}) + + +def _resolve_friendly_name( + hass: HomeAssistant, + entry: ConfigEntryType, + service_info: BluetoothServiceInfoBleak, + meta: ShadeMetadata | None, +) -> str: + """Resolve a shade's friendly name (Shelly-style) and refresh the cache. + + Hub data wins; otherwise fall back to the cached value from a prior + successful resolution; otherwise fall back to the BLE advert name. + """ + address = service_info.address + cached_names: dict[str, str] = entry.data.get(CONF_FRIENDLY_NAMES, {}) + if meta is not None: + friendly_name = meta.friendly_name + elif address in cached_names: + friendly_name = cached_names[address] + else: + friendly_name = service_info.name or address + + _persist_cache_entry(hass, entry, CONF_FRIENDLY_NAMES, address, friendly_name) + return friendly_name + + async def _async_setup_shade( hass: HomeAssistant, entry: ConfigEntryType, @@ -150,11 +197,11 @@ async def _async_setup_shade( return meta = shade_metadata.get(service_info.name) - friendly_name = meta.friendly_name if meta else service_info.name - cached_map: dict[str, int] = dict(entry.data.get(CONF_POWER_TYPES, {})) + friendly_name = _resolve_friendly_name(hass, entry, service_info, meta) + power_type: PowerType | None = meta.power_type if meta else None if power_type is None: - cached_value = cached_map.get(address) + cached_value = entry.data.get(CONF_POWER_TYPES, {}).get(address) if isinstance(cached_value, int): try: power_type = PowerType(cached_value) @@ -177,12 +224,9 @@ async def _async_setup_shade( LOGGER.debug("power_type BLE fallback failed for %s", address) # Persist any newly-resolved power_type so hub-down restarts keep classification. - if coordinator.power_type is not None and cached_map.get(address) != int( - coordinator.power_type - ): - cached_map[address] = int(coordinator.power_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_POWER_TYPES: cached_map} + if coordinator.power_type is not None: + _persist_cache_entry( + hass, entry, CONF_POWER_TYPES, address, int(coordinator.power_type) ) # Populate dev_details before entity dispatch so the device registers with @@ -254,6 +298,35 @@ def _async_discovered_device( return True +async def async_remove_config_entry_device( + hass: HomeAssistant, + entry: ConfigEntryType, + device_entry: dr.DeviceEntry, +) -> bool: + """Allow user-driven device removal; purge cached state for that address.""" + addresses = {ident[1] for ident in device_entry.identifiers if ident[0] == DOMAIN} + if not addresses: + return True + + new_data = dict(entry.data) + for key in (CONF_FRIENDLY_NAMES, CONF_POWER_TYPES): + cache = dict(new_data.get(key, {})) + for addr in addresses: + cache.pop(addr, None) + new_data[key] = cache + hass.config_entries.async_update_entry(entry, data=new_data) + + for addr in addresses: + coord = entry.runtime_data.pop(addr, None) + if coord is not None: + # _async_stop is a parent-class (DataUpdateCoordinator) convention; + # entry.async_on_unload only fires on full entry unload, so we + # invoke it directly here for per-device removal. + coord._async_stop() # noqa: SLF001 + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index 736e61a..8480df2 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -12,6 +12,7 @@ CONF_HOME_KEY: Final[str] = "home_key" CONF_HUB_URL: Final[str] = "hub_url" CONF_POWER_TYPES: Final[str] = "power_types" +CONF_FRIENDLY_NAMES: Final[str] = "friendly_names" class PowerType(IntEnum): From e5e0a0abda1c9aed736529a6f96f93b7ac77abd6 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 5 May 2026 14:54:31 +1000 Subject: [PATCH 27/37] feat: add reset clock and reset mode diagnostic sensors for battery powered blinds --- .../hunterdouglas_powerview_ble/api.py | 4 +-- .../binary_sensor.py | 31 ++++++++++++++----- .../hunterdouglas_powerview_ble/strings.json | 10 ++++++ .../translations/en.json | 10 ++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 1782769..2920564 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -297,8 +297,8 @@ def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: "is_closing": bool(flags == 0x1), "battery_charging": bool(flags == 0x3), # observed "battery_level": POWER_LEVELS[(data[8] >> 6)], - "resetMode": bool(data[8] & 0x1), - "resetClock": bool(data[8] & 0x2), + "reset_mode": bool(data[8] & 0x1), + "reset_clock": bool(data[8] & 0x2), } # 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 38b8341..f8be25e 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,24 @@ key=ATTR_BATTERY_CHARGING, translation_key=ATTR_BATTERY_CHARGING, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - ) + ), + # Diagnostic flags from byte 8 of the BLE advertisement. They surface when + # a battery wand has been removed (charging) and the shade has lost its + # RTC / scheduling state — see the "shade unresponsive after charging" + # known issue. Disabled by default to keep the device page clean for + # users who aren't debugging this scenario. + BinarySensorEntityDescription( + key="reset_clock", + translation_key="reset_clock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="reset_mode", + translation_key="reset_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), ] @@ -30,13 +47,11 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create binary sensor entities for a single shade coordinator.""" - include_battery = coordinator.show_battery_entities + if not coordinator.show_battery_entities: + return + mac = format_mac(coordinator.address) async_add_entities( - [ - PVBinarySensor(coordinator, descr, format_mac(coordinator.address)) - for descr in BINARY_SENSOR_TYPES - if include_battery or descr.key != ATTR_BATTERY_CHARGING - ] + PVBinarySensor(coordinator, descr, mac) for descr in BINARY_SENSOR_TYPES ) diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 0fccf5e..a757c36 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -28,5 +28,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "binary_sensor": { + "reset_clock": { + "name": "Clock reset" + }, + "reset_mode": { + "name": "Mode reset" + } + } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 4551c16..2611e0a 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -28,5 +28,15 @@ "already_configured": "Device is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible." } + }, + "entity": { + "binary_sensor": { + "reset_clock": { + "name": "Clock reset" + }, + "reset_mode": { + "name": "Mode reset" + } + } } } From 974a6156a16b3df7fd1d17a53aed1206903f278c Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 5 May 2026 15:00:27 +1000 Subject: [PATCH 28/37] fix: create diagnostic sensors for all shades --- .../hunterdouglas_powerview_ble/binary_sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index f8be25e..e947e72 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -47,11 +47,12 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create binary sensor entities for a single shade coordinator.""" - if not coordinator.show_battery_entities: - return + include_battery = coordinator.show_battery_entities mac = format_mac(coordinator.address) async_add_entities( - PVBinarySensor(coordinator, descr, mac) for descr in BINARY_SENSOR_TYPES + PVBinarySensor(coordinator, descr, mac) + for descr in BINARY_SENSOR_TYPES + if include_battery or descr.key != ATTR_BATTERY_CHARGING ) From 98fa8721ce6b41878ff3ac24d31c649323c830d1 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 5 May 2026 15:19:15 +1000 Subject: [PATCH 29/37] fix: make diagnostic sensors names more meaningful and apply the PROBLEM device class --- .../hunterdouglas_powerview_ble/binary_sensor.py | 2 ++ custom_components/hunterdouglas_powerview_ble/strings.json | 4 ++-- .../hunterdouglas_powerview_ble/translations/en.json | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index e947e72..2eedabf 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -31,12 +31,14 @@ BinarySensorEntityDescription( key="reset_clock", translation_key="reset_clock", + device_class=BinarySensorDeviceClass.PROBLEM, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="reset_mode", translation_key="reset_mode", + device_class=BinarySensorDeviceClass.PROBLEM, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index a757c36..5f99eea 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -32,10 +32,10 @@ "entity": { "binary_sensor": { "reset_clock": { - "name": "Clock reset" + "name": "Clock reset required" }, "reset_mode": { - "name": "Mode reset" + "name": "Mode reset required" } } } diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 2611e0a..9fa5a21 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -32,10 +32,10 @@ "entity": { "binary_sensor": { "reset_clock": { - "name": "Clock reset" + "name": "Clock reset required" }, "reset_mode": { - "name": "Mode reset" + "name": "Mode reset required" } } } From dfd897372da982bcdab196ceeb5d6b94c359f62c Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 5 May 2026 15:41:17 +1000 Subject: [PATCH 30/37] feat: add more shade types and capabilities based on aio-powerview-api and openhab's database --- .../hunterdouglas_powerview_ble/api.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 2920564..df6546b 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -40,7 +40,9 @@ 6: "Duette", 10: "Duette and Applause SkyLift", 19: "Provenance Woven Wood", - 26: "Vertical", + 26: "Skyline Panel, Left Stack", + 27: "Skyline Panel, Right Stack", + 28: "Skyline Panel, Split Stack", 31: "Vignette", 32: "Vignette", 42: "M25T Roller Blind", @@ -48,6 +50,9 @@ 52: "Banded Shades", 53: "Sonnette", 57: "Carole Roman Shades", + 69: "Curtain, Left Stack", + 70: "Curtain, Right Stack", + 71: "Curtain, Split Stack", 84: "Vignette", # top down (single rail, inverted position) 7: "Top Down", @@ -59,9 +64,13 @@ # tilt only (no position movement) 39: "Parkland", 40: "Everwood Alternative Wood Blinds", + 66: "Palm Beach Shutters", # tilt on closed 18: "Bottom Up, Tilt on Closed 90°", + 23: "Silhouette", + 43: "Facette", 44: "Twist", + 72: "Silhouette", # tilt anywhere (position + tilt) 51: "Venetian, Tilt Anywhere", 54: "Vertical Slats, Left Stack", @@ -71,6 +80,7 @@ # duolite (dual overlapping fabrics) 38: "Dual Overlapped, Tilt 90°", 65: "Dual Overlapped", + 79: "Duolite Lift", 95: "Dual Overlapped Illuminated", } @@ -96,9 +106,13 @@ class ShadeCapability(NamedTuple): # tilt only (no position movement) 39: ShadeCapability(has_tilt=True, tilt_only=True), 40: ShadeCapability(has_tilt=True, tilt_only=True), + 66: ShadeCapability(has_tilt=True, tilt_only=True), # tilt on closed (tilt only available at fully closed position) 18: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), + 23: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), + 43: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), 44: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), + 72: ShadeCapability(has_tilt=True, is_tilt_on_closed=True), # top-down only (single rail, inverted position) 7: ShadeCapability(is_top_down=True), 10: ShadeCapability(is_top_down=True), @@ -110,6 +124,7 @@ class ShadeCapability(NamedTuple): 9: ShadeCapability(is_tdbu=True, is_duolite=True), 38: ShadeCapability(is_duolite=True), 65: ShadeCapability(is_duolite=True), + 79: ShadeCapability(is_duolite=True), 95: ShadeCapability(is_duolite=True), } From a40c2baa734562cb3c0ba6006f25b262b342bdfe Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Wed, 6 May 2026 07:09:31 +1000 Subject: [PATCH 31/37] feat: add support for type 103 blinds --- custom_components/hunterdouglas_powerview_ble/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index df6546b..837cfd2 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -77,6 +77,7 @@ 55: "Vertical Slats, Right Stack", 56: "Vertical Slats, Split Stack", 62: "Venetian, Tilt Anywhere", + 103: "Designer Banded, Tilt Anywhere", # duolite (dual overlapping fabrics) 38: "Dual Overlapped, Tilt 90°", 65: "Dual Overlapped", @@ -103,6 +104,7 @@ class ShadeCapability(NamedTuple): 55: ShadeCapability(has_tilt=True), 56: ShadeCapability(has_tilt=True), 62: ShadeCapability(has_tilt=True), + 103: ShadeCapability(has_tilt=True), # tilt only (no position movement) 39: ShadeCapability(has_tilt=True, tilt_only=True), 40: ShadeCapability(has_tilt=True, tilt_only=True), From 365fdb4bf8060e747d9907f66ed16b7f625633a6 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Tue, 23 Jun 2026 16:25:00 +1000 Subject: [PATCH 32/37] fix: added zerconf discovery and added key fetch retrying code to make the process more robust --- .../config_flow.py | 243 ++++++++++++------ .../hunterdouglas_powerview_ble/manifest.json | 6 +- .../hunterdouglas_powerview_ble/strings.json | 13 + .../translations/en.json | 13 + 4 files changed, 201 insertions(+), 74 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 31cb580..b20d1eb 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Hunter Douglas PowerView BLE integration.""" +import asyncio import hashlib import struct from typing import Any @@ -20,9 +21,12 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, LOGGER, MFCT_ID +_DEFAULT_HUB_URL = "http://powerview-g3.local" + def _hub_unique_id(home_key: str) -> str: """Derive a stable unique ID for a hub entry from the home key.""" @@ -82,6 +86,13 @@ def _parse_key_response(ble_name: str, result: dict) -> bytes | None: # noqa: P return key_data +# The hub connects to shades over BLE on demand, so the first request to each +# shade usually times out while that connection is established. Retry the whole +# shade list a few times, pausing between passes to let the connections settle. +_KEY_FETCH_PASSES = 3 +_KEY_FETCH_PASS_DELAY = 5 # seconds + + async def _fetch_key_from_hub(hass: HomeAssistant, hub_url: str) -> bytes: """Fetch 16-byte homekey from a PowerView G3 hub. @@ -90,8 +101,9 @@ async def _fetch_key_from_hub(hass: HomeAssistant, hub_url: str) -> bytes: The hub must establish a BLE connection to each shade before it can proxy the key request. On the first pass that connection is often not yet open, - so the hub returns an error immediately. A second pass (after a short - pause to let the hub complete its BLE connections) reliably succeeds. + so the hub returns "command timed out" (err=8). We retry the whole shade + list up to _KEY_FETCH_PASSES times, pausing between passes to let the hub + complete the BLE connections the earlier requests kicked off. Raises ValueError on protocol/key errors. Raises aiohttp.ClientError on network errors. @@ -118,55 +130,85 @@ async def _fetch_key_from_hub(hass: HomeAssistant, hub_url: str) -> bytes: request_frame = struct.pack(" vol.Schema: + """Build the homekey form schema, defaulting the hub URL field. + + When a gateway has been discovered via zeroconf, ``hub_url_default`` is the + discovered URL so the manual-entry fallback comes pre-filled. + """ + return vol.Schema( + { + vol.Required("key_method", default="hub"): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value="hub", + label="Fetch automatically from PowerView hub", + ), + SelectOptionDict( + value="manual", + label="Enter key manually (32 hex characters)", + ), + SelectOptionDict( + value="skip", + label=( + "Skip (no key — controls disabled for " + "encrypted shades)" + ), + ), + ] + ) + ), + vol.Optional(CONF_HUB_URL, default=hub_url_default): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional("home_key", default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + } + ) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -180,13 +222,38 @@ def __init__(self) -> None: self._home_key: str = "" self._hub_url: str = "" - def _create_entry(self) -> ConfigFlowResult: - """Create the hub config entry.""" + def _existing_hub_entry(self) -> config_entries.ConfigEntry | None: + """Return the existing hub (v2+) config entry, if any.""" + for entry in self._async_current_entries(): + if entry.version >= 2: + return entry + return None + + async def _create_entry(self) -> ConfigFlowResult: + """Set the stable unique ID and create the hub config entry.""" + await self.async_set_unique_id( + _hub_unique_id(self._home_key), raise_on_progress=False + ) + self._abort_if_unique_id_configured() data: dict[str, str] = {CONF_HOME_KEY: self._home_key} if self._hub_url: data[CONF_HUB_URL] = self._hub_url return self.async_create_entry(title="PowerView Home", data=data) + def _show_homekey_form( + self, step_id: str, errors: dict[str, str], **placeholders: str + ) -> ConfigFlowResult: + """Render the key-source form, pre-filling the hub URL when discovered.""" + return self.async_show_form( + step_id=step_id, + data_schema=_homekey_schema(self._hub_url or _DEFAULT_HUB_URL), + errors=errors, + description_placeholders={ + "hub_url_example": _DEFAULT_HUB_URL, + **placeholders, + }, + ) + def _validate_manual_key( self, user_input: dict[str, Any], errors: dict[str, str] ) -> bool: @@ -234,12 +301,6 @@ async def _validate_homekey_input( return False hub_url = user_input.get(CONF_HUB_URL, "").rstrip("/") - _HUB_ERROR_MAP: dict[type[Exception], str] = { - aiohttp.ClientResponseError: "hub_http_error", - aiohttp.ClientConnectionError: "hub_connection_error", - TimeoutError: "hub_timeout", - ValueError: "hub_protocol_error", - } try: key = await _fetch_key_from_hub(self.hass, hub_url) except tuple(_HUB_ERROR_MAP) as ex: @@ -274,13 +335,60 @@ async def async_step_bluetooth( # If a hub entry already exists (unique_id may differ), shades are # auto-discovered internally — nothing more for the user to do. - for entry in self._async_current_entries(): - if entry.version >= 2: - return self.async_abort(reason="already_configured") + if self._existing_hub_entry(): + return self.async_abort(reason="already_configured") # No hub entry yet — redirect to user setup return await self.async_step_user() + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by zeroconf discovery of a G3 gateway.""" + LOGGER.debug("Zeroconf gateway detected: %s", discovery_info) + + host = discovery_info.hostname.rstrip(".") or str(discovery_info.ip_address) + self._hub_url = f"http://{host}" + + # Dedup repeated mDNS announcements while a flow is in progress. + await self.async_set_unique_id(f"gateway_{host}") + + # If the single hub entry already exists, just record the (possibly + # changed) gateway URL so metadata/key fetches keep working after a + # DHCP/hostname change, then abort — there is nothing for the user to do. + entry = self._existing_hub_entry() + if entry: + if entry.data.get(CONF_HUB_URL) != self._hub_url: + self.hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_HUB_URL: self._hub_url}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + + self.context["title_placeholders"] = {"name": host} + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm a discovered gateway and choose how to obtain the key. + + Offers the same options as the manual user step (fetch from the hub, + enter the key manually, or skip) with the discovered URL pre-filled, so + the user can bypass the automatic hub fetch if they prefer. + """ + errors: dict[str, str] = {} + + if user_input is not None and await self._validate_homekey_input( + user_input, errors + ): + return await self._create_entry() + + return self._show_homekey_form( + "zeroconf_confirm", errors, name=self._hub_url + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -288,25 +396,14 @@ async def async_step_user( LOGGER.debug("user step") # Only one hub entry allowed (per key, but for simplicity one total) - for entry in self._async_current_entries(): - if entry.version >= 2: - return self.async_abort(reason="single_instance_allowed") + if self._existing_hub_entry(): + return self.async_abort(reason="single_instance_allowed") errors: dict[str, str] = {} if user_input is not None and await self._validate_homekey_input( user_input, errors ): - unique_id = _hub_unique_id(self._home_key) - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured() - return self._create_entry() + return await self._create_entry() - return self.async_show_form( - step_id="user", - data_schema=_HOMEKEY_SCHEMA, - errors=errors, - description_placeholders={ - "hub_url_example": "http://powerview-g3.local", - }, - ) + return self._show_homekey_form("user", errors) diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index 9680d6b..2f5d9de 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -16,5 +16,9 @@ "issue_tracker": "https://github.com/patman15/hdpv_ble/issues", "loggers": ["hunterdouglas_powerview_ble"], "requirements": ["cryptography>=43.0.0"], - "version": "0.24" + "version": "0.24", + "zeroconf": [ + { "type": "_powerview-g3._tcp.local." }, + { "type": "_PowerView-G3._tcp.local." } + ] } diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json index 5f99eea..7f6ff51 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -14,6 +14,19 @@ "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } + }, + "zeroconf_confirm": { + "title": "PowerView gateway discovered", + "description": "A PowerView G3 gateway was found at {name}. Choose how to configure your encryption key — fetch it automatically from the gateway, enter it manually, or skip. All shades on your network will be discovered automatically via Bluetooth.", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } } }, "error": { diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json index 9fa5a21..4f51729 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -14,6 +14,19 @@ "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" } + }, + "zeroconf_confirm": { + "title": "PowerView gateway discovered", + "description": "A PowerView G3 gateway was found at {name}. Choose how to configure your encryption key — fetch it automatically from the gateway, enter it manually, or skip. All shades on your network will be discovered automatically via Bluetooth.", + "data": { + "key_method": "Key source", + "hub_url": "PowerView hub URL", + "home_key": "HomeKey (32 hex characters or \\xNN format)" + }, + "data_description": { + "hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}", + "home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)" + } } }, "error": { From 6ee436706a5f87fb32f9a5909c3cd4ebc188eefd Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Wed, 24 Jun 2026 14:04:01 +1000 Subject: [PATCH 33/37] fix: now report unknown for position, tilt, and battery when they go out of Bluetooth range --- .../binary_sensor.py | 2 + .../coordinator.py | 41 +++++++++++++++---- .../hunterdouglas_powerview_ble/cover.py | 21 +++++++--- .../hunterdouglas_powerview_ble/sensor.py | 11 ++++- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index 2eedabf..4864cde 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -88,4 +88,6 @@ def __init__( @property def is_on(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Handle updated data from the coordinator.""" + if not self.coordinator.data_available: + return None return bool(self.coordinator.data.get(self.entity_description.key)) diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 14df890..69ee45b 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -28,6 +28,11 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): _DEV_INFO_REFRESH_S: Final[float] = 24 * 3600 _DEV_INFO_RETRY_S: Final[float] = 60 + # Position/battery come only from V2 adverts. Past this many seconds without + # a fresh V2 record we stop reporting the retained sample as current, so an + # out-of-range shade doesn't keep showing a stale position. + _STALE_AFTER_S: Final[float] = 300.0 + def __init__( self, hass: HomeAssistant, @@ -45,6 +50,7 @@ def __init__( ) self.api = PowerViewBLE(ble_device, home_key) self.data: dict[str, int | float | bool] = {} + self._last_v2_at: float = 0.0 self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} self.velocity: int = 0 @@ -77,6 +83,20 @@ def shade_capabilities(self) -> ShadeCapability: """Return the shade capabilities based on type ID.""" return get_shade_capabilities(self.type_id) + @property + def data_available(self) -> bool: + """Whether the last V2 advertisement is recent enough to trust. + + Position, tilt and battery are only carried in V2 adverts. An + out-of-range shade stops sending them while HA may still consider the + device present, so past ``_STALE_AFTER_S`` we no longer report the + retained sample as current. + """ + return ( + self._last_v2_at != 0.0 + and time.monotonic() - self._last_v2_at < self._STALE_AFTER_S + ) + async def query_dev_info(self) -> None: """Fetch device info over GATT and push into the device registry. @@ -191,15 +211,22 @@ def _async_handle_bluetooth_event( LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data) self.api.set_ble_device(service_info.device) - new_data: dict[str, int | float | bool] = {ATTR_RSSI: service_info.rssi} + + # Merge onto the retained sample rather than replacing it: an advert + # without a valid V2 record (wrong length / legacy format / 2073 + # absent) decodes to {} and must not wipe the last-known position, + # tilt and battery state. + new_data: dict[str, int | float | bool] = dict(self.data) + new_data[ATTR_RSSI] = service_info.rssi if change == bluetooth.BluetoothChange.ADVERTISEMENT: - new_data.update( - self.api.dec_manufacturer_data( - bytearray(service_info.manufacturer_data.get(2073, b"")) - ) + decoded = self.api.dec_manufacturer_data( + bytearray(service_info.manufacturer_data.get(2073, b"")) ) - self.api.encrypted = bool(new_data.get("home_id")) - self._maybe_refresh_dev_info() + if decoded: + new_data.update(decoded) + self.api.encrypted = bool(decoded.get("home_id")) + self._last_v2_at = time.monotonic() + self._maybe_refresh_dev_info() if new_data == self.data: return diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index d2c30b5..313a0d0 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -87,6 +87,8 @@ def __init__( @property def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Return if the cover is opening or not.""" + if not self._coord.data_available: + return None return bool(self._coord.data.get("is_opening")) or ( isinstance(self._target_position, int) and isinstance(self.current_cover_position, int) @@ -97,6 +99,8 @@ def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableO @property def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] """Return if the cover is closing or not.""" + if not self._coord.data_available: + return None return bool(self._coord.data.get("is_closing")) or ( isinstance(self._target_position, int) and isinstance(self.current_cover_position, int) @@ -123,8 +127,7 @@ def current_cover_position(self) -> int | None: # type: ignore[reportIncompatib None is unknown, 0 is closed, 100 is fully open. """ - pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) - return round(pos) if pos is not None else None + return self._fresh_position(ATTR_CURRENT_POSITION) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -153,6 +156,13 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: def _reset_target_position(self) -> None: self._target_position = None + def _fresh_position(self, key: str) -> int | None: + """Return the rounded ``key`` position, or None if stale/absent.""" + if not self._coord.data_available: + return None + pos = self._coord.data.get(key) + return round(pos) if pos is not None else None + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" LOGGER.debug("open cover") @@ -218,8 +228,7 @@ def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncom None is unknown """ - pos: Final = self._coord.data.get(ATTR_CURRENT_TILT_POSITION) - return round(pos) if pos is not None else None + return self._fresh_position(ATTR_CURRENT_TILT_POSITION) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the tilt to a specific position.""" @@ -306,8 +315,8 @@ class PowerViewCoverTopDown(PowerViewCover): @property def current_cover_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride] """Return current position, inverting the device axis.""" - pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION) - return OPEN_POSITION - round(pos) if pos is not None else None + pos = self._fresh_position(ATTR_CURRENT_POSITION) + return OPEN_POSITION - pos if pos is not None else None async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position, inverting for the device.""" diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index 0226016..03287ba 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -78,5 +78,14 @@ def __init__( @property def native_value(self) -> int | float | None: # type: ignore[reportIncompatibleVariableOverride] - """Return the sensor value.""" + """Return the sensor value. + + RSSI refreshes on every advert, so it is always reported. V2-derived + values (e.g. battery) read as unknown once the advert data is stale. + """ + if ( + self.entity_description.key != ATTR_RSSI + and not self.coordinator.data_available + ): + return None return self.coordinator.data.get(self.entity_description.key) From 1cbc56cf561fdf3ca7e720c9c2b457df69b790ab Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 5 Jul 2026 08:42:18 +1000 Subject: [PATCH 34/37] fix: get battery-powered information from the hub instead of via BLE --- .../hunterdouglas_powerview_ble/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index 7a67436..8bb90aa 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -45,12 +45,17 @@ ) from .coordinator import PVCoordinator -# Hub /home/shades powerType (aiopvapi Gen3: 1=hardwired, 2=battery, -# 3=rechargeable) → our internal PowerType (matches BLE 0xFFDE byte 0). +# Raw Gen3 /home/shades powerType (aio-powerview-api V3 map) → our internal +# PowerType. The two use different numbering — internal PowerType follows the +# BLE 0xFFDE byte-0 encoding where 0=hardwired — so translate explicitly. +# 0=battery must stay mapped: every Gen3 battery shade reports 0, and leaving +# it out resolves to None and drops the shade onto the unreliable BLE fallback. _HUB_POWERTYPE_TO_INTERNAL: dict[int, PowerType] = { + 0: PowerType.BATTERY, 1: PowerType.HARDWIRED, - 2: PowerType.BATTERY, - 3: PowerType.RECHARGEABLE, + 2: PowerType.RECHARGEABLE, + 11: PowerType.RECHARGEABLE, # fixed rechargeable (PowerView+ internal battery) + 12: PowerType.HARDWIRED, # fixed hardwired (smart power supply) } From 6dd9a740d918e9e255aa1d6d745130c40ca51561 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 12 Jul 2026 08:33:00 +1000 Subject: [PATCH 35/37] fix: remove code for hiding battery sensors from hard wired shades due to incorrect behaviour --- .../hunterdouglas_powerview_ble/__init__.py | 101 ++++-------------- .../hunterdouglas_powerview_ble/api.py | 50 +-------- .../binary_sensor.py | 5 +- .../config_flow.py | 4 +- .../hunterdouglas_powerview_ble/const.py | 11 -- .../coordinator.py | 23 +--- .../hunterdouglas_powerview_ble/sensor.py | 2 - 7 files changed, 27 insertions(+), 169 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index 8bb90aa..83e3e47 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -4,10 +4,8 @@ @license: Apache-2.0 license """ -import asyncio import base64 from collections.abc import Callable -from dataclasses import dataclass import aiohttp from bleak.backends.device import BLEDevice @@ -36,37 +34,13 @@ from .const import ( CONF_FRIENDLY_NAMES, CONF_HUB_URL, - CONF_POWER_TYPES, DOMAIN, LOGGER, MFCT_ID, SIGNAL_NEW_SHADE, - PowerType, ) from .coordinator import PVCoordinator -# Raw Gen3 /home/shades powerType (aio-powerview-api V3 map) → our internal -# PowerType. The two use different numbering — internal PowerType follows the -# BLE 0xFFDE byte-0 encoding where 0=hardwired — so translate explicitly. -# 0=battery must stay mapped: every Gen3 battery shade reports 0, and leaving -# it out resolves to None and drops the shade onto the unreliable BLE fallback. -_HUB_POWERTYPE_TO_INTERNAL: dict[int, PowerType] = { - 0: PowerType.BATTERY, - 1: PowerType.HARDWIRED, - 2: PowerType.RECHARGEABLE, - 11: PowerType.RECHARGEABLE, # fixed rechargeable (PowerView+ internal battery) - 12: PowerType.HARDWIRED, # fixed hardwired (smart power supply) -} - - -@dataclass(frozen=True) -class ShadeMetadata: - """Per-shade metadata sourced from the G3 hub's /home/shades response.""" - - friendly_name: str - power_type: PowerType | None = None - - PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -104,10 +78,8 @@ def _async_new_shade(coordinator: PVCoordinator) -> None: ) -async def _fetch_shade_metadata( - hass: HomeAssistant, hub_url: str -) -> dict[str, ShadeMetadata]: - """Fetch per-shade metadata (friendly name, power_type) from the hub. +async def _fetch_shade_names(hass: HomeAssistant, hub_url: str) -> dict[str, str]: + """Fetch the shades' friendly names from the hub, keyed by BLE advert name. Returns empty dict on failure. """ @@ -120,7 +92,7 @@ async def _fetch_shade_metadata( except (TimeoutError, aiohttp.ClientError, ValueError): return {} - metadata: dict[str, ShadeMetadata] = {} + names: dict[str, str] = {} for shade in shades or []: ble_name = shade.get("bleName", "") if not ble_name: @@ -130,12 +102,8 @@ async def _fetch_shade_metadata( name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name except Exception: # noqa: BLE001 name = ble_name - raw_pt = shade.get("powerType") - power_type = ( - _HUB_POWERTYPE_TO_INTERNAL.get(raw_pt) if isinstance(raw_pt, int) else None - ) - metadata[ble_name] = ShadeMetadata(friendly_name=name, power_type=power_type) - return metadata + names[ble_name] = name + return names def _persist_cache_entry( @@ -162,7 +130,7 @@ def _resolve_friendly_name( hass: HomeAssistant, entry: ConfigEntryType, service_info: BluetoothServiceInfoBleak, - meta: ShadeMetadata | None, + hub_name: str | None, ) -> str: """Resolve a shade's friendly name (Shelly-style) and refresh the cache. @@ -171,8 +139,8 @@ def _resolve_friendly_name( """ address = service_info.address cached_names: dict[str, str] = entry.data.get(CONF_FRIENDLY_NAMES, {}) - if meta is not None: - friendly_name = meta.friendly_name + if hub_name is not None: + friendly_name = hub_name elif address in cached_names: friendly_name = cached_names[address] else: @@ -186,7 +154,7 @@ async def _async_setup_shade( hass: HomeAssistant, entry: ConfigEntryType, service_info: BluetoothServiceInfoBleak, - shade_metadata: dict[str, ShadeMetadata], + shade_names: dict[str, str], ) -> None: """Create a coordinator for a newly discovered shade.""" address = service_info.address @@ -201,39 +169,15 @@ async def _async_setup_shade( LOGGER.debug("BLE device %s not connectable, skipping", address) return - meta = shade_metadata.get(service_info.name) - friendly_name = _resolve_friendly_name(hass, entry, service_info, meta) - - power_type: PowerType | None = meta.power_type if meta else None - if power_type is None: - cached_value = entry.data.get(CONF_POWER_TYPES, {}).get(address) - if isinstance(cached_value, int): - try: - power_type = PowerType(cached_value) - except ValueError: - power_type = None - - coordinator = PVCoordinator( - hass, ble_device, entry.data.copy(), friendly_name, power_type + friendly_name = _resolve_friendly_name( + hass, entry, service_info, shade_names.get(service_info.name) ) + coordinator = PVCoordinator(hass, ble_device, entry.data.copy(), friendly_name) + entry.runtime_data[address] = coordinator entry.async_on_unload(coordinator.async_start()) - # BLE fallback for hub-less (manual-key) setups: one-time query so the - # entity platforms (dispatched below) see the resolved value. - if coordinator.power_type is None and coordinator.api.has_key: - try: - await asyncio.wait_for(coordinator.query_power_type(), timeout=10) - except (BleakError, TimeoutError): - LOGGER.debug("power_type BLE fallback failed for %s", address) - - # Persist any newly-resolved power_type so hub-down restarts keep classification. - if coordinator.power_type is not None: - _persist_cache_entry( - hass, entry, CONF_POWER_TYPES, address, int(coordinator.power_type) - ) - # Populate dev_details before entity dispatch so the device registers with # firmware/serial on first creation — the HA registry doesn't re-read # DeviceInfo later. Failures are retried from the advertisement handler. @@ -259,11 +203,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool entry.runtime_data = {} - # Resolve shade friendly names and power_type from hub if available + # Resolve shade friendly names from hub if available hub_url = entry.data.get(CONF_HUB_URL, "") - shade_metadata: dict[str, ShadeMetadata] = {} + shade_names: dict[str, str] = {} if hub_url: - shade_metadata = await _fetch_shade_metadata(hass, hub_url) + shade_names = await _fetch_shade_names(hass, hub_url) # Forward platforms first so dispatched entities have their setup ready await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -275,7 +219,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool and UUID in service_info.service_uuids ): hass.async_create_task( - _async_setup_shade(hass, entry, service_info, shade_metadata) + _async_setup_shade(hass, entry, service_info, shade_names) ) # Register for future BLE discoveries @@ -285,7 +229,7 @@ def _async_discovered_device( ) -> None: if service_info.address not in entry.runtime_data: hass.async_create_task( - _async_setup_shade(hass, entry, service_info, shade_metadata) + _async_setup_shade(hass, entry, service_info, shade_names) ) entry.async_on_unload( @@ -314,11 +258,10 @@ async def async_remove_config_entry_device( return True new_data = dict(entry.data) - for key in (CONF_FRIENDLY_NAMES, CONF_POWER_TYPES): - cache = dict(new_data.get(key, {})) - for addr in addresses: - cache.pop(addr, None) - new_data[key] = cache + cache = dict(new_data.get(CONF_FRIENDLY_NAMES, {})) + for addr in addresses: + cache.pop(addr, None) + new_data[CONF_FRIENDLY_NAMES] = cache hass.config_entries.async_update_entry(entry, data=new_data) for addr in addresses: diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 837cfd2..561984c 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -22,7 +22,7 @@ ATTR_CURRENT_TILT_POSITION, ) -from .const import LOGGER, TIMEOUT, PowerType +from .const import LOGGER, TIMEOUT UUID_COV_SERVICE: Final[str] = normalize_uuid_str("fdc1") UUID_TX: Final[str] = "cafe1001-c0ff-ee01-8000-a110ca7ab1e0" @@ -158,7 +158,6 @@ class ShadeCmd(Enum): STOP = 0xB8F7 ACTIVATE_SCENE = 0xBAF7 IDENTIFY = 0x11F7 - POWER_STATUS = 0xDEFF @dataclass @@ -272,22 +271,6 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No LOGGER.error("Error: %s - %s", type(ex).__name__, ex) raise - async def _query(self, cmd: tuple[ShadeCmd, bytes]) -> bytes: - """Send a read-type opcode and return its payload bytes.""" - async with self._cmd_lock: - await self._connect() - try: - try: - seq = await self._transact(cmd) - except TimeoutError as ex: - raise TimeoutError("Device did not send response.") from ex - if not self._verify_header(self._data, seq, cmd[0]): - raise BleakError("Malformed query response header") - length = int(self._data[3]) - return bytes(self._data[4 : 4 + length]) - finally: - await self._client.disconnect() - @staticmethod def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: """Decode manufacturer data from BLE advertisement V2.""" @@ -441,37 +424,6 @@ async def query_dev_info(self) -> dict[str, str]: LOGGER.debug("%s device data: %s", self.name, data) return data.copy() - async def query_power_type(self) -> PowerType | None: - """Return the shade's power source, or None if unknown/unavailable.""" - if self._cipher is None: - return None - try: - payload = await self._query((ShadeCmd.POWER_STATUS, b"")) - except (BleakError, TimeoutError) as ex: - LOGGER.debug("%s: power_type query failed: %s", self.name, ex) - return None - - # A 1-byte reply is an error code (e.g. 0x04 = invalid length); it - # must NOT be returned as a power_type or it would be misread as - # PowerType.value=4. - if len(payload) == 1: - LOGGER.debug( - "%s: power_type query rejected (err=0x%02X)", self.name, payload[0] - ) - return None - if len(payload) == 8: - try: - return PowerType(payload[0]) - except ValueError: - LOGGER.debug( - "%s: unknown power_type byte 0x%02X", self.name, payload[0] - ) - return None - LOGGER.debug( - "%s: unexpected power_type payload length %d", self.name, len(payload) - ) - return None - def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" diff --git a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index 4864cde..b5d2553 100644 --- a/custom_components/hunterdouglas_powerview_ble/binary_sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py @@ -49,12 +49,9 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create binary sensor entities for a single shade coordinator.""" - include_battery = coordinator.show_battery_entities mac = format_mac(coordinator.address) async_add_entities( - PVBinarySensor(coordinator, descr, mac) - for descr in BINARY_SENSOR_TYPES - if include_battery or descr.key != ATTR_BATTERY_CHARGING + PVBinarySensor(coordinator, descr, mac) for descr in BINARY_SENSOR_TYPES ) diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index b20d1eb..bc70473 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -290,8 +290,8 @@ async def _validate_homekey_input( if method == "manual": # Still capture a hub URL if the user provided one — the integration - # uses it to fetch shade metadata (friendly names, power_type) even - # when the homekey itself was supplied manually. + # uses it to fetch the shades' friendly names even when the homekey + # itself was supplied manually. hub_url = user_input.get(CONF_HUB_URL, "").rstrip("/") if hub_url: self._hub_url = hub_url diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py index 8480df2..b01ffe6 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -1,6 +1,5 @@ """Constants for the BLE Battery Management System integration.""" -from enum import IntEnum import logging from typing import Final @@ -11,18 +10,8 @@ CONF_HOME_KEY: Final[str] = "home_key" CONF_HUB_URL: Final[str] = "hub_url" -CONF_POWER_TYPES: Final[str] = "power_types" CONF_FRIENDLY_NAMES: Final[str] = "friendly_names" - -class PowerType(IntEnum): - """Shade power source. Values match the BLE 0xFFDE reply's byte 0.""" - - HARDWIRED = 0 - BATTERY = 1 - RECHARGEABLE = 2 - - # dispatcher signal for newly discovered shades (format with entry_id) SIGNAL_NEW_SHADE: Final[str] = f"{DOMAIN}_new_shade_{{entry_id}}" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 69ee45b..c5df7fc 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -17,7 +17,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities -from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER, PowerType +from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -39,7 +39,6 @@ def __init__( ble_device: BLEDevice, data: dict[str, Any], friendly_name: str | None = None, - power_type: PowerType | None = None, ) -> None: """Initialize BMS data coordinator.""" assert ble_device.name is not None @@ -54,7 +53,6 @@ def __init__( self._manuf_dat = data.get("manufacturer_data") self.dev_details: dict[str, str] = {} self.velocity: int = 0 - self._power_type: PowerType | None = power_type self._last_dev_info_at: float = 0.0 self._dev_info_task: asyncio.Task[None] | None = None @@ -143,25 +141,6 @@ def _maybe_refresh_dev_info(self) -> None: self._refresh_dev_info_safe(), name=f"pvble_dev_info_{self.address}" ) - @property - def power_type(self) -> PowerType | None: - """Return the shade's power source, or None if unknown.""" - return self._power_type - - @property - def show_battery_entities(self) -> bool: - """Whether battery-related entities should be created for this shade. - - Unknown power_type is treated as battery-ish so real battery data is - never silently hidden on unclassified shades. - """ - return self._power_type != PowerType.HARDWIRED - - async def query_power_type(self) -> None: - """Query the shade's power_type over BLE and cache the result.""" - LOGGER.debug("%s: querying power_type", self.name) - self._power_type = await self.api.query_power_type() - @property def device_info(self) -> DeviceInfo: """Return detailed device information for GUI.""" diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index 03287ba..07512bf 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -43,12 +43,10 @@ def _add_entities( coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback ) -> None: """Create sensor entities for a single shade coordinator.""" - include_battery = coordinator.show_battery_entities async_add_entities( [ PVSensor(coordinator, descr, format_mac(coordinator.address)) for descr in SENSOR_TYPES - if include_battery or descr.key != ATTR_BATTERY_LEVEL ] ) From 893582c33bc88c636a8aec9fbfed32dacc846952 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 12 Jul 2026 08:59:22 +1000 Subject: [PATCH 36/37] feat: add diagnostic download capabilities --- .../hunterdouglas_powerview_ble/api.py | 28 +++ .../coordinator.py | 5 + .../diagnostics.py | 225 ++++++++++++++++++ scripts/shade_report.py | 59 +++-- 4 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 custom_components/hunterdouglas_powerview_ble/diagnostics.py diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 561984c..703e1e0 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -158,6 +158,7 @@ class ShadeCmd(Enum): STOP = 0xB8F7 ACTIVATE_SCENE = 0xBAF7 IDENTIFY = 0x11F7 + POWER_STATUS = 0xDEFF @dataclass @@ -271,6 +272,22 @@ async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> No LOGGER.error("Error: %s - %s", type(ex).__name__, ex) raise + async def _query(self, cmd: tuple[ShadeCmd, bytes]) -> bytes: + """Send a read-type opcode and return its payload bytes.""" + async with self._cmd_lock: + await self._connect() + try: + try: + seq = await self._transact(cmd) + except TimeoutError as ex: + raise TimeoutError("Device did not send response.") from ex + if not self._verify_header(self._data, seq, cmd[0]): + raise BleakError("Malformed query response header") + length = int(self._data[3]) + return bytes(self._data[4 : 4 + length]) + finally: + await self._client.disconnect() + @staticmethod def dec_manufacturer_data(data: bytearray) -> dict[str, float | int | bool]: """Decode manufacturer data from BLE advertisement V2.""" @@ -424,6 +441,17 @@ async def query_dev_info(self) -> dict[str, str]: LOGGER.debug("%s device data: %s", self.name, data) return data.copy() + async def query_power_status(self) -> bytes: + """Return the raw 0xFFDE power-status reply, uninterpreted. + + Deliberately returns bytes rather than a decoded power source. The + encoding is not established: byte 0 was previously read as a power-type + enum, but every sample behind that reading came from hardwired shades, + and acting on it misclassified battery shades as hardwired. Until a + confirmed battery-shade sample exists, this is reported, never acted on. + """ + return await self._query((ShadeCmd.POWER_STATUS, b"")) + def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index c5df7fc..0d82994 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -68,6 +68,11 @@ def __init__( bluetooth.BluetoothScanningMode.ACTIVE, ) + @property + def friendly_name(self) -> str: + """Return the shade's resolved friendly name.""" + return self._friendly_name + @property def type_id(self) -> int | None: """Return the shade type ID from manufacturer data or live BLE data.""" diff --git a/custom_components/hunterdouglas_powerview_ble/diagnostics.py b/custom_components/hunterdouglas_powerview_ble/diagnostics.py new file mode 100644 index 0000000..2b91171 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/diagnostics.py @@ -0,0 +1,225 @@ +"""Diagnostics for the Hunter Douglas PowerView (BLE) integration. + +Nothing in this module interprets what it collects, by design. The integration +used to classify a shade's power source and hide the battery entities of any +shade it judged hardwired; the classification was built on samples taken from +hardwired shades only, so it misread battery shades as hardwired and hid the +very sensors it was meant to protect. That code is gone. + +This dump gathers the evidence needed to get the encoding right instead of +guessing at it: the hub's record verbatim, the raw advertisement, and the raw +0xFFDE reply, correlated per shade so a user can label each one and report back. +Three things are unknown and each is answered by a field below: + + * what the hub reports in `powerType` for a *known* battery shade + (`shades[].hub_record`) + * whether the 0xFFDE reply differs at all between battery and hardwired + shades (`shades[].power_status_0xffde`) + * whether 0xFFDE byte 2 is a real battery percentage — it reads 100 on + hardwired shades — which would beat the advertisement's four-step level + (`shades[].advertisement.power_level_code`) +""" + +import asyncio +from typing import Any, Final + +import aiohttp +from bleak.exc import BleakError + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.redact import async_redact_data + +from . import ConfigEntryType +from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, MFCT_ID +from .coordinator import PVCoordinator + +# home_key is the AES key that drives the shades and home_id identifies the +# PowerView network; a serial number identifies the owner's hardware. None of +# them are needed to answer the power-source question, and this file is meant +# to be attached to a public issue. +TO_REDACT: Final[set[str]] = { + CONF_HOME_KEY, + "homeId", + "home_id", + "serial_nr", + "serial_number", +} + +# Reading 0xFFDE means connecting to the shade. Opening one connection per shade +# at once is the adapter contention that made the old startup-time power query +# flaky in the first place, so keep the fan-out bounded even though a user has +# to click a button to get here. +_MAX_PARALLEL_QUERIES: Final[int] = 3 +_QUERY_TIMEOUT_S: Final[float] = 20.0 +_HUB_TIMEOUT_S: Final[float] = 10.0 + +WHAT_WE_NEED: Final[str] = ( + "This integration does not detect a shade's power source, so every shade " + "gets battery entities whether or not it has a battery. The values below " + "are raw and uninterpreted on purpose. To help us encode this correctly, " + "please attach this file to the GitHub issue and tell us, for each shade " + "listed under 'shades', whether it runs on a battery wand, on a " + "rechargeable battery, or is hardwired to mains power." +) + + +async def _async_hub_shades( + hass: HomeAssistant, hub_url: str +) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + """Return the hub's status and its raw /home/shades records, by BLE name. + + Records pass through verbatim. Establishing what the hub actually sends is + the point, so nothing here is mapped, renamed or filtered. + """ + if not hub_url: + return {"configured": False}, {} + + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=_HUB_TIMEOUT_S) + try: + async with session.get(f"{hub_url}/home/shades", timeout=timeout) as resp: + resp.raise_for_status() + shades = await resp.json(content_type=None) + except (TimeoutError, aiohttp.ClientError, ValueError) as ex: + return { + "configured": True, + "reachable": False, + "error": f"{type(ex).__name__}: {ex}", + }, {} + + records: dict[str, dict[str, Any]] = { + shade["bleName"]: shade for shade in shades or [] if shade.get("bleName") + } + status: dict[str, Any] = { + "configured": True, + "reachable": True, + "shade_count": len(records), + # Hub records are joined to shades by an exact name match, so a shade + # whose advertised name differs from the hub's bleName silently loses + # its hub data. List the names to make such a near-miss visible. + "ble_names": sorted(records), + } + return status, records + + +def _advertisement(hass: HomeAssistant, coord: PVCoordinator) -> dict[str, Any]: + """Return the shade's most recent advertisement, raw and decoded.""" + service_info = bluetooth.async_last_service_info( + hass, coord.address, connectable=True + ) + if service_info is None: + return {"error": "no advertisement seen for this shade"} + + raw = bytes(service_info.manufacturer_data.get(MFCT_ID, b"")) + return { + "raw": raw.hex(" "), + "length": len(raw), + # The top two bits of byte 8 are what the battery sensor reports, via a + # 3/2/1/0 -> 100/50/20/0 % table. A hardwired shade is known to sit at + # 3; report the code itself so what a battery shade puts here can be + # compared against it. + "power_level_code": raw[8] >> 6 if len(raw) == 9 else None, + "rssi": service_info.rssi, + "stale": not coord.data_available, + "decoded": async_redact_data(dict(coord.data), TO_REDACT), + } + + +async def _async_power_status( + coord: PVCoordinator, sem: asyncio.Semaphore +) -> dict[str, Any]: + """Read one shade's raw 0xFFDE reply over BLE. + + Failure is data too, not an error to raise: a shade that cannot be reached + still belongs in the report, so the reason is recorded and the dump goes on. + """ + if coord.api.encrypted and not coord.api.has_key: + return {"error": "shade is encrypted and no home key is configured"} + + async with sem: + try: + payload = await asyncio.wait_for( + coord.api.query_power_status(), timeout=_QUERY_TIMEOUT_S + ) + except (BleakError, TimeoutError) as ex: + return {"error": f"{type(ex).__name__}: {ex}"} + + return { + "raw": payload.hex(" "), + "length": len(payload), + "bytes": {f"b{idx}": value for idx, value in enumerate(payload)}, + } + + +async def _async_shade( + hass: HomeAssistant, + coord: PVCoordinator, + hub_records: dict[str, dict[str, Any]], + sem: asyncio.Semaphore, +) -> dict[str, Any]: + """Collect every raw power-related signal available for one shade.""" + ble_name = coord.api.name + record = hub_records.get(ble_name) + return { + "name": coord.friendly_name, + "ble_name": ble_name, + "address": coord.address, + "type_id": coord.type_id, + "device": async_redact_data(dict(coord.dev_details), TO_REDACT), + "advertisement": _advertisement(hass, coord), + "power_status_0xffde": await _async_power_status(coord, sem), + "hub_matched": record is not None, + "hub_record": async_redact_data(record, TO_REDACT) if record else None, + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntryType +) -> dict[str, Any]: + """Return diagnostics for every shade in the config entry. + + Deliberately dumps the whole entry rather than one device: the encoding can + only be settled by comparing a battery shade against a hardwired one, so a + single file covering a mixed install is exactly what is wanted. + """ + hub_status, hub_records = await _async_hub_shades( + hass, entry.data.get(CONF_HUB_URL, "") + ) + sem = asyncio.Semaphore(_MAX_PARALLEL_QUERIES) + shades = await asyncio.gather( + *( + _async_shade(hass, coord, hub_records, sem) + for coord in entry.runtime_data.values() + ) + ) + return { + "what_we_need_from_you": WHAT_WE_NEED, + "config_entry": async_redact_data(dict(entry.data), TO_REDACT), + "hub": hub_status, + "shades": shades, + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntryType, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a single shade.""" + addresses = {ident[1] for ident in device.identifiers if ident[0] == DOMAIN} + coord = next( + (coord for addr, coord in entry.runtime_data.items() if addr in addresses), + None, + ) + if coord is None: + return {"error": "no coordinator is running for this device"} + + hub_status, hub_records = await _async_hub_shades( + hass, entry.data.get(CONF_HUB_URL, "") + ) + return { + "what_we_need_from_you": WHAT_WE_NEED, + "hub": hub_status, + "shade": await _async_shade(hass, coord, hub_records, asyncio.Semaphore(1)), + } diff --git a/scripts/shade_report.py b/scripts/shade_report.py index 8b10a8f..4a80466 100644 --- a/scripts/shade_report.py +++ b/scripts/shade_report.py @@ -87,13 +87,26 @@ # dummy data, off-by-one vs real firmware. # 0xFFDE → 8-byte payload. Byte-by-byte findings on hardwired Duette # (type 6, fw_rev=22), verified across four shades and ~10 -# targeted motion/idle experiments on one unit: -# b0 power_type 0=hardwired (confirmed); 1=battery, -# 2=rechargeable (hypothesis — no non- -# hardwired sample yet). -# b1 = 0x01 constant on all hardwired shades seen. -# Likely a power-type subvariant or flags -# byte; pending a non-hardwired sample. +# targeted motion/idle experiments on one unit, then corrected +# against a six-shade dump cross-checked with the G3 hub: +# b0 = 0x00 status, 0 = success. NOT the power type. +# Every reply in this protocol leads with a +# status code — a rejected 0xFFDE returns a +# 1-byte 0x04 (invalid length) in this same +# position, and _verify_ack_reply treats +# payload[0] == 0 as success. It was read as +# a power-source enum once, which made every +# shade that answered look hardwired (0 being +# the hardwired code) and silently stripped +# the battery sensors off battery shades. A +# constant cannot classify anything; the only +# reason it looked right is that every sample +# behind it came from a hardwired shade. +# b1 = 0x01 power type, and it agrees with the hub: the +# G3 reports powerType=1 for these same six +# shades, all of them confirmed mains-powered. +# The battery and rechargeable codes are still +# unknown — no such sample exists yet. # b2 = 0x64 (100) strong hypothesis: battery/power-level # %, pegged at 100 on wall power. A # battery shade should report its real % @@ -151,12 +164,13 @@ 0x04: "invalid length (hypothesis)", } -# 0xFFDE byte 0 — power type. 0 is confirmed hardwired on fw_rev=22. -# 1/2 are hypotheses pending sample data from battery/rechargeable shades. +# 0xFFDE byte 1 — power type (byte 0 is the reply's status code, not the power +# type). 1 = hardwired is confirmed: six shades at fw_rev=22 all report b1=0x01, +# the G3 hub independently reports powerType=1 for the same six, and all six are +# known to be mains-powered. Every other code is deliberately absent: guessing +# at them is what produced the last bug, so an unseen value prints as unknown. POWER_TYPE_LABELS: dict[int, str] = { - 0: "hardwired", - 1: "battery (hypothesis)", - 2: "rechargeable (hypothesis)", + 1: "hardwired (confirmed)", } # Known-good response lengths per opcode. Used only by annotate_query @@ -424,20 +438,21 @@ def annotate_query(cmd: int, payload: bytes) -> str | None: ) return "\n".join(lines) if cmd == 0xFFDE and len(payload) == 8: - pt = payload[0] - label = POWER_TYPE_LABELS.get(pt, f"unknown ({pt})") - # Per-byte interpretations as of 2026-04-21 investigation (see - # module-level comment for the experiment log). b0 and b6 are - # confirmed; b1/b2/b4/b7 are strong hypotheses based on four - # hardwired shades at fw_rev=22; b3 is a live noisy byte; b5 is - # persistent per-shade state with an unknown refresh trigger. + power_type = payload[1] + label = POWER_TYPE_LABELS.get(power_type, f"unknown ({power_type})") + # Per-byte interpretations as of 2026-04-21, corrected once the hub + # cross-check landed (see module-level comment). b0, b1 and b6 are + # confirmed; b2/b4/b7 are strong hypotheses based on hardwired shades + # at fw_rev=22; b3 is a live noisy byte; b5 is persistent per-shade + # state with an unknown refresh trigger. # 11-space indent on continuation lines aligns with the "→ " # prefix added by _print_queries (9 spaces + arrow + space). indent = " " return ( - f"byte 0 = 0x{payload[0]:02X} → power_type={pt} ({label})\n" - f"{indent}byte 1 = 0x{payload[1]:02X} → power-subtype/flags " - f"(const 0x01 on hardwired; hypothesis)\n" + f"byte 0 = 0x{payload[0]:02X} → status (0 = success; NOT the power " + f"type — reading it as one is what hid the battery sensors)\n" + f"{indent}byte 1 = 0x{payload[1]:02X} → power_type={power_type} " + f"({label}); same encoding as the hub's powerType\n" f"{indent}byte 2 = 0x{payload[2]:02X} ({payload[2]}) → " f"battery/power level % (hypothesis — pegged at 100 on hardwired)\n" f"{indent}byte 3 = 0x{payload[3]:02X} → live/noisy byte " From be7743167462f7855873ab1888cad16a3ca5ffa6 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Sun, 12 Jul 2026 09:30:36 +1000 Subject: [PATCH 37/37] fix: extend diagnostics to all shade features --- .../diagnostics.py | 182 +++++++++++------- 1 file changed, 115 insertions(+), 67 deletions(-) diff --git a/custom_components/hunterdouglas_powerview_ble/diagnostics.py b/custom_components/hunterdouglas_powerview_ble/diagnostics.py index 2b91171..7dea69d 100644 --- a/custom_components/hunterdouglas_powerview_ble/diagnostics.py +++ b/custom_components/hunterdouglas_powerview_ble/diagnostics.py @@ -1,23 +1,18 @@ """Diagnostics for the Hunter Douglas PowerView (BLE) integration. -Nothing in this module interprets what it collects, by design. The integration -used to classify a shade's power source and hide the battery entities of any -shade it judged hardwired; the classification was built on samples taken from -hardwired shades only, so it misread battery shades as hardwired and hid the -very sensors it was meant to protect. That code is gone. - -This dump gathers the evidence needed to get the encoding right instead of -guessing at it: the hub's record verbatim, the raw advertisement, and the raw -0xFFDE reply, correlated per shade so a user can label each one and report back. -Three things are unknown and each is answered by a field below: - - * what the hub reports in `powerType` for a *known* battery shade - (`shades[].hub_record`) - * whether the 0xFFDE reply differs at all between battery and hardwired - shades (`shades[].power_status_0xffde`) - * whether 0xFFDE byte 2 is a real battery percentage — it reads 100 on - hardwired shades — which would beat the advertisement's four-step level - (`shades[].advertisement.power_level_code`) +Dumps every signal the integration holds for each shade, decoded as little as +possible: the advertisement bytes, the shade's replies to its GATT queries, the +hub's record, plus device, capability and connectivity state. It is meant to be +attached to a bug report, whatever the bug is — position and tilt, range and +connectivity, encryption and control failures, the reset/reinit flags, or a +shade's power source. + +Raw values are reported *alongside* the integration's interpretation of them, +never instead of it, because the two have disagreed before: byte 0 of the 0xFFDE +reply was taken for a power-source enum when it is really the protocol's status +code. It reads 0 on every successful reply, so the integration concluded that +every shade was mains-powered and stripped the battery sensors off the ones that +were not. A decoded field can be wrong; the bytes it came from cannot. """ import asyncio @@ -27,42 +22,45 @@ from bleak.exc import BleakError from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.redact import async_redact_data from . import ConfigEntryType +from .api import ShadeCmd from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, MFCT_ID from .coordinator import PVCoordinator -# home_key is the AES key that drives the shades and home_id identifies the -# PowerView network; a serial number identifies the owner's hardware. None of -# them are needed to answer the power-source question, and this file is meant -# to be attached to a public issue. +# home_key is the AES key that drives the shades, home_id identifies the +# PowerView network, and a serial number identifies the owner's hardware. None +# is needed to diagnose anything, and this file gets attached to public issues. +# Each key is listed in both spellings: the shade reports snake_case over GATT +# and the hub reports camelCase in its JSON, so redacting one spelling alone +# silently leaks the same value from the other source. TO_REDACT: Final[set[str]] = { CONF_HOME_KEY, "homeId", + "homeKey", "home_id", + "serialNumber", "serial_nr", "serial_number", } -# Reading 0xFFDE means connecting to the shade. Opening one connection per shade -# at once is the adapter contention that made the old startup-time power query -# flaky in the first place, so keep the fan-out bounded even though a user has -# to click a button to get here. +# Every GATT query costs a BLE connection. Opening one per shade at once is the +# adapter contention that made the old startup-time queries unreliable, so keep +# the fan-out bounded even though a user has to click a button to get here. _MAX_PARALLEL_QUERIES: Final[int] = 3 _QUERY_TIMEOUT_S: Final[float] = 20.0 _HUB_TIMEOUT_S: Final[float] = 10.0 -WHAT_WE_NEED: Final[str] = ( - "This integration does not detect a shade's power source, so every shade " - "gets battery entities whether or not it has a battery. The values below " - "are raw and uninterpreted on purpose. To help us encode this correctly, " - "please attach this file to the GitHub issue and tell us, for each shade " - "listed under 'shades', whether it runs on a battery wand, on a " - "rechargeable battery, or is hardwired to mains power." +_NOTE: Final[str] = ( + "Raw, uninterpreted values for every shade, for attaching to a GitHub " + "issue. Some things the integration cannot know and must be stated in the " + "issue text — notably whether a shade runs on a battery or is wired to " + "mains, which it does not detect." ) @@ -72,7 +70,7 @@ async def _async_hub_shades( """Return the hub's status and its raw /home/shades records, by BLE name. Records pass through verbatim. Establishing what the hub actually sends is - the point, so nothing here is mapped, renamed or filtered. + half the value here, so nothing is mapped, renamed or filtered. """ if not hub_url: return {"configured": False}, {} @@ -84,8 +82,12 @@ async def _async_hub_shades( resp.raise_for_status() shades = await resp.json(content_type=None) except (TimeoutError, aiohttp.ClientError, ValueError) as ex: + # A .local hub URL resolves over mDNS, which is flaky from inside a + # container at boot; the integration swallows that failure silently, so + # surface it here. return { "configured": True, + "url": hub_url, "reachable": False, "error": f"{type(ex).__name__}: {ex}", }, {} @@ -95,6 +97,7 @@ async def _async_hub_shades( } status: dict[str, Any] = { "configured": True, + "url": hub_url, "reachable": True, "shade_count": len(records), # Hub records are joined to shades by an exact name match, so a shade @@ -105,11 +108,50 @@ async def _async_hub_shades( return status, records -def _advertisement(hass: HomeAssistant, coord: PVCoordinator) -> dict[str, Any]: - """Return the shade's most recent advertisement, raw and decoded.""" - service_info = bluetooth.async_last_service_info( - hass, coord.address, connectable=True - ) +def _device(coord: PVCoordinator) -> dict[str, Any]: + """Return the shade's identity: type, model and firmware revisions.""" + return { + "type_id": coord.type_id, + **async_redact_data(dict(coord.dev_details), TO_REDACT), + } + + +def _capabilities(coord: PVCoordinator) -> dict[str, bool]: + """Return what the integration believes this shade type can do. + + Derived from type_id alone, so a shade whose entities look wrong (a missing + tilt, a phantom second rail) is usually a wrong or unmapped type_id here. + """ + caps = coord.shade_capabilities + return { + "has_tilt": caps.has_tilt, + "tilt_only": caps.tilt_only, + "is_tilt_on_closed": caps.is_tilt_on_closed, + "is_top_down": caps.is_top_down, + "is_tdbu": caps.is_tdbu, + "is_duolite": caps.is_duolite, + } + + +def _connectivity( + coord: PVCoordinator, service_info: BluetoothServiceInfoBleak | None +) -> dict[str, Any]: + """Return radio and encryption state, for range and control failures.""" + return { + "present": coord.device_present, + "rssi": service_info.rssi if service_info else None, + # Position, tilt and battery ride only on V2 adverts; once those stop + # arriving the retained sample is stale and the entities read unknown. + "advert_stale": not coord.data_available, + "encrypted": coord.api.encrypted, + "home_key_configured": coord.api.has_key, + } + + +def _advertisement( + coord: PVCoordinator, service_info: BluetoothServiceInfoBleak | None +) -> dict[str, Any]: + """Return the last advertisement: raw bytes and the decoded sample.""" if service_info is None: return {"error": "no advertisement seen for this shade"} @@ -117,41 +159,48 @@ def _advertisement(hass: HomeAssistant, coord: PVCoordinator) -> dict[str, Any]: return { "raw": raw.hex(" "), "length": len(raw), - # The top two bits of byte 8 are what the battery sensor reports, via a - # 3/2/1/0 -> 100/50/20/0 % table. A hardwired shade is known to sit at - # 3; report the code itself so what a battery shade puts here can be - # compared against it. + "is_v2_record": len(raw) == 9, + # The top two bits of byte 8 drive the battery sensor via a + # 3/2/1/0 -> 100/50/20/0 % table. Report the code itself: hardwired + # shades also sit at 3, so the decoded 100% below means little on its + # own. "power_level_code": raw[8] >> 6 if len(raw) == 9 else None, - "rssi": service_info.rssi, - "stale": not coord.data_available, "decoded": async_redact_data(dict(coord.data), TO_REDACT), + "velocity": coord.velocity, } -async def _async_power_status( +async def _async_queries( coord: PVCoordinator, sem: asyncio.Semaphore ) -> dict[str, Any]: - """Read one shade's raw 0xFFDE reply over BLE. + """Read the shade's GATT queries, reported byte-for-byte. Failure is data too, not an error to raise: a shade that cannot be reached still belongs in the report, so the reason is recorded and the dump goes on. + Keyed by opcode so further queries can be added without reshaping the file. """ if coord.api.encrypted and not coord.api.has_key: - return {"error": "shade is encrypted and no home key is configured"} + reason = {"error": "shade is encrypted and no home key is configured"} + return {ShadeCmd.POWER_STATUS.name: reason} async with sem: try: payload = await asyncio.wait_for( coord.api.query_power_status(), timeout=_QUERY_TIMEOUT_S ) + result: dict[str, Any] = { + "opcode": f"0x{ShadeCmd.POWER_STATUS.value:04X}", + "raw": payload.hex(" "), + "length": len(payload), + "bytes": {f"b{idx}": value for idx, value in enumerate(payload)}, + } except (BleakError, TimeoutError) as ex: - return {"error": f"{type(ex).__name__}: {ex}"} + result = { + "opcode": f"0x{ShadeCmd.POWER_STATUS.value:04X}", + "error": f"{type(ex).__name__}: {ex}", + } - return { - "raw": payload.hex(" "), - "length": len(payload), - "bytes": {f"b{idx}": value for idx, value in enumerate(payload)}, - } + return {ShadeCmd.POWER_STATUS.name: result} async def _async_shade( @@ -160,17 +209,21 @@ async def _async_shade( hub_records: dict[str, dict[str, Any]], sem: asyncio.Semaphore, ) -> dict[str, Any]: - """Collect every raw power-related signal available for one shade.""" + """Collect every signal the integration holds for one shade.""" + service_info = bluetooth.async_last_service_info( + hass, coord.address, connectable=True + ) ble_name = coord.api.name record = hub_records.get(ble_name) return { "name": coord.friendly_name, "ble_name": ble_name, "address": coord.address, - "type_id": coord.type_id, - "device": async_redact_data(dict(coord.dev_details), TO_REDACT), - "advertisement": _advertisement(hass, coord), - "power_status_0xffde": await _async_power_status(coord, sem), + "device": _device(coord), + "capabilities": _capabilities(coord), + "connectivity": _connectivity(coord, service_info), + "advertisement": _advertisement(coord, service_info), + "queries": await _async_queries(coord, sem), "hub_matched": record is not None, "hub_record": async_redact_data(record, TO_REDACT) if record else None, } @@ -179,12 +232,7 @@ async def _async_shade( async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntryType ) -> dict[str, Any]: - """Return diagnostics for every shade in the config entry. - - Deliberately dumps the whole entry rather than one device: the encoding can - only be settled by comparing a battery shade against a hardwired one, so a - single file covering a mixed install is exactly what is wanted. - """ + """Return diagnostics for every shade in the config entry.""" hub_status, hub_records = await _async_hub_shades( hass, entry.data.get(CONF_HUB_URL, "") ) @@ -196,7 +244,7 @@ async def async_get_config_entry_diagnostics( ) ) return { - "what_we_need_from_you": WHAT_WE_NEED, + "note": _NOTE, "config_entry": async_redact_data(dict(entry.data), TO_REDACT), "hub": hub_status, "shades": shades, @@ -219,7 +267,7 @@ async def async_get_device_diagnostics( hass, entry.data.get(CONF_HUB_URL, "") ) return { - "what_we_need_from_you": WHAT_WE_NEED, + "note": _NOTE, "hub": hub_status, "shade": await _async_shade(hass, coord, hub_records, asyncio.Semaphore(1)), }