diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index dd594f2..fef2f61 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -4,53 +4,194 @@ @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] = [ Platform.BINARY_SENSOR, - Platform.COVER, - Platform.SENSOR, Platform.BUTTON, + Platform.COVER, + Platform.NUMBER, + 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()) - 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 @@ -58,20 +199,8 @@ 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: + 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/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index 5a6cc97..e11f755 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 @@ -40,24 +40,88 @@ 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", - # top down, tilt anywhere + # 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 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): + """Capability flags for a shade type.""" + + 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) + + +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), + 40: ShadeCapability(has_tilt=True, tilt_only=True), + # tilt on closed (tilt only available at fully closed position) + 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), + # 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 (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() + + +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 @@ -97,18 +161,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 +181,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.""" @@ -134,6 +194,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.""" @@ -182,27 +247,34 @@ 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 [] - pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") + return {} + # 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)), - ("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 / 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(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), + } # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity async def set_position( @@ -227,7 +299,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") @@ -236,20 +308,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 @@ -355,6 +427,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/binary_sensor.py b/custom_components/hunterdouglas_powerview_ble/binary_sensor.py index dda9127..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,21 +26,30 @@ 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(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/button.py b/custom_components/hunterdouglas_powerview_ble/button.py index bf7649b..fcf1a3d 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.device_registry import 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,23 +28,28 @@ 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] """Representation of a powerview shade.""" _attr_has_entity_name = True - _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( self, @@ -60,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/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 7a38b35..e26881b 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -1,45 +1,253 @@ -"""Config flow for BLE Battery Management System integration.""" +"""Config flow for Hunter Douglas PowerView BLE integration.""" -from dataclasses import dataclass +import hashlib +import struct from typing import Any +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 ( 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, CONF_HUB_URL, DOMAIN, LOGGER, MFCT_ID + + +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" + + +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 + + 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(" 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. + + 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. + """ + 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: + raise ValueError("No shades found on the hub") + + # 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") + + # GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0 + request_frame = struct.pack(" None: """Initialize the config flow.""" + self._home_key: str = "" + self._hub_url: str = "" - self._discovered_device: ConfigFlow.DiscoveredDevice | None = None - self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} + def _create_entry(self) -> ConfigFlowResult: + """Create the hub config entry.""" + data: dict[str, str] = {CONF_HOME_KEY: self._home_key} + if self._hub_url: + 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. + + 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: + """Parse and validate homekey user_input, populating self state. + + Returns True on success, False on validation error (errors dict is populated). + """ + method = user_input.get("key_method", "skip") + + if method == "skip": + self._home_key = "" + return True + + if method == "manual": + return self._validate_manual_key(user_input, errors) + + if method != "hub": + return False + + 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", + TimeoutError: "hub_timeout", + ValueError: "hub_protocol_error", + } + try: + key = await _fetch_key_from_hub(self.hass, hub_url) + except tuple(_HUB_ERROR_MAP) as 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 + return True async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -47,92 +255,59 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initialized by Bluetooth discovery.""" LOGGER.debug("Bluetooth device detected: %s", discovery_info) - 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 + + await self.async_set_unique_id(unique_id) 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() + # 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") - 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: - return self.async_create_entry( - title=self._discovered_device.name, - data={ - "manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[ - MFCT_ID - ].hex() - }, - ) - - self._set_confirm_only() - - return self.async_show_form( - step_id="bluetooth_confirm", - description_placeholders={"name": self._discovered_device.name}, - ) + # 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 to pick discovered device.""" + """Handle the user step — create a hub entry.""" LOGGER.debug("user step") - if user_input is not None: - address = user_input[CONF_ADDRESS] - await self.async_set_unique_id(address, raise_on_progress=False) + # 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") + + errors: dict[str, str] = {} + + if user_input is not None and await self._validate_homekey_input( + user_input, errors + ): + 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() - 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() - }, - ) - - 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 self.async_abort(reason="no_devices_found") - - titles: list[SelectOptionDict] = [] - for address, discovery in self._discovered_devices.items(): - titles.append({"value": address, "label": discovery.name}) + return self._create_entry() return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_ADDRESS): SelectSelector( - SelectSelectorConfig(options=titles) - ) - } - ), + 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 d723594..f8b0c1b 100644 --- a/custom_components/hunterdouglas_powerview_ble/const.py +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -8,10 +8,11 @@ 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" +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 68883cc..e6e7a67 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -12,27 +12,36 @@ 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 .const import ATTR_RSSI, DOMAIN, HOME_KEY, LOGGER +from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities +from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER 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.api = PowerViewBLE(ble_device, HOME_KEY) + 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) 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)", - ble_device.name, + self._friendly_name, ble_device.address, ) super().__init__( @@ -42,6 +51,19 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): bluetooth.BluetoothScanningMode.ACTIVE, ) + @property + def type_id(self) -> int | None: + """Return the shade type ID from manufacturer data or live BLE data.""" + if self._manuf_dat: + return int(bytes.fromhex(self._manuf_dat)[2]) + live = self.data.get("type_id") + return int(live) if live is not None else 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) @@ -50,24 +72,23 @@ 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")) - 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"), @@ -77,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.""" @@ -93,18 +114,19 @@ 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.data = {ATTR_RSSI: service_info.rssi} + self.api.set_ble_device(service_info.device) + 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 4c25bf5..a37e40e 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -16,34 +16,45 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -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 . import ConfigEntryType, async_setup_shade_platform from .api import CLOSED_POSITION, OPEN_POSITION -from .const import DOMAIN, HOME_KEY, LOGGER +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 demo cover platform.""" + """Create cover entities for a single shade coordinator.""" + caps = coordinator.shade_capabilities - coordinator: PVCoordinator = config_entry.runtime_data - model: Final[str|None] = coordinator.dev_details.get("model") - entities: list[PowerViewCover] = [] - if model in ["39"]: - entities.append(PowerViewCoverTiltOnly(coordinator)) + 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: + entities = [PowerViewCoverTopDown(coordinator)] else: - entities.append(PowerViewCover(coordinator)) + entities = [PowerViewCover(coordinator)] 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.""" @@ -62,7 +73,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( @@ -73,11 +84,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.""" @@ -106,9 +112,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 len(HOME_KEY) != 16 - ) 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 @@ -133,7 +137,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 +160,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 +173,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 +234,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: @@ -240,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.""" @@ -255,6 +264,101 @@ 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. + + 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.""" diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json index eeab944..9680d6b 100644 --- a/custom_components/hunterdouglas_powerview_ble/manifest.json +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -11,10 +11,10 @@ "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"], "requirements": ["cryptography>=43.0.0"], - "version": "0.23" + "version": "0.24" } diff --git a/custom_components/hunterdouglas_powerview_ble/number.py b/custom_components/hunterdouglas_powerview_ble/number.py new file mode 100644 index 0000000..b92310c --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/number.py @@ -0,0 +1,74 @@ +"""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, 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, + config_entry: ConfigEntryType, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the velocity number entity.""" + async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities) + + +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() 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 19601be..0fccf5e 100644 --- a/custom_components/hunterdouglas_powerview_ble/strings.json +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -1,15 +1,32 @@ { "config": { - "flow_title": "Setup {name}", + "flow_title": "PowerView Home Setup", "step": { - "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "user": { + "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", + "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...)" + } } }, + "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%]", - "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 528686d..4551c16 100644 --- a/custom_components/hunterdouglas_powerview_ble/translations/en.json +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -1,15 +1,32 @@ { "config": { + "flow_title": "PowerView Home Setup", + "step": { + "user": { + "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", + "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...)" + } + } + }, + "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" - }, - "flow_title": "Setup {name}", - "step": { - "bluetooth_confirm": { - "description": "Do you want to set up {name}?" - } + "single_instance_allowed": "Already configured. Only a single configuration possible." } } } diff --git a/scripts/extract_gateway3_homekey.py b/scripts/extract_gateway3_homekey.py index 0058c3d..a90ecc5 100644 --- a/scripts/extract_gateway3_homekey.py +++ b/scripts/extract_gateway3_homekey.py @@ -55,12 +55,15 @@ 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 +82,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']}'")