fix: device and entity naming
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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("<BBBB", 251, 18, 1, 0)
|
||||
|
||||
# Try each shade until one returns a valid key (some may be out of range)
|
||||
last_error: Exception = ValueError("No shades responded")
|
||||
for shade in shades:
|
||||
ble_name = shade.get("bleName", "")
|
||||
if not ble_name:
|
||||
continue
|
||||
for hs in hub_shades:
|
||||
try:
|
||||
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()},
|
||||
timeout=timeout,
|
||||
) 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}")
|
||||
|
||||
|
||||
_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):
|
||||
"""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._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."""
|
||||
return self.async_create_entry(
|
||||
title=self._device_name,
|
||||
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=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 = ""
|
||||
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()
|
||||
if await self._validate_homekey_input(user_input, errors):
|
||||
# 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
|
||||
friendly = self._hub_name_for(self._device_name)
|
||||
if friendly:
|
||||
self._device_name = friendly
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user