hub-architecture #1

Merged
matthew merged 19 commits from hub-architecture into main 2026-04-17 14:04:28 +00:00
14 changed files with 921 additions and 276 deletions

View File

@@ -4,53 +4,194 @@
@license: Apache-2.0 license @license: Apache-2.0 license
""" """
import base64
from collections.abc import Callable
import aiohttp
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.exc import BleakError 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.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady 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 from .coordinator import PVCoordinator
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.COVER,
Platform.SENSOR,
Platform.BUTTON, 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: 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)) LOGGER.debug("Setup of %s", repr(entry))
if entry.unique_id is None: entry.runtime_data = {}
raise ConfigEntryError("Missing unique ID for device.")
ble_device: BLEDevice | None = async_ble_device_from_address( # Resolve shade friendly names from hub if available
hass=hass, address=entry.unique_id, connectable=True 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)
if not ble_device: # Forward platforms first so dispatched entities have their setup ready
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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_start())
# 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,
)
)
return True return True
@@ -58,20 +199,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntryType) -> boo
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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)) LOGGER.debug("Unloaded config entry: %s, ok? %s!", entry.unique_id, str(unload_ok))
return 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

View File

@@ -4,7 +4,7 @@ import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import time import time
from typing import Final from typing import Final, NamedTuple
from bleak import BleakClient from bleak import BleakClient
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
@@ -40,24 +40,88 @@ SHADE_TYPE: Final[dict[int, str]] = {
6: "Duette", 6: "Duette",
10: "Duette and Applause SkyLift", 10: "Duette and Applause SkyLift",
19: "Provenance Woven Wood", 19: "Provenance Woven Wood",
26: "Vertical",
31: "Vignette", 31: "Vignette",
32: "Vignette", 32: "Vignette",
42: "M25T Roller Blind", 42: "M25T Roller Blind",
49: "AC Roller", 49: "AC Roller",
52: "Banded Shades", 52: "Banded Shades",
53: "Sonnette", 53: "Sonnette",
57: "Carole Roman Shades",
84: "Vignette", 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", 8: "Duette, Top Down Bottom Up",
9: "Duette DuoLite, Top Down Bottom Up", 9: "Duette DuoLite, Top Down Bottom Up",
33: "Duette Architella, Top Down Bottom Up", 33: "Duette Architella, Top Down Bottom Up",
39: "Parkland",
47: "Pleated, Top Down Bottom Up", 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", 51: "Venetian, Tilt Anywhere",
54: "Vertical Slats, Left Stack",
55: "Vertical Slats, Right Stack",
56: "Vertical Slats, Split Stack",
62: "Venetian, Tilt Anywhere", 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 OPEN_POSITION: Final[int] = 100
CLOSED_POSITION: Final[int] = 0 CLOSED_POSITION: Final[int] = 0
@@ -97,18 +161,10 @@ class PowerViewBLE:
def __init__(self, ble_device: BLEDevice, home_key: bytes = b"") -> None: def __init__(self, ble_device: BLEDevice, home_key: bytes = b"") -> None:
"""Initialize device API via Bluetooth.""" """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.name: Final[str] = self._ble_device.name or "unknown"
self._seqcnt: int = 1 self._seqcnt: int = 1
self._client: BleakClient = BleakClient( self._client: BleakClient = BleakClient(self._ble_device)
self._ble_device,
disconnected_callback=self._on_disconnect,
services=[
UUID_COV_SERVICE,
UUID_DEV_SERVICE,
# self.UUID_BAT_SERVICE,
],
)
self._data_event = asyncio.Event() self._data_event = asyncio.Event()
self._data: bytes = b"" self._data: bytes = b""
self._info: PVDeviceInfo = PVDeviceInfo() self._info: PVDeviceInfo = PVDeviceInfo()
@@ -125,6 +181,10 @@ class PowerViewBLE:
await self._data_event.wait() await self._data_event.wait()
self._data_event.clear() 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 @property
def encrypted(self) -> bool: def encrypted(self) -> bool:
"""Return whether communication with this shade is encrypted.""" """Return whether communication with this shade is encrypted."""
@@ -134,6 +194,11 @@ class PowerViewBLE:
def encrypted(self, value: bool) -> None: def encrypted(self, value: bool) -> None:
self._is_encrypted = value 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 @property
def info(self) -> PVDeviceInfo: def info(self) -> PVDeviceInfo:
"""Return device information, e.g. SW version.""" """Return device information, e.g. SW version."""
@@ -182,27 +247,34 @@ class PowerViewBLE:
raise raise
@staticmethod @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.""" """Decode manufacturer data from BLE advertisement V2."""
if len(data) != 9: if len(data) != 9:
LOGGER.debug("not a V2 record!") LOGGER.debug("not a V2 record!")
return [] return {}
pos: Final[int] = int.from_bytes(data[3:5], byteorder="little") # 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) pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4)
return [ return {
(ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), ATTR_CURRENT_POSITION: pos / 10,
("position2", pos2 >> 2), "position2": pos2 >> 2,
("position3", int(data[6])), "position3": int(data[6]),
(ATTR_CURRENT_TILT_POSITION, int(data[7])), ATTR_CURRENT_TILT_POSITION: int(data[7]),
("home_id", int.from_bytes(data[0:2], byteorder="little")), "home_id": int.from_bytes(data[0:2], byteorder="little"),
("type_id", int(data[2])), "type_id": int(data[2]),
("is_opening", bool(pos & 0x3 == 0x2)), "is_opening": bool(flags == 0x2),
("is_closing", bool(pos & 0x3 == 0x1)), "is_closing": bool(flags == 0x1),
("battery_charging", bool(pos & 0x3 == 0x3)), # observed "battery_charging": bool(flags == 0x3), # observed
("battery_level", POWER_LEVELS[(data[8] >> 6)]), # cannot hit 4 "battery_level": POWER_LEVELS[(data[8] >> 6)], # cannot hit 4
("resetMode", bool(data[8] & 0x1)), "resetMode": bool(data[8] & 0x1),
("resetClock", bool(data[8] & 0x2)), "resetClock": bool(data[8] & 0x2),
] }
# position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity
async def set_position( async def set_position(
@@ -227,7 +299,7 @@ class PowerViewBLE:
await self._cmd( await self._cmd(
( (
ShadeCmd.SET_POSITION, 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(pos2, 2, byteorder="little")
+ int.to_bytes(pos3, 2, byteorder="little") + int.to_bytes(pos3, 2, byteorder="little")
+ int.to_bytes(tilt, 2, byteorder="little") + int.to_bytes(tilt, 2, byteorder="little")
@@ -236,20 +308,20 @@ class PowerViewBLE:
disconnect, disconnect,
) )
async def open(self) -> None: async def open(self, velocity: int = 0x0) -> None:
"""Fully open cover.""" """Fully open cover."""
LOGGER.debug("%s open", self.name) 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: async def stop(self) -> None:
"""Stop device movement.""" """Stop device movement."""
LOGGER.debug("%s stop", self.name) LOGGER.debug("%s stop", self.name)
await self._cmd((ShadeCmd.STOP, b"")) await self._cmd((ShadeCmd.STOP, b""))
async def close(self) -> None: async def close(self, velocity: int = 0x0) -> None:
"""Fully close cover.""" """Fully close cover."""
LOGGER.debug("%s close", self.name) 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 # uint8_t scene#, uint8_t unknown
# open: scene 2 # open: scene 2
@@ -355,6 +427,7 @@ class PowerViewBLE:
self._ble_device, self._ble_device,
self.name, self.name,
disconnected_callback=self._on_disconnect, disconnected_callback=self._on_disconnect,
ble_device_callback=lambda: self._ble_device,
services=[ services=[
UUID_COV_SERVICE, UUID_COV_SERVICE,
UUID_DEV_SERVICE, UUID_DEV_SERVICE,

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ConfigEntryType from . import ConfigEntryType, async_setup_shade_platform
from .const import DOMAIN from .const import DOMAIN
from .coordinator import PVCoordinator 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( async def async_setup_entry(
_hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntryType, config_entry: ConfigEntryType,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add sensors for passed config_entry in Home Assistant.""" """Add sensors for passed config_entry in Home Assistant."""
async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities)
coord: PVCoordinator = config_entry.runtime_data
for descr in BINARY_SENSOR_TYPES:
async_add_entities(
[PVBinarySensor(coord, descr, format_mac(config_entry.unique_id))]
)
class PVBinarySensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], BinarySensorEntity): # type: ignore[reportIncompatibleMethodOverride] class PVBinarySensor(
PassiveBluetoothCoordinatorEntity[PVCoordinator], BinarySensorEntity
): # type: ignore[reportIncompatibleMethodOverride]
"""The generic PV binary sensor implementation.""" """The generic PV binary sensor implementation."""
def __init__( def __init__(

View File

@@ -10,12 +10,12 @@ from homeassistant.components.button import (
ButtonEntity, ButtonEntity,
ButtonEntityDescription, ButtonEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ConfigEntryType, async_setup_shade_platform
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .coordinator import PVCoordinator 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( async def async_setup_entry(
_hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntryType,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the demo cover platform.""" """Set up the button platform."""
async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities)
coordinator: PVCoordinator = config_entry.runtime_data
for descr in BUTTONS_SHADE:
async_add_entities([PowerViewButton(coordinator, descr)])
class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEntity): # type: ignore[reportIncompatibleVariableOverride] class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEntity): # type: ignore[reportIncompatibleVariableOverride]
"""Representation of a powerview shade.""" """Representation of a powerview shade."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_device_class = ButtonDeviceClass.IDENTIFY
def __init__( def __init__(
self, self,
@@ -60,11 +65,6 @@ class PowerViewButton(PassiveBluetoothCoordinatorEntity[PVCoordinator], ButtonEn
) )
super().__init__(coordinator) 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: async def async_press(self) -> None:
"""Handle the button press.""" """Handle the button press."""
LOGGER.debug("identify cover") LOGGER.debug("identify cover")

View File

@@ -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 from typing import Any
import aiohttp
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlowResult 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 ( from homeassistant.helpers.selector import (
SelectOptionDict, SelectOptionDict,
SelectSelector, SelectSelector,
SelectSelectorConfig, SelectSelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
) )
from .api import UUID_COV_SERVICE as UUID from .const import CONF_HOME_KEY, CONF_HUB_URL, DOMAIN, LOGGER, MFCT_ID
from .const import 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("<BBBB", response_bytes[0:4])
if len(response_bytes) != 4 + length:
LOGGER.warning(
"Shade %s frame length mismatch (header=%d, actual=%d)",
ble_name,
4 + length,
len(response_bytes),
)
return None
if response_bytes[4] != 0:
LOGGER.warning(
"Shade %s returned error status %d", ble_name, response_bytes[4]
)
return None
key_data = response_bytes[5:]
if len(key_data) != 16:
LOGGER.warning(
"Shade %s returned key of wrong length (%d, expected 16)",
ble_name,
len(key_data),
)
return None
return key_data
async def _fetch_key_from_hub(
hass: HomeAssistant, hub_url: str
) -> 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("<BBBB", 251, 18, 1, 0)
last_error: Exception = ValueError("No shades responded")
for ble_name in ble_names:
try:
async with session.post(
f"{hub_url}/home/shades/exec?shades={ble_name}",
json={"hex": request_frame.hex()},
timeout=timeout,
) as resp:
resp.raise_for_status()
result = await resp.json(content_type=None)
except (TimeoutError, aiohttp.ClientError) as ex:
LOGGER.warning("Shade %s unreachable: %s", ble_name, ex)
last_error = ex
continue
key_data = _parse_key_response(ble_name, result)
if key_data is not None:
return key_data
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=[
SelectOptionDict(
value="hub",
label="Fetch automatically from PowerView hub",
),
SelectOptionDict(
value="manual",
label="Enter key manually (32 hex characters)",
),
SelectOptionDict(
value="skip",
label="Skip (no key — controls disabled for encrypted shades)",
),
]
)
),
vol.Optional(CONF_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 Hunter Douglas PowerView BLE."""
VERSION = 1 VERSION = 2
MINOR_VERSION = 0 MINOR_VERSION = 1
@dataclass
class DiscoveredDevice:
"""A discovered bluetooth device."""
name: str
discovery_info: BluetoothServiceInfoBleak
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self._home_key: str = ""
self._hub_url: str = ""
self._discovered_device: ConfigFlow.DiscoveredDevice | None = None def _create_entry(self) -> ConfigFlowResult:
self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} """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( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak self, discovery_info: BluetoothServiceInfoBleak
@@ -47,92 +255,59 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by Bluetooth discovery.""" """Handle a flow initialized by Bluetooth discovery."""
LOGGER.debug("Bluetooth device detected: %s", discovery_info) 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._abort_if_unique_id_configured()
self._discovered_device = ConfigFlow.DiscoveredDevice( # If a hub entry already exists (unique_id may differ), shades are
discovery_info.name, discovery_info # auto-discovered internally — nothing more for the user to do.
) for entry in self._async_current_entries():
self.context["title_placeholders"] = {"name": self._discovered_device.name} if entry.version >= 2:
return await self.async_step_bluetooth_confirm() return self.async_abort(reason="already_configured")
async def async_step_bluetooth_confirm( # No hub entry yet — redirect to user setup
self, user_input: dict[str, Any] | None = None return await self.async_step_user()
) -> 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},
)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the user step to pick discovered device.""" """Handle the user step — create a hub entry."""
LOGGER.debug("user step") LOGGER.debug("user step")
if user_input is not None: # Only one hub entry allowed (per key, but for simplicity one total)
address = user_input[CONF_ADDRESS] for entry in self._async_current_entries():
await self.async_set_unique_id(address, raise_on_progress=False) 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._abort_if_unique_id_configured()
self._discovered_device = self._discovered_devices[address] return self._create_entry()
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.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=_HOMEKEY_SCHEMA,
{ errors=errors,
vol.Required(CONF_ADDRESS): SelectSelector( description_placeholders={
SelectSelectorConfig(options=titles) "hub_url_example": "http://powerview-g3.local",
) },
}
),
) )

View File

@@ -8,10 +8,11 @@ LOGGER: Final = logging.getLogger(__package__)
MFCT_ID: Final[int] = 2073 MFCT_ID: Final[int] = 2073
TIMEOUT: Final[int] = 5 TIMEOUT: Final[int] = 5
# put the key here, needs to be 16 bytes long, e.g. CONF_HOME_KEY: Final[str] = "home_key"
# HOME_KEY: Final[bytes] = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" CONF_HUB_URL: Final[str] = "hub_url"
HOME_KEY: Final[bytes] = b""
# 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) # attributes (do not change)
ATTR_RSSI: Final[str] = "rssi" ATTR_RSSI: Final[str] = "rssi"

View File

@@ -12,27 +12,36 @@ from homeassistant.components.bluetooth.passive_update_coordinator import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from .api import SHADE_TYPE, PowerViewBLE from .api import SHADE_TYPE, PowerViewBLE, ShadeCapability, get_shade_capabilities
from .const import ATTR_RSSI, DOMAIN, HOME_KEY, LOGGER from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER
class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): 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._friendly_name = friendly_name or ble_device.name
self.api = PowerViewBLE(ble_device, HOME_KEY) home_key_hex: str = data.get(CONF_HOME_KEY, "")
home_key: bytes = (
bytes.fromhex(home_key_hex) if len(home_key_hex) == 32 else b""
)
self.api = PowerViewBLE(ble_device, home_key)
self.data: dict[str, int | float | bool] = {} self.data: dict[str, int | float | bool] = {}
self._manuf_dat = data.get("manufacturer_data") self._manuf_dat = data.get("manufacturer_data")
self.dev_details: dict[str, str] = {} self.dev_details: dict[str, str] = {}
self.velocity: int = 0
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__(
@@ -42,6 +51,19 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
bluetooth.BluetoothScanningMode.ACTIVE, 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: async def query_dev_info(self) -> None:
"""Receive detailed information from device.""" """Receive detailed information from device."""
LOGGER.debug("%s: querying device info", self.name) LOGGER.debug("%s: querying device info", self.name)
@@ -50,24 +72,23 @@ 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(self.type_id, "unknown"))
if self._manuf_dat if self.type_id is not None
else None else None
), ),
model_id=( 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"), serial_number=self.dev_details.get("serial_nr"),
sw_version=self.dev_details.get("sw_rev"), sw_version=self.dev_details.get("sw_rev"),
@@ -77,7 +98,7 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
@property @property
def device_present(self) -> bool: def device_present(self) -> bool:
"""Check if a device is present.""" """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: def _async_stop(self) -> None:
"""Shutdown coordinator and any connection.""" """Shutdown coordinator and any connection."""
@@ -93,18 +114,19 @@ 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.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: if change == bluetooth.BluetoothChange.ADVERTISEMENT:
self.data.update( new_data.update(
self.api.dec_manufacturer_data( self.api.dec_manufacturer_data(
bytearray(service_info.manufacturer_data.get(2073, b"")) 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) LOGGER.debug("data sample %s", self.data)
super()._async_handle_bluetooth_event(service_info, change) super()._async_handle_bluetooth_event(service_info, change)

View File

@@ -16,34 +16,45 @@ from homeassistant.components.cover import (
CoverEntity, CoverEntity,
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ConfigEntryType, async_setup_shade_platform
from .api import CLOSED_POSITION, OPEN_POSITION from .api import CLOSED_POSITION, OPEN_POSITION
from .const import DOMAIN, HOME_KEY, LOGGER from .const import DOMAIN, LOGGER
from .coordinator import PVCoordinator from .coordinator import PVCoordinator
async def async_setup_entry( def _add_entities(
_hass: HomeAssistant, coordinator: PVCoordinator, async_add_entities: AddEntitiesCallback
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> 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 if caps.tilt_only:
model: Final[str|None] = coordinator.dev_details.get("model") entities: list[PowerViewCover] = [PowerViewCoverTiltOnly(coordinator)]
entities: list[PowerViewCover] = [] elif caps.is_tilt_on_closed:
if model in ["39"]: entities = [PowerViewCoverTiltOnClosed(coordinator)]
entities.append(PowerViewCoverTiltOnly(coordinator)) elif caps.has_tilt:
entities = [PowerViewCoverTilt(coordinator)]
elif caps.is_top_down:
entities = [PowerViewCoverTopDown(coordinator)]
else: else:
entities.append(PowerViewCover(coordinator)) entities = [PowerViewCover(coordinator)]
async_add_entities(entities) 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] class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride]
"""Representation of a PowerView shade with Up/Down functionality only.""" """Representation of a PowerView shade with Up/Down functionality only."""
@@ -62,7 +73,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(
@@ -73,11 +84,6 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
) )
super().__init__(coordinator) 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 @property
def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride] def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
"""Return if the cover is opening or not.""" """Return if the cover is opening or not."""
@@ -106,9 +112,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
@property @property
def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride] def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride]
"""Flag supported features, disable control if encryption is needed.""" """Flag supported features, disable control if encryption is needed."""
if ( if self._coord.data.get("home_id") and not self._coord.api.has_key:
self._coord.data.get("home_id") and len(HOME_KEY) != 16
) or self._coord.data.get("battery_charging"):
return CoverEntityFeature(0) return CoverEntityFeature(0)
return super().supported_features return super().supported_features
@@ -133,7 +137,10 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
return return
self._target_position = round(target_position) self._target_position = round(target_position)
try: 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() self.async_write_ha_state()
except BleakError as err: except BleakError as err:
LOGGER.error( LOGGER.error(
@@ -153,7 +160,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
return return
try: try:
self._target_position = OPEN_POSITION self._target_position = OPEN_POSITION
await self._coord.api.open() await self._coord.api.open(velocity=self._coord.velocity)
self.async_write_ha_state() self.async_write_ha_state()
except BleakError as err: except BleakError as err:
LOGGER.error("Failed to open cover '%s': %s", self.name, err) LOGGER.error("Failed to open cover '%s': %s", self.name, err)
@@ -166,7 +173,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti
return return
try: try:
self._target_position = CLOSED_POSITION self._target_position = CLOSED_POSITION
await self._coord.api.close() await self._coord.api.close(velocity=self._coord.velocity)
self.async_write_ha_state() self.async_write_ha_state()
except BleakError as err: except BleakError as err:
LOGGER.error("Failed to close cover '%s': %s", self.name, err) LOGGER.error("Failed to close cover '%s': %s", self.name, err)
@@ -227,7 +234,9 @@ class PowerViewCoverTilt(PowerViewCover):
try: try:
await self._coord.api.set_position( 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() self.async_write_ha_state()
except BleakError as err: except BleakError as err:
@@ -240,7 +249,7 @@ class PowerViewCoverTilt(PowerViewCover):
async def async_stop_cover_tilt(self, **kwargs: Any) -> None: async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover.""" """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: async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt.""" """Open the cover tilt."""
@@ -255,6 +264,101 @@ class PowerViewCoverTilt(PowerViewCover):
await self.async_set_cover_tilt_position(**_kwargs) 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): class PowerViewCoverTiltOnly(PowerViewCoverTilt):
"""Representation of a PowerView shade with additional tilt functionality.""" """Representation of a PowerView shade with additional tilt functionality."""

View File

@@ -11,10 +11,10 @@
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://github.com/patman15/hdpv_ble", "documentation": "https://github.com/patman15/hdpv_ble",
"integration_type": "device", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/patman15/hdpv_ble/issues", "issue_tracker": "https://github.com/patman15/hdpv_ble/issues",
"loggers": ["hunterdouglas_powerview_ble"], "loggers": ["hunterdouglas_powerview_ble"],
"requirements": ["cryptography>=43.0.0"], "requirements": ["cryptography>=43.0.0"],
"version": "0.23" "version": "0.24"
} }

View File

@@ -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()

View File

@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ConfigEntryType from . import ConfigEntryType, async_setup_shade_platform
from .const import ATTR_RSSI, DOMAIN from .const import ATTR_RSSI, DOMAIN
from .coordinator import PVCoordinator 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( async def async_setup_entry(
_hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntryType, config_entry: ConfigEntryType,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add sensors for passed config_entry in Home Assistant.""" """Add sensors for passed config_entry in Home Assistant."""
async_setup_shade_platform(hass, config_entry, async_add_entities, _add_entities)
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))]
)
class PVSensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], SensorEntity): # type: ignore[reportIncompatibleMethodOverride] class PVSensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], SensorEntity): # type: ignore[reportIncompatibleMethodOverride]

View File

@@ -1,15 +1,32 @@
{ {
"config": { "config": {
"flow_title": "Setup {name}", "flow_title": "PowerView Home Setup",
"step": { "step": {
"bluetooth_confirm": { "user": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" "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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
"not_supported": "Device not supported"
} }
} }
} }

View File

@@ -1,15 +1,32 @@
{ {
"config": { "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": { "abort": {
"already_configured": "Device is already configured", "already_configured": "Device is already configured",
"no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible."
"not_supported": "Device not supported"
},
"flow_title": "Setup {name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to set up {name}?"
}
} }
} }
} }

View File

@@ -55,12 +55,15 @@ def get_shade_key(hub: str, ble_name) -> bytes:
raise raise
result: dict = json.loads(shades_exec_resp.content) result: dict = json.loads(shades_exec_resp.content)
if result.get("err") != 0 or len(result.get("responses", [])) != 1: responses = result.get("responses", [])
raise OSError("Error when attempting GetShadeKey") if len(responses) != 1 or "hex" not in responses[0]:
response: Final[bytes] = bytes.fromhex(result["responses"][0]["hex"]) 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) dec_resp: Final[dict[str, Any]] = decode_response(response)
if dec_resp["errorCode"] != 0: 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: if len(dec_resp["data"]) != 16:
raise ValueError("Expected 16 byte homekey") raise ValueError("Expected 16 byte homekey")
return dec_resp["data"] return dec_resp["data"]
@@ -79,9 +82,23 @@ def main(hub: str) -> int:
shades = json.loads(shades_resp.content) shades = json.loads(shades_resp.content)
print(f"Found {len(shades)} shades, interrogating") print(f"Found {len(shades)} shades, interrogating")
network_key: bytes | None = None
for shade in shades: for shade in shades:
name: str = base64.b64decode(shade["name"]).decode("utf-8") name: str = base64.b64decode(shade["name"]).decode("utf-8")
try:
key: bytes = get_shade_key(hub, shade["bleName"]) 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"Shade '{name}':")
print(f"\tBLE name: '{shade['bleName']}'") print(f"\tBLE name: '{shade['bleName']}'")