fix: device and entity naming

This commit is contained in:
Richard Mann
2026-04-06 12:17:19 +10:00
parent 317b450702
commit 894580c20b
4 changed files with 185 additions and 190 deletions

View File

@@ -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" 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: try:
await coordinator.query_dev_info() await coordinator.query_dev_info()
except BleakError as err: except BleakError as err:

View File

@@ -48,6 +48,38 @@ class HubShadeInfo:
ble_name: str # BLE advertisement name, e.g. "DUE:94ED" 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( async def _fetch_key_and_shades_from_hub(
hass: HomeAssistant, hub_url: str hass: HomeAssistant, hub_url: str
) -> tuple[bytes, list[HubShadeInfo]]: ) -> tuple[bytes, list[HubShadeInfo]]:
@@ -61,42 +93,22 @@ async def _fetch_key_and_shades_from_hub(
Raises aiohttp.ClientError on network errors. Raises aiohttp.ClientError on network errors.
Raises asyncio.TimeoutError on timeout. Raises asyncio.TimeoutError on timeout.
""" """
session = async_get_clientsession(hass) hub_shades = await _fetch_shades_from_hub(hass, hub_url)
timeout = aiohttp.ClientTimeout(total=10) if not hub_shades:
# 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") raise ValueError("No shades found on the hub")
# Parse shade metadata (name is base64-encoded on the hub) session = async_get_clientsession(hass)
hub_shades: list[HubShadeInfo] = [] timeout = aiohttp.ClientTimeout(total=10)
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 # GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0
request_frame = struct.pack("<BBBB", 251, 18, 1, 0) request_frame = struct.pack("<BBBB", 251, 18, 1, 0)
# Try each shade until one returns a valid key (some may be out of range) # Try each shade until one returns a valid key (some may be out of range)
last_error: Exception = ValueError("No shades responded") last_error: Exception = ValueError("No shades responded")
for shade in shades: for hs in hub_shades:
ble_name = shade.get("bleName", "")
if not ble_name:
continue
try: try:
async with session.post( async with session.post(
f"{hub_url}/home/shades/exec?shades={ble_name}", f"{hub_url}/home/shades/exec?shades={hs.ble_name}",
json={"hex": request_frame.hex()}, json={"hex": request_frame.hex()},
timeout=timeout, timeout=timeout,
) as resp: ) as resp:
@@ -126,6 +138,36 @@ async def _fetch_key_and_shades_from_hub(
raise ValueError(f"No reachable shade returned a valid key: {last_error}") raise ValueError(f"No reachable shade returned a valid key: {last_error}")
_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)",
},
]
)
),
vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional("home_key", default=""): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT)
),
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BT Battery Management System.""" """Handle a config flow for BT Battery Management System."""
@@ -147,18 +189,72 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._manufacturer_data_hex: str = "" self._manufacturer_data_hex: str = ""
self._device_name: str = "" self._device_name: str = ""
self._home_key: str = "" self._home_key: str = ""
self._hub_url: str = ""
self._hub_shades: list[HubShadeInfo] = [] self._hub_shades: list[HubShadeInfo] = []
def _create_entry(self) -> ConfigFlowResult: def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry with collected data.""" """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( return self.async_create_entry(
title=self._device_name, title=self._device_name,
data={ data=data,
"manufacturer_data": self._manufacturer_data_hex,
CONF_HOME_KEY: self._home_key,
},
) )
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( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -189,6 +285,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# Unencrypted shades can skip the homekey step entirely # Unencrypted shades can skip the homekey step entirely
if not _needs_encryption(self._manufacturer_data_hex): if not _needs_encryption(self._manufacturer_data_hex):
await self._resolve_friendly_name()
return self._create_entry() return self._create_entry()
return await self.async_step_homekey_bluetooth() return await self.async_step_homekey_bluetooth()
@@ -208,95 +305,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
existing = self._existing_home_key() existing = self._existing_home_key()
if existing and user_input is None: if existing and user_input is None:
self._home_key = existing self._home_key = existing
await self._resolve_friendly_name()
return self._create_entry() return self._create_entry()
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
method = user_input.get("key_method", "skip") if await self._validate_homekey_input(user_input, errors):
# Use hub name for the entry title if available
if method == "skip": friendly = self._hub_name_for(self._device_name)
self._home_key = "" if friendly:
self._device_name = friendly
return self._create_entry() 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( return self.async_show_form(
step_id="homekey_bluetooth", step_id="homekey_bluetooth",
data_schema=vol.Schema( data_schema=_HOMEKEY_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, errors=errors,
description_placeholders={"name": self._device_name}, 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: def _existing_home_key(self) -> str:
"""Return the home_key from any already-configured entry, or ''.""" """Return the home_key from any already-configured entry, or ''."""
for entry in self._async_current_entries(): return self._existing_entry_value(CONF_HOME_KEY)
key = entry.data.get(CONF_HOME_KEY, "")
if key: async def _resolve_friendly_name(self) -> None:
return key """Try to resolve BLE device name to hub friendly name."""
return "" 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: def _hub_name_for(self, ble_name: str) -> str | None:
"""Return the human-readable hub name for a BLE name, or 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 ble_name = device.name
name = self._hub_name_for(ble_name) or ble_name name = self._hub_name_for(ble_name) or ble_name
mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex() 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( entries.append(
{ {
"address": address, "address": address,
"name": name, "name": name,
"data": { "data": entry_data,
"manufacturer_data": mfct_hex,
CONF_HOME_KEY: self._home_key,
},
} }
) )
# Kick off auto-add flows for all but the last shade # Kick off auto-add flows for all but the last shade
for info in entries[:-1]: await asyncio.gather(*(
await self.hass.config_entries.flow.async_init( self.hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": "auto_add"}, context={"source": "auto_add"},
data=info, data=info,
) )
for info in entries[:-1]
))
# Create the final entry normally (ends this flow) # Create the final entry normally (ends this flow)
last = entries[-1] last = entries[-1]
@@ -399,12 +458,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
async def async_step_auto_add( async def async_step_auto_add(
self, data: dict[str, Any] self, user_input: dict[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Create a config entry for a shade selected via multi-select.""" """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() 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( async def async_step_manual(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -440,75 +501,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
method = user_input.get("key_method", "skip") if await self._validate_homekey_input(user_input, errors):
if method == "skip":
self._home_key = ""
return await self.async_step_select_device() 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( return self.async_show_form(
step_id="homekey", step_id="homekey",
data_schema=vol.Schema( data_schema=_HOMEKEY_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, errors=errors,
) )

View File

@@ -20,11 +20,13 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
"""Update coordinator for a battery management system.""" """Update coordinator for a battery management system."""
def __init__( 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: ) -> None:
"""Initialize BMS data coordinator.""" """Initialize BMS data coordinator."""
assert ble_device.name is not None assert ble_device.name is not None
self._mac = ble_device.address 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_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.api = PowerViewBLE(ble_device, home_key)
@@ -34,7 +36,7 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
LOGGER.debug( LOGGER.debug(
"Initializing coordinator for %s (%s)", "Initializing coordinator for %s (%s)",
ble_device.name, self._friendly_name,
ble_device.address, ble_device.address,
) )
super().__init__( super().__init__(
@@ -52,16 +54,15 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return detailed device information for GUI.""" """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( return DeviceInfo(
identifiers={ identifiers={
(DOMAIN, self.name), (DOMAIN, self.address),
(BLUETOOTH_DOMAIN, self.address), (BLUETOOTH_DOMAIN, self.address),
}, },
connections={(CONNECTION_BLUETOOTH, self.address)}, connections={(CONNECTION_BLUETOOTH, self.address)},
name=self.name, name=self._friendly_name,
configuration_url=None, configuration_url=None,
# properties used in GUI:
manufacturer="Hunter Douglas", manufacturer="Hunter Douglas",
model=( model=(
str(SHADE_TYPE.get(int(bytes.fromhex(self._manuf_dat)[2]), "unknown")) str(SHADE_TYPE.get(int(bytes.fromhex(self._manuf_dat)[2]), "unknown"))
@@ -95,9 +96,6 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
) -> None: ) -> None:
"""Handle a Bluetooth event.""" """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) LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data)
self.api.set_ble_device(service_info.device) self.api.set_ble_device(service_info.device)
self.data = {ATTR_RSSI: service_info.rssi} self.data = {ATTR_RSSI: service_info.rssi}

View File

@@ -62,7 +62,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
) -> None: ) -> None:
"""Initialize the shade.""" """Initialize the shade."""
LOGGER.debug("%s: init() PowerViewCover", coordinator.name) LOGGER.debug("%s: init() PowerViewCover", coordinator.name)
self._attr_name = CoverDeviceClass.SHADE self._attr_name = None
self._coord: PVCoordinator = coordinator self._coord: PVCoordinator = coordinator
self._attr_device_info = self._coord.device_info self._attr_device_info = self._coord.device_info
self._target_position: int | None = round( self._target_position: int | None = round(