From 31185a4446a8cc7db0c59988b76d8997f4cbd753 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 09:07:16 +1000 Subject: [PATCH 01/19] 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 | 432 ++++++++++++++++-- .../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, 540 insertions(+), 47 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 @@ class PowerViewBLE: 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.components.bluetooth import ( ) 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(" 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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) + 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() + entries.append( + { + "address": address, + "name": name, + "data": { + "manufacturer_data": mfct_hex, + CONF_HOME_KEY: self._home_key, + }, + } + ) + + # 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._discovered_device = self._discovered_devices[address] - - self.context["title_placeholders"] = {"name": self._discovered_device.name} - - return self.async_create_entry( - title=self._discovered_device.name, - data={ - "manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[ - MFCT_ID - ].hex() - }, - ) + 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) 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 @@ LOGGER: Final = logging.getLogger(__package__) 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.core import HomeAssistant, callback 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): """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.device_registry import DeviceInfo, format_mac 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti 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/19] 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 @@ class PowerViewBLE: 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 @@ class PowerViewBLE: 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): # 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/19] fix: device and entity naming --- .../hunterdouglas_powerview_ble/__init__.py | 2 +- .../config_flow.py | 355 +++++++++--------- .../coordinator.py | 16 +- .../hunterdouglas_powerview_ble/cover.py | 2 +- 4 files changed, 185 insertions(+), 190 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,6 +48,38 @@ class HubShadeInfo: ble_name: str # BLE advertisement name, e.g. "DUE:94ED" +async def _fetch_shades_from_hub( + hass: HomeAssistant, hub_url: str +) -> list[HubShadeInfo]: + """Fetch shade list with human-readable names from a PowerView G3 hub. + + Raises aiohttp.ClientError on network errors. + Raises asyncio.TimeoutError on timeout. + """ + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=10) + + 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: + 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]]: @@ -61,42 +93,22 @@ async def _fetch_key_and_shades_from_hub( 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: + hub_shades = await _fetch_shades_from_hub(hass, hub_url) + if not hub_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)) + 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(" 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # 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,95 +305,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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_entry_value(self, key: str) -> str: + """Return the first non-empty value for *key* across configured entries.""" + 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 ''.""" - for entry in self._async_current_entries(): - key = entry.data.get(CONF_HOME_KEY, "") - if key: - return key - return "" + 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.""" @@ -334,24 +388,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): LOGGER.debug( "Initializing coordinator for %s (%s)", - ble_device.name, + self._friendly_name, ble_device.address, ) super().__init__( @@ -52,16 +54,15 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): ) -> 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti ) -> 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/19] 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) 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/19] fix: linting and formatting --- .../hunterdouglas_powerview_ble/api.py | 2 +- .../binary_sensor.py | 4 +- .../config_flow.py | 205 ++++++++++-------- .../coordinator.py | 9 +- .../hunterdouglas_powerview_ble/cover.py | 4 +- scripts/extract_gateway3_homekey.py | 4 +- 6 files changed, 128 insertions(+), 100 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 @@ class PowerViewBLE: 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,27 +114,27 @@ async def _fetch_key_and_shades_from_hub( ) as resp: resp.raise_for_status() result = await resp.json(content_type=None) - - 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(" 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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/19] 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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/19] 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 @@ _HOMEKEY_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)", - }, + 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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/19] 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 | 5 +- .../hunterdouglas_powerview_ble/api.py | 35 ++++++++-- .../coordinator.py | 21 ++++-- .../hunterdouglas_powerview_ble/cover.py | 34 ++++----- .../hunterdouglas_powerview_ble/number.py | 69 +++++++++++++++++++ 5 files changed, 137 insertions(+), 27 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 @@ from .coordinator import PVCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, - Platform.COVER, - Platform.SENSOR, Platform.BUTTON, + Platform.COVER, + Platform.NUMBER, + Platform.SENSOR, ] 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 @@ import asyncio 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 @@ SHADE_TYPE: Final[dict[int, str]] = { 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 @@ class PowerViewBLE: 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.components.bluetooth.passive_update_coordinator import ( 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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.components.cover import ( ) 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti ) 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti 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 @@ class PowerViewCoverTilt(PowerViewCover): 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/19] 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.components.bluetooth.passive_update_coordinator import ( 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/19] feat: refactor to a hub model --- .../hunterdouglas_powerview_ble/__init__.py | 208 ++++++-- .../binary_sensor.py | 23 +- .../hunterdouglas_powerview_ble/button.py | 22 +- .../config_flow.py | 485 ++++-------------- .../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, 357 insertions(+), 556 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 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 .const import LOGGER +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 @@ PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -type ConfigEntryType = ConfigEntry[PVCoordinator] +type HubRuntimeData = dict[str, PVCoordinator] +type ConfigEntryType = ConfigEntry[HubRuntimeData] + +type AddEntitiesFn = Callable[[PVCoordinator, AddEntitiesCallback], None] + + +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) + + @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: + 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(), 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: + 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 BT Battery Management System from a config entry.""" + """Set up PowerView Home from a config entry.""" LOGGER.debug("Setup of %s", repr(entry)) - if entry.unique_id is None: - raise ConfigEntryError("Missing unique ID for device.") + entry.runtime_data = {} - ble_device: BLEDevice | None = async_ble_device_from_address( - hass=hass, address=entry.unique_id, connectable=True + # 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) + + # Forward platforms first so dispatched entities have their setup ready + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # 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, + ) ) - if not ble_device: - raise ConfigEntryNotReady( - f"Could not find PowerView device ({entry.unique_id}) via Bluetooth" - ) - - coordinator = PVCoordinator(hass, ble_device, entry.data.copy(), entry.title) - try: - await coordinator.query_dev_info() - except BleakError as err: - raise ConfigEntryNotReady("Unable to query device info.") from err - - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(coordinator.async_start()) 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.core import HomeAssistant 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 @@ BINARY_SENSOR_TYPES: list[BinarySensorEntityDescription] = [ ] +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 @@ from homeassistant.components.button import ( 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 @@ BUTTONS_SHADE: Final = [ ] +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 aiohttp 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 @@ from homeassistant.helpers.selector import ( 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 _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 _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" -@dataclass -class HubShadeInfo: - """Shade metadata from the PowerView hub.""" +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 - name: str # Human-readable name (decoded from base64) - ble_name: str # BLE advertisement name, e.g. "DUE:94ED" + 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 + + 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 "" + if entry.version >= 2: + return self.async_abort(reason="already_configured") - 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 + # 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] + # 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") - 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) - ), - } - ), - ) - - 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 @@ MFCT_ID: Final[int] = 2073 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @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 @@ from homeassistant.components.cover import ( 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.core import HomeAssistant 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.core import HomeAssistant 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 @@ SENSOR_TYPES: list[SensorEntityDescription] = [ ] +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/19] 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 @@ SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { 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/19] 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 @@ class PowerViewCoverTilt(PowerViewCover): 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/19] 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 @@ class PowerViewBLE: 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.components.button import ( ) 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 @@ class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEn ) 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): ) -> 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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): @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 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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 @@ class PowerViewCoverTilt(PowerViewCover): 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/19] 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 @@ from homeassistant.helpers.selector import ( 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 - # If a hub entry already exists, shades are auto-discovered + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # 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/19] 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/19] 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 @@ SHADE_TYPE: Final[dict[int, str]] = { 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 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti @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/19] 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 @@ class PowerViewBLE: 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/19] 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 @@ SHADE_TYPE: Final[dict[int, str]] = { 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 @@ SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { # 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/19] 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 @@ SHADE_CAPABILITIES: Final[dict[int, ShadeCapability]] = { 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 @@ class PowerViewCoverTilt(PowerViewCover): 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.