From 31185a4446a8cc7db0c59988b76d8997f4cbd753 Mon Sep 17 00:00:00 2001 From: Richard Mann Date: Mon, 6 Apr 2026 09:07:16 +1000 Subject: [PATCH] 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']}'")