hub-architecture #1
@@ -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)
|
||||||
|
|
||||||
|
# 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
|
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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
},
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
74
custom_components/hunterdouglas_powerview_ble/number.py
Normal file
74
custom_components/hunterdouglas_powerview_ble/number.py
Normal 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()
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}?"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
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"Shade '{name}':")
|
||||||
print(f"\tBLE name: '{shade['bleName']}'")
|
print(f"\tBLE name: '{shade['bleName']}'")
|
||||||
|
|||||||
Reference in New Issue
Block a user