Compare commits
12 Commits
improve-co
...
hub-archit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deee1baad5 | ||
|
|
354d06b468 | ||
|
|
7936a4fb24 | ||
|
|
1b9aed4f89 | ||
|
|
8a6a17b767 | ||
|
|
0efca4ff52 | ||
|
|
789f716707 | ||
|
|
ce04907d58 | ||
|
|
87bac49529 | ||
|
|
e2bb3b5592 | ||
|
|
acb2c5ff52 | ||
|
|
7a2bf2193a |
@@ -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(), entry.title)
|
|
||||||
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
|
||||||
|
|
||||||
@@ -183,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(
|
||||||
@@ -237,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
|
||||||
|
|||||||
@@ -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,18 +26,25 @@ 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(
|
class PVBinarySensor(
|
||||||
|
|||||||
@@ -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,9 +1,6 @@
|
|||||||
"""Config flow for BLE Battery Management System integration."""
|
"""Config flow for Hunter Douglas PowerView BLE integration."""
|
||||||
|
|
||||||
import asyncio
|
import hashlib
|
||||||
import base64
|
|
||||||
import contextlib
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import struct
|
import struct
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -11,12 +8,8 @@ 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.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
@@ -28,32 +21,83 @@ from homeassistant.helpers.selector import (
|
|||||||
TextSelectorType,
|
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 CONF_HOME_KEY, DOMAIN, LOGGER, MFCT_ID
|
|
||||||
|
|
||||||
|
|
||||||
def _needs_encryption(manufacturer_data_hex: str) -> bool:
|
def _hub_unique_id(home_key: str) -> str:
|
||||||
"""Return True if the BLE advertisement indicates encryption (home_id != 0)."""
|
"""Derive a stable unique ID for a hub entry from the home key."""
|
||||||
data = bytearray.fromhex(manufacturer_data_hex)
|
if home_key:
|
||||||
if len(data) < 2:
|
digest = hashlib.sha256(home_key.encode()).hexdigest()[:16]
|
||||||
return False
|
return f"pvhome_{digest}"
|
||||||
home_id = int.from_bytes(data[0:2], byteorder="little")
|
return "pvhome_unencrypted"
|
||||||
return home_id != 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
def _parse_key_response(ble_name: str, result: dict) -> bytes | None: # noqa: PLR0911
|
||||||
class HubShadeInfo:
|
"""Parse a shade exec response and return the 16-byte key, or None."""
|
||||||
"""Shade metadata from the PowerView hub."""
|
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
|
||||||
|
|
||||||
name: str # Human-readable name (decoded from base64)
|
responses = result.get("responses", [])
|
||||||
ble_name: str # BLE advertisement name, e.g. "DUE:94ED"
|
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_shades_from_hub(
|
async def _fetch_key_from_hub(
|
||||||
hass: HomeAssistant, hub_url: str
|
hass: HomeAssistant, hub_url: str
|
||||||
) -> list[HubShadeInfo]:
|
) -> bytes:
|
||||||
"""Fetch shade list with human-readable names from a PowerView G3 hub.
|
"""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 aiohttp.ClientError on network errors.
|
||||||
Raises asyncio.TimeoutError on timeout.
|
Raises asyncio.TimeoutError on timeout.
|
||||||
"""
|
"""
|
||||||
@@ -65,76 +109,36 @@ async def _fetch_shades_from_hub(
|
|||||||
shades = await resp.json(content_type=None)
|
shades = await resp.json(content_type=None)
|
||||||
|
|
||||||
if not shades:
|
if not shades:
|
||||||
return []
|
|
||||||
|
|
||||||
hub_shades: list[HubShadeInfo] = []
|
|
||||||
for shade in shades:
|
|
||||||
ble_name = shade.get("bleName", "")
|
|
||||||
if not ble_name:
|
|
||||||
continue
|
|
||||||
name_b64 = shade.get("name", "")
|
|
||||||
try:
|
|
||||||
name = base64.b64decode(name_b64).decode("utf-8") if name_b64 else ble_name
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
name = ble_name
|
|
||||||
hub_shades.append(HubShadeInfo(name=name, ble_name=ble_name))
|
|
||||||
return hub_shades
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_key_and_shades_from_hub(
|
|
||||||
hass: HomeAssistant, hub_url: str
|
|
||||||
) -> tuple[bytes, list[HubShadeInfo]]:
|
|
||||||
"""Fetch 16-byte homekey and shade list from a PowerView G3 hub.
|
|
||||||
|
|
||||||
Returns (key, shade_list). The key is network-wide so any reachable shade
|
|
||||||
returns the same value. The shade list contains human-readable names that
|
|
||||||
can be used to label BLE-discovered devices.
|
|
||||||
|
|
||||||
Raises ValueError on protocol/key errors.
|
|
||||||
Raises aiohttp.ClientError on network errors.
|
|
||||||
Raises asyncio.TimeoutError on timeout.
|
|
||||||
"""
|
|
||||||
hub_shades = await _fetch_shades_from_hub(hass, hub_url)
|
|
||||||
if not hub_shades:
|
|
||||||
raise ValueError("No shades found on the hub")
|
raise ValueError("No shades found on the hub")
|
||||||
|
|
||||||
session = async_get_clientsession(hass)
|
# Sort by signal strength (strongest first) — a stronger signal means the
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
# 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
|
# GetShadeKey BLE request: sid=251, cid=18, seqId=1, data_len=0
|
||||||
request_frame = struct.pack("<BBBB", 251, 18, 1, 0)
|
request_frame = struct.pack("<BBBB", 251, 18, 1, 0)
|
||||||
|
|
||||||
# Try each shade until one returns a valid key (some may be out of range)
|
|
||||||
last_error: Exception = ValueError("No shades responded")
|
last_error: Exception = ValueError("No shades responded")
|
||||||
for hs in hub_shades:
|
for ble_name in ble_names:
|
||||||
try:
|
try:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f"{hub_url}/home/shades/exec?shades={hs.ble_name}",
|
f"{hub_url}/home/shades/exec?shades={ble_name}",
|
||||||
json={"hex": request_frame.hex()},
|
json={"hex": request_frame.hex()},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
result = await resp.json(content_type=None)
|
result = await resp.json(content_type=None)
|
||||||
except (TimeoutError, aiohttp.ClientError) as ex:
|
except (TimeoutError, aiohttp.ClientError) as ex:
|
||||||
|
LOGGER.warning("Shade %s unreachable: %s", ble_name, ex)
|
||||||
last_error = ex
|
last_error = ex
|
||||||
continue
|
continue
|
||||||
|
|
||||||
responses = result.get("responses", [])
|
key_data = _parse_key_response(ble_name, result)
|
||||||
if len(responses) != 1 or "hex" not in responses[0]:
|
if key_data is not None:
|
||||||
continue
|
return key_data
|
||||||
|
|
||||||
response_bytes = bytes.fromhex(responses[0]["hex"])
|
|
||||||
if len(response_bytes) < 5:
|
|
||||||
continue
|
|
||||||
_s, _c, _q, length = struct.unpack("<BBBB", response_bytes[0:4])
|
|
||||||
if len(response_bytes) != 4 + length:
|
|
||||||
continue
|
|
||||||
if response_bytes[4] != 0:
|
|
||||||
continue
|
|
||||||
key_data = response_bytes[5:]
|
|
||||||
if len(key_data) != 16:
|
|
||||||
continue
|
|
||||||
return key_data, hub_shades
|
|
||||||
|
|
||||||
raise ValueError(f"No reachable shade returned a valid key: {last_error}")
|
raise ValueError(f"No reachable shade returned a valid key: {last_error}")
|
||||||
|
|
||||||
@@ -159,7 +163,7 @@ _HOMEKEY_SCHEMA = vol.Schema(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Optional("hub_url", default="http://powerview-g3.local"): TextSelector(
|
vol.Optional(CONF_HUB_URL, default="http://powerview-g3.local"): TextSelector(
|
||||||
TextSelectorConfig(type=TextSelectorType.URL)
|
TextSelectorConfig(type=TextSelectorType.URL)
|
||||||
),
|
),
|
||||||
vol.Optional("home_key", default=""): TextSelector(
|
vol.Optional("home_key", default=""): TextSelector(
|
||||||
@@ -170,46 +174,27 @@ _HOMEKEY_SCHEMA = vol.Schema(
|
|||||||
|
|
||||||
|
|
||||||
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 = 1
|
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._discovered_device: ConfigFlow.DiscoveredDevice | None = None
|
|
||||||
self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {}
|
|
||||||
self._manufacturer_data_hex: str = ""
|
|
||||||
self._device_name: str = ""
|
|
||||||
self._home_key: str = ""
|
self._home_key: str = ""
|
||||||
self._hub_url: str = ""
|
self._hub_url: str = ""
|
||||||
self._hub_shades: list[HubShadeInfo] = []
|
|
||||||
|
|
||||||
def _create_entry(self) -> ConfigFlowResult:
|
def _create_entry(self) -> ConfigFlowResult:
|
||||||
"""Create the config entry with collected data."""
|
"""Create the hub config entry."""
|
||||||
data: dict[str, str] = {
|
data: dict[str, str] = {CONF_HOME_KEY: self._home_key}
|
||||||
"manufacturer_data": self._manufacturer_data_hex,
|
|
||||||
CONF_HOME_KEY: self._home_key,
|
|
||||||
}
|
|
||||||
if self._hub_url:
|
if self._hub_url:
|
||||||
data["hub_url"] = self._hub_url
|
data[CONF_HUB_URL] = self._hub_url
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(title="PowerView Home", data=data)
|
||||||
title=self._device_name,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_manual_key(
|
def _validate_manual_key(
|
||||||
self, user_input: dict[str, Any], errors: dict[str, str]
|
self, user_input: dict[str, Any], errors: dict[str, str]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Validate a manually entered hex key and store it.
|
"""Validate a manually entered hex key.
|
||||||
|
|
||||||
Returns True on success, False on validation error.
|
Returns True on success, False on validation error.
|
||||||
"""
|
"""
|
||||||
@@ -233,7 +218,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Parse and validate homekey user_input, populating self state.
|
"""Parse and validate homekey user_input, populating self state.
|
||||||
|
|
||||||
Returns True on success, False on validation error (errors dict is populated).
|
Returns True on success, False on validation error (errors dict is populated).
|
||||||
On skip, self._home_key is set to "".
|
|
||||||
"""
|
"""
|
||||||
method = user_input.get("key_method", "skip")
|
method = user_input.get("key_method", "skip")
|
||||||
|
|
||||||
@@ -247,7 +231,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if method != "hub":
|
if method != "hub":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
hub_url = user_input.get("hub_url", "").rstrip("/")
|
hub_url = user_input.get(CONF_HUB_URL, "").rstrip("/")
|
||||||
_HUB_ERROR_MAP: dict[type[Exception], str] = {
|
_HUB_ERROR_MAP: dict[type[Exception], str] = {
|
||||||
aiohttp.ClientResponseError: "hub_http_error",
|
aiohttp.ClientResponseError: "hub_http_error",
|
||||||
aiohttp.ClientConnectionError: "hub_connection_error",
|
aiohttp.ClientConnectionError: "hub_connection_error",
|
||||||
@@ -255,14 +239,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
ValueError: "hub_protocol_error",
|
ValueError: "hub_protocol_error",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
key, hub_shades = await _fetch_key_and_shades_from_hub(self.hass, hub_url)
|
key = await _fetch_key_from_hub(self.hass, hub_url)
|
||||||
except tuple(_HUB_ERROR_MAP) as ex:
|
except tuple(_HUB_ERROR_MAP) as ex:
|
||||||
errors["hub_url"] = _HUB_ERROR_MAP[type(ex)]
|
LOGGER.warning("Hub key fetch failed: %s", ex)
|
||||||
|
errors[CONF_HUB_URL] = _HUB_ERROR_MAP[type(ex)]
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._home_key = key.hex()
|
self._home_key = key.hex()
|
||||||
self._hub_url = hub_url
|
self._hub_url = hub_url
|
||||||
self._hub_shades = hub_shades
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_step_bluetooth(
|
async def async_step_bluetooth(
|
||||||
@@ -271,296 +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.
|
||||||
)
|
|
||||||
self.context["title_placeholders"] = {"name": self._discovered_device.name}
|
|
||||||
return await self.async_step_bluetooth_confirm()
|
|
||||||
|
|
||||||
async def async_step_bluetooth_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Confirm bluetooth device discovery."""
|
|
||||||
assert self._discovered_device is not None
|
|
||||||
LOGGER.debug("confirm step for %s", self._discovered_device.name)
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
self._manufacturer_data_hex = (
|
|
||||||
self._discovered_device.discovery_info.manufacturer_data[MFCT_ID].hex()
|
|
||||||
)
|
|
||||||
self._device_name = self._discovered_device.name
|
|
||||||
|
|
||||||
# Unencrypted shades can skip the homekey step entirely
|
|
||||||
if not _needs_encryption(self._manufacturer_data_hex):
|
|
||||||
await self._resolve_friendly_name()
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
return await self.async_step_homekey_bluetooth()
|
|
||||||
|
|
||||||
self._set_confirm_only()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="bluetooth_confirm",
|
|
||||||
description_placeholders={"name": self._discovered_device.name},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_homekey_bluetooth(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Configure homekey for a shade discovered via Bluetooth."""
|
|
||||||
# Reuse an existing key if another shade was already configured
|
|
||||||
existing = self._existing_home_key()
|
|
||||||
if existing and user_input is None:
|
|
||||||
self._home_key = existing
|
|
||||||
await self._resolve_friendly_name()
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
if user_input is not None and await self._validate_homekey_input(
|
|
||||||
user_input, errors
|
|
||||||
):
|
|
||||||
# Use hub name for the entry title if available
|
|
||||||
friendly = self._hub_name_for(self._device_name)
|
|
||||||
if friendly:
|
|
||||||
self._device_name = friendly
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="homekey_bluetooth",
|
|
||||||
data_schema=_HOMEKEY_SCHEMA,
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders={
|
|
||||||
"name": self._device_name,
|
|
||||||
"hub_url_example": "http://powerview-g3.local",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _existing_entry_value(self, key: str) -> str:
|
|
||||||
"""Return the first non-empty value for *key* across configured entries."""
|
|
||||||
for entry in self._async_current_entries():
|
for entry in self._async_current_entries():
|
||||||
if value := entry.data.get(key, ""):
|
if entry.version >= 2:
|
||||||
return value
|
return self.async_abort(reason="already_configured")
|
||||||
return ""
|
|
||||||
|
|
||||||
def _existing_home_key(self) -> str:
|
# No hub entry yet — redirect to user setup
|
||||||
"""Return the home_key from any already-configured entry, or ''."""
|
return await self.async_step_user()
|
||||||
return self._existing_entry_value(CONF_HOME_KEY)
|
|
||||||
|
|
||||||
async def _resolve_friendly_name(self) -> None:
|
|
||||||
"""Try to resolve BLE device name to hub friendly name."""
|
|
||||||
hub_url = self._hub_url or self._existing_entry_value("hub_url")
|
|
||||||
if not hub_url:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
shades = await _fetch_shades_from_hub(self.hass, hub_url)
|
|
||||||
for hs in shades:
|
|
||||||
if hs.ble_name == self._device_name:
|
|
||||||
self._device_name = hs.name
|
|
||||||
break
|
|
||||||
if not self._hub_url:
|
|
||||||
self._hub_url = hub_url
|
|
||||||
except (TimeoutError, aiohttp.ClientError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _hub_name_for(self, ble_name: str) -> str | None:
|
|
||||||
"""Return the human-readable hub name for a BLE name, or None."""
|
|
||||||
for hs in self._hub_shades:
|
|
||||||
if hs.ble_name == ble_name:
|
|
||||||
return hs.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
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 — reuse existing key or offer a menu."""
|
"""Handle the user step — create a hub entry."""
|
||||||
LOGGER.debug("user step")
|
LOGGER.debug("user step")
|
||||||
existing = self._existing_home_key()
|
|
||||||
if existing:
|
|
||||||
self._home_key = existing
|
|
||||||
self._hub_url = self._hub_url or self._existing_entry_value("hub_url")
|
|
||||||
if self._hub_url and not self._hub_shades:
|
|
||||||
with contextlib.suppress(
|
|
||||||
TimeoutError, aiohttp.ClientError, ValueError
|
|
||||||
):
|
|
||||||
self._hub_shades = await _fetch_shades_from_hub(
|
|
||||||
self.hass, self._hub_url
|
|
||||||
)
|
|
||||||
return self.async_show_menu(
|
|
||||||
step_id="user",
|
|
||||||
menu_options=["select_device", "manual"],
|
|
||||||
)
|
|
||||||
return await self.async_step_homekey()
|
|
||||||
|
|
||||||
def _build_selected_entries(
|
# Only one hub entry allowed (per key, but for simplicity one total)
|
||||||
self, user_input: dict[str, Any]
|
for entry in self._async_current_entries():
|
||||||
) -> list[dict[str, Any]]:
|
if entry.version >= 2:
|
||||||
"""Build config entry data for each selected shade address."""
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
addresses: list[str] = user_input[CONF_ADDRESS]
|
|
||||||
if isinstance(addresses, str):
|
|
||||||
addresses = [addresses]
|
|
||||||
|
|
||||||
entries: list[dict[str, Any]] = []
|
|
||||||
for address in addresses:
|
|
||||||
device = self._discovered_devices[address]
|
|
||||||
ble_name = device.name
|
|
||||||
name = self._hub_name_for(ble_name) or ble_name
|
|
||||||
mfct_hex = device.discovery_info.manufacturer_data[MFCT_ID].hex()
|
|
||||||
entry_data: dict[str, str] = {
|
|
||||||
"manufacturer_data": mfct_hex,
|
|
||||||
CONF_HOME_KEY: self._home_key,
|
|
||||||
}
|
|
||||||
if self._hub_url:
|
|
||||||
entry_data["hub_url"] = self._hub_url
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"address": address,
|
|
||||||
"name": name,
|
|
||||||
"data": entry_data,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
async def async_step_select_device(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Select one or more BLE-discovered shades, or fall through to manual."""
|
|
||||||
LOGGER.debug("select_device step")
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
entries = self._build_selected_entries(user_input)
|
|
||||||
|
|
||||||
# Kick off auto-add flows for all but the last shade
|
|
||||||
await asyncio.gather(
|
|
||||||
*(
|
|
||||||
self.hass.config_entries.flow.async_init(
|
|
||||||
DOMAIN,
|
|
||||||
context={"source": "auto_add"},
|
|
||||||
data=info,
|
|
||||||
)
|
|
||||||
for info in entries[:-1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the final entry normally (ends this flow)
|
|
||||||
last = entries[-1]
|
|
||||||
await self.async_set_unique_id(last["address"], raise_on_progress=False)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
self._device_name = last["name"]
|
|
||||||
self._manufacturer_data_hex = last["data"]["manufacturer_data"]
|
|
||||||
self.context["title_placeholders"] = {"name": self._device_name}
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
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 await self.async_step_manual()
|
|
||||||
|
|
||||||
titles: list[SelectOptionDict] = []
|
|
||||||
for address, discovery in self._discovered_devices.items():
|
|
||||||
hub_name = self._hub_name_for(discovery.name)
|
|
||||||
label = f"{hub_name} ({discovery.name})" if hub_name else discovery.name
|
|
||||||
titles.append({"value": address, "label": label})
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="select_device",
|
|
||||||
data_schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_ADDRESS): SelectSelector(
|
|
||||||
SelectSelectorConfig(options=titles, multiple=True)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_auto_add(
|
|
||||||
self, discovery_info: dict[str, Any]
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle a shade queued from multi-select for individual setup."""
|
|
||||||
await self.async_set_unique_id(discovery_info["address"])
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
self._device_name = discovery_info["name"]
|
|
||||||
self._manufacturer_data_hex = discovery_info["data"]["manufacturer_data"]
|
|
||||||
self._home_key = discovery_info["data"].get(CONF_HOME_KEY, "")
|
|
||||||
self._hub_url = discovery_info["data"].get("hub_url", "")
|
|
||||||
|
|
||||||
self.context["title_placeholders"] = {"name": self._device_name}
|
|
||||||
return await self.async_step_auto_add_confirm()
|
|
||||||
|
|
||||||
async def async_step_auto_add_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Confirm adding a shade discovered via multi-select."""
|
|
||||||
if user_input is not None:
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
self._set_confirm_only()
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="auto_add_confirm",
|
|
||||||
description_placeholders={"name": self._device_name},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_manual(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle manual entry of a BLE device address and name."""
|
|
||||||
if user_input is not None:
|
|
||||||
address = user_input[CONF_ADDRESS].upper().strip()
|
|
||||||
self._device_name = user_input["ble_name"].strip()
|
|
||||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
self.context["title_placeholders"] = {"name": self._device_name}
|
|
||||||
self._manufacturer_data_hex = ""
|
|
||||||
return self._create_entry()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="manual",
|
|
||||||
data_schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_ADDRESS): TextSelector(
|
|
||||||
TextSelectorConfig(type=TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
vol.Required("ble_name"): TextSelector(
|
|
||||||
TextSelectorConfig(type=TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_homekey(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Configure homekey — collected before device selection."""
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None and await self._validate_homekey_input(
|
if user_input is not None and await self._validate_homekey_input(
|
||||||
user_input, errors
|
user_input, errors
|
||||||
):
|
):
|
||||||
return await self.async_step_select_device()
|
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()
|
||||||
|
return self._create_entry()
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="homekey",
|
step_id="user",
|
||||||
data_schema=_HOMEKEY_SCHEMA,
|
data_schema=_HOMEKEY_SCHEMA,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
description_placeholders={
|
description_placeholders={
|
||||||
"hub_url_example": "http://powerview-g3.local",
|
"hub_url_example": "http://powerview-g3.local",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ MFCT_ID: Final[int] = 2073
|
|||||||
TIMEOUT: Final[int] = 5
|
TIMEOUT: Final[int] = 5
|
||||||
|
|
||||||
CONF_HOME_KEY: Final[str] = "home_key"
|
CONF_HOME_KEY: Final[str] = "home_key"
|
||||||
|
CONF_HUB_URL: Final[str] = "hub_url"
|
||||||
|
|
||||||
|
# dispatcher signal for newly discovered shades (format with entry_id)
|
||||||
|
SIGNAL_NEW_SHADE: Final[str] = f"{DOMAIN}_new_shade_{{entry_id}}"
|
||||||
|
|
||||||
# attributes (do not change)
|
# attributes (do not change)
|
||||||
ATTR_RSSI: Final[str] = "rssi"
|
ATTR_RSSI: Final[str] = "rssi"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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, CONF_HOME_KEY, DOMAIN, LOGGER
|
from .const import ATTR_RSSI, CONF_HOME_KEY, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
|||||||
) -> 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._friendly_name = friendly_name or ble_device.name
|
||||||
home_key_hex: str = data.get(CONF_HOME_KEY, "")
|
home_key_hex: str = data.get(CONF_HOME_KEY, "")
|
||||||
home_key: bytes = (
|
home_key: bytes = (
|
||||||
@@ -38,6 +37,7 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
|||||||
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)",
|
||||||
@@ -51,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)
|
||||||
@@ -70,12 +83,12 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
|||||||
configuration_url=None,
|
configuration_url=None,
|
||||||
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"),
|
||||||
@@ -85,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."""
|
||||||
@@ -103,14 +116,17 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
|||||||
|
|
||||||
LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data)
|
LOGGER.debug("BLE event %s: %s", change, service_info.manufacturer_data)
|
||||||
self.api.set_ble_device(service_info.device)
|
self.api.set_ble_device(service_info.device)
|
||||||
self.data = {ATTR_RSSI: service_info.rssi}
|
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, 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 == "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."""
|
||||||
|
|
||||||
@@ -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 not self._coord.api.has_key
|
|
||||||
) 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,7 +11,7 @@
|
|||||||
"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"],
|
||||||
|
|||||||
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,24 +1,10 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"flow_title": "Setup {name}",
|
"flow_title": "PowerView Home Setup",
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Add PowerView Shade",
|
"title": "Set up PowerView Home",
|
||||||
"menu_options": {
|
"description": "Configure your PowerView encryption key. All shades on your network will be discovered automatically via Bluetooth.",
|
||||||
"select_device": "Select from discovered shades",
|
|
||||||
"manual": "Enter device details manually"
|
|
||||||
},
|
|
||||||
"menu_option_descriptions": {
|
|
||||||
"select_device": "Choose from shades detected via Bluetooth nearby.",
|
|
||||||
"manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bluetooth_confirm": {
|
|
||||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
|
||||||
},
|
|
||||||
"homekey": {
|
|
||||||
"title": "Configure HomeKey",
|
|
||||||
"description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.",
|
|
||||||
"data": {
|
"data": {
|
||||||
"key_method": "Key source",
|
"key_method": "Key source",
|
||||||
"hub_url": "PowerView hub URL",
|
"hub_url": "PowerView hub URL",
|
||||||
@@ -28,37 +14,6 @@
|
|||||||
"hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}",
|
"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...)"
|
"home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"homekey_bluetooth": {
|
|
||||||
"title": "Configure HomeKey for {name}",
|
|
||||||
"description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).",
|
|
||||||
"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...)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auto_add_confirm": {
|
|
||||||
"description": "Do you want to set up {name}?"
|
|
||||||
},
|
|
||||||
"select_device": {
|
|
||||||
"title": "Select Shades",
|
|
||||||
"description": "Select the PowerView shades to add via Bluetooth.",
|
|
||||||
"data": {
|
|
||||||
"address": "Shades"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"manual": {
|
|
||||||
"title": "Enter Device Details",
|
|
||||||
"description": "Enter the device details manually.",
|
|
||||||
"data": {
|
|
||||||
"address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)",
|
|
||||||
"ble_name": "BLE device name (e.g. DUE:94ED)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -71,8 +26,7 @@
|
|||||||
},
|
},
|
||||||
"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,24 +1,10 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"flow_title": "Setup {name}",
|
"flow_title": "PowerView Home Setup",
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Add PowerView Shade",
|
"title": "Set up PowerView Home",
|
||||||
"menu_options": {
|
"description": "Configure your PowerView encryption key. All shades on your network will be discovered automatically via Bluetooth.",
|
||||||
"select_device": "Select from discovered shades",
|
|
||||||
"manual": "Enter device details manually"
|
|
||||||
},
|
|
||||||
"menu_option_descriptions": {
|
|
||||||
"select_device": "Choose from shades detected via Bluetooth nearby.",
|
|
||||||
"manual": "Enter a Bluetooth MAC address and device name directly, for example if a shade is out of range of discovery."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bluetooth_confirm": {
|
|
||||||
"description": "Do you want to set up {name}?"
|
|
||||||
},
|
|
||||||
"homekey": {
|
|
||||||
"title": "Configure HomeKey",
|
|
||||||
"description": "All shades on a PowerView network share the same encryption key. The recommended method is to fetch it from your G3 hub. You can also enter the key manually, or skip if your shades are unencrypted.",
|
|
||||||
"data": {
|
"data": {
|
||||||
"key_method": "Key source",
|
"key_method": "Key source",
|
||||||
"hub_url": "PowerView hub URL",
|
"hub_url": "PowerView hub URL",
|
||||||
@@ -28,34 +14,6 @@
|
|||||||
"hub_url": "Base URL of your PowerView G3 hub, e.g. {hub_url_example}",
|
"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...)"
|
"home_key": "32-character hex string (e.g. 0102030405060708090a0b0c0d0e0f10) or \\x escaped format (e.g. \\x01\\x02...)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"homekey_bluetooth": {
|
|
||||||
"title": "Configure HomeKey for {name}",
|
|
||||||
"description": "This shade uses encryption. The recommended method is to fetch the key from your G3 hub. You can also enter it manually, or skip (controls will be disabled until a key is configured).",
|
|
||||||
"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...)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"select_device": {
|
|
||||||
"title": "Select Shades",
|
|
||||||
"description": "Select the PowerView shades to add via Bluetooth.",
|
|
||||||
"data": {
|
|
||||||
"address": "Shades"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"manual": {
|
|
||||||
"title": "Enter Device Details",
|
|
||||||
"description": "Enter the device details manually.",
|
|
||||||
"data": {
|
|
||||||
"address": "Bluetooth MAC address (e.g. AA:BB:CC:DD:EE:FF)",
|
|
||||||
"ble_name": "BLE device name (e.g. DUE:94ED)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -68,8 +26,7 @@
|
|||||||
},
|
},
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user