From 894580c20bd467d9289aa451c9c0a65af38904a1 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 12:17:19 +1000 Subject: [PATCH] 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(