From 3529eb6044678655912a07d58734d0e59b40b1f6 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Fri, 16 Feb 2024 01:27:11 +1100 Subject: [PATCH] Powerview Gen 3 functionality (#110158) Co-authored-by: J. Nick Koston --- .../hunterdouglas_powerview/__init__.py | 132 +- .../hunterdouglas_powerview/button.py | 50 +- .../hunterdouglas_powerview/config_flow.py | 62 +- .../hunterdouglas_powerview/const.py | 83 +- .../hunterdouglas_powerview/coordinator.py | 33 +- .../hunterdouglas_powerview/cover.py | 462 +++--- .../hunterdouglas_powerview/entity.py | 62 +- .../hunterdouglas_powerview/manifest.json | 4 +- .../hunterdouglas_powerview/model.py | 12 +- .../hunterdouglas_powerview/scene.py | 18 +- .../hunterdouglas_powerview/select.py | 65 +- .../hunterdouglas_powerview/sensor.py | 81 +- .../hunterdouglas_powerview/shade_data.py | 111 +- .../hunterdouglas_powerview/strings.json | 7 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../hunterdouglas_powerview/__init__.py | 2 - .../hunterdouglas_powerview/conftest.py | 150 +- .../hunterdouglas_powerview/const.py | 98 ++ .../fixtures/{ => gen1}/fwversion.json | 2 +- .../{userdata_v1.json => gen1/userdata.json} | 12 +- .../fixtures/gen2/fwversion.json | 15 + .../fixtures/gen2/repeaters.json | 41 + .../fixtures/gen2/rooms.json | 134 ++ .../fixtures/gen2/scenemembers.json | 551 +++++++ .../fixtures/gen2/scenes.json | 206 +++ .../fixtures/gen2/scheduledevents.json | 188 +++ .../fixtures/gen2/shades.json | 422 ++++++ .../fixtures/gen2/userdata.json | 53 + .../fixtures/gen3/gateway/info.json | 4 + .../fixtures/gen3/gateway/primary.json | 79 + .../fixtures/gen3/gateway/secondary.json | 79 + .../fixtures/gen3/home/automations.json | 101 ++ .../fixtures/gen3/home/home.json | 1294 +++++++++++++++++ .../fixtures/gen3/home/rooms.json | 83 ++ .../fixtures/gen3/home/scenes.json | 182 +++ .../fixtures/gen3/home/shades.json | 314 ++++ .../fixtures/userdata.json | 50 - .../test_config_flow.py | 449 +++--- .../hunterdouglas_powerview/test_scene.py | 97 +- 41 files changed, 4787 insertions(+), 1010 deletions(-) create mode 100644 tests/components/hunterdouglas_powerview/const.py rename tests/components/hunterdouglas_powerview/fixtures/{ => gen1}/fwversion.json (74%) rename tests/components/hunterdouglas_powerview/fixtures/{userdata_v1.json => gen1/userdata.json} (76%) create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/fwversion.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/repeaters.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/rooms.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/scenemembers.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/scenes.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/scheduledevents.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/shades.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen2/userdata.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/info.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/primary.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/secondary.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/home/automations.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/home/home.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/home/rooms.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/home/scenes.json create mode 100644 tests/components/hunterdouglas_powerview/fixtures/gen3/home/shades.json delete mode 100644 tests/components/hunterdouglas_powerview/fixtures/userdata.json diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 56ebbe6fb26..2f238a3fe6f 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -3,40 +3,23 @@ import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest -from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.tools import base64_to_unicode +from aiopvapi.hub import Hub +from aiopvapi.resources.model import PowerviewData from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes from aiopvapi.shades import Shades -from aiopvapi.userdata import UserData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import ( - API_PATH_FWVERSION, - DEFAULT_LEGACY_MAINPROCESSOR, - DOMAIN, - FIRMWARE, - FIRMWARE_MAINPROCESSOR, - FIRMWARE_NAME, - HUB_EXCEPTIONS, - HUB_NAME, - MAC_ADDRESS_IN_USERDATA, - ROOM_DATA, - SCENE_DATA, - SERIAL_NUMBER_IN_USERDATA, - SHADE_DATA, - USER_DATA, -) +from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo, PowerviewEntryData from .shade_data import PowerviewShadeData -from .util import async_map_data_by_id PARALLEL_UPDATES = 1 @@ -58,46 +41,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data hub_address = config[CONF_HOST] + api_version = config.get(CONF_API_VERSION, None) + _LOGGER.debug("Connecting %s at %s with v%s api", DOMAIN, hub_address, api_version) + websession = async_get_clientsession(hass) - pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) + pv_request = AioRequest( + hub_address, loop=hass.loop, websession=websession, api_version=api_version + ) try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) - - async with asyncio.timeout(10): - rooms = Rooms(pv_request) - room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - - async with asyncio.timeout(10): - scenes = Scenes(pv_request) - scene_data = async_map_data_by_id( - (await scenes.get_resources())[SCENE_DATA] - ) - - async with asyncio.timeout(10): - shades = Shades(pv_request) - shade_entries = await shades.get_resources() - shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) - + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) except HUB_EXCEPTIONS as err: raise ConfigEntryNotReady( - f"Connection error to PowerView hub: {hub_address}: {err}" + f"Connection error to PowerView hub {hub_address}: {err}" ) from err + + if hub.role != "Primary": + # this should be caught in config_flow, but account for a hub changing roles + # this will only happen manually by a user + _LOGGER.error( + "%s (%s) is performing role of %s Hub. " + "Only the Primary Hub can manage shades", + hub.name, + hub.hub_address, + hub.role, + ) + return False + + try: + async with asyncio.timeout(10): + rooms = Rooms(pv_request) + room_data: PowerviewData = await rooms.get_rooms() + async with asyncio.timeout(10): + scenes = Scenes(pv_request) + scene_data: PowerviewData = await scenes.get_scenes() + async with asyncio.timeout(10): + shades = Shades(pv_request) + shade_data: PowerviewData = await shades.get_shades() + except HUB_EXCEPTIONS as err: + raise ConfigEntryNotReady( + f"Connection error to PowerView hub {hub_address}: {err}" + ) from err + if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + if CONF_API_VERSION not in config: + new_data = {**entry.data} + new_data[CONF_API_VERSION] = hub.api_version + hass.config_entries.async_update_entry(entry, data=new_data) + + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub) coordinator.async_set_updated_data(PowerviewShadeData()) # populate raw shade data into the coordinator for diagnostics - coordinator.data.store_group_data(shade_entries[SHADE_DATA]) + coordinator.data.store_group_data(shade_data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData( api=pv_request, - room_data=room_data, - scene_data=scene_data, - shade_data=shade_data, + room_data=room_data.processed, + scene_data=scene_data.processed, + shade_data=shade_data.processed, coordinator=coordinator, device_info=device_info, ) @@ -107,39 +114,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_get_device_info( - pv_request: AioRequest, hub_address: str -) -> PowerviewDeviceInfo: +async def async_get_device_info(hub: Hub) -> PowerviewDeviceInfo: """Determine device info.""" - userdata = UserData(pv_request) - resources = await userdata.get_resources() - userdata_data = resources[USER_DATA] - - if FIRMWARE in userdata_data: - main_processor_info = userdata_data[FIRMWARE][FIRMWARE_MAINPROCESSOR] - elif userdata_data: - # Legacy devices - fwversion = ApiEntryPoint(pv_request, API_PATH_FWVERSION) - resources = await fwversion.get_resources() - - if FIRMWARE in resources: - main_processor_info = resources[FIRMWARE][FIRMWARE_MAINPROCESSOR] - else: - main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR - return PowerviewDeviceInfo( - name=base64_to_unicode(userdata_data[HUB_NAME]), - mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], - serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], - firmware=main_processor_info, - model=main_processor_info[FIRMWARE_NAME], - hub_address=hub_address, + name=hub.name, + mac_address=hub.mac_address, + serial_number=hub.serial_number, + firmware=hub.firmware, + model=hub.model, + hub_address=hub.ip, ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index cb6bc72954f..c37741fcb09 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -5,7 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ( + ATTR_NAME, + MOTION_CALIBRATE, + MOTION_FAVORITE, + MOTION_JOG, +) +from aiopvapi.hub import Hub +from aiopvapi.resources.shade import BaseShade from homeassistant.components.button import ( ButtonDeviceClass, @@ -17,7 +24,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -27,7 +34,8 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" - press_action: Callable[[BaseShade], Any] + press_action: Callable[[BaseShade | Hub], Any] + create_entity_fn: Callable[[BaseShade | Hub], bool] @dataclass(frozen=True) @@ -37,18 +45,20 @@ class PowerviewButtonDescription( """Class to describe a Button entity.""" -BUTTONS: Final = [ +BUTTONS_SHADE: Final = [ PowerviewButtonDescription( key="calibrate", translation_key="calibrate", icon="mdi:swap-vertical-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_CALIBRATE), press_action=lambda shade: shade.calibrate(), ), PowerviewButtonDescription( key="identify", device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_JOG), press_action=lambda shade: shade.jog(), ), PowerviewButtonDescription( @@ -56,6 +66,7 @@ BUTTONS: Final = [ translation_key="favorite", icon="mdi:heart", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_FAVORITE), press_action=lambda shade: shade.favorite(), ), ] @@ -71,28 +82,25 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[ButtonEntity] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - - for description in BUTTONS: - entities.append( - PowerviewButton( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") + for description in BUTTONS_SHADE: + if description.create_entity_fn(shade): + entities.append( + PowerviewShadeButton( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) -class PowerviewButton(ShadeEntity, ButtonEntity): +class PowerviewShadeButton(ShadeEntity, ButtonEntity): """Representation of an advanced feature button.""" def __init__( diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 81532187bbf..359edfb340e 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,14 +3,15 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.hub import Hub import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp, zeroconf -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -19,9 +20,9 @@ from .const import DOMAIN, HUB_EXCEPTIONS _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) HAP_SUFFIX = "._hap._tcp.local." -POWERVIEW_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G2_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G3_SUFFIX = "._powerview-g3._tcp.local." async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str, str]: @@ -36,44 +37,70 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) except HUB_EXCEPTIONS as err: raise CannotConnect from err + if hub.role != "Primary": + raise UnsupportedDevice( + f"{hub.name} ({hub.hub_address}) is the {hub.role} Hub. " + "Only the Primary can manage shades" + ) + + _LOGGER.debug("Connection made using api version: %s", hub.api_version) + # Return info that you want to store in the config entry. return { "title": device_info.name, "unique_id": device_info.serial_number, + CONF_API_VERSION: hub.api_version, } -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class PowerviewConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Hunter Douglas PowerView.""" VERSION = 1 def __init__(self) -> None: """Initialize the powerview config flow.""" - self.powerview_config: dict[str, str] = {} + self.powerview_config: dict = {} self.discovered_ip: str | None = None self.discovered_name: str | None = None + self.data_schema: dict = {vol.Required(CONF_HOST): str} async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors: dict[str, Any] = {} + errors: dict[str, str] = {} + if user_input is not None: info, error = await self._async_validate_or_error(user_input[CONF_HOST]) + if info and not error: + self.powerview_config = { + CONF_HOST: user_input[CONF_HOST], + CONF_NAME: info["title"], + CONF_API_VERSION: info[CONF_API_VERSION], + } await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( - title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} + title=self.powerview_config[CONF_NAME], + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) + + if TYPE_CHECKING: + assert error is not None errors["base"] = error return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=vol.Schema(self.data_schema), errors=errors ) async def _async_validate_or_error( @@ -85,6 +112,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, host) except CannotConnect: return None, "cannot_connect" + except UnsupportedDevice: + return None, "unsupported_device" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return None, "unknown" @@ -102,7 +131,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host - name = discovery_info.name.removesuffix(POWERVIEW_SUFFIX) + name = discovery_info.name.removesuffix(POWERVIEW_G2_SUFFIX) + name = name.removesuffix(POWERVIEW_G3_SUFFIX) self.discovered_name = name return await self.async_step_discovery_confirm() @@ -137,6 +167,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.powerview_config = { CONF_HOST: self.discovered_ip, CONF_NAME: self.discovered_name, + CONF_API_VERSION: info[CONF_API_VERSION], } return await self.async_step_link() @@ -147,7 +178,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=self.powerview_config[CONF_NAME], - data={CONF_HOST: self.powerview_config[CONF_HOST]}, + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) self._set_confirm_only() @@ -159,3 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" + + +class UnsupportedDevice(exceptions.HomeAssistantError): + """Error to indicate the device is not supported.""" diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 319ea0c5b73..a2d18c6f512 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -1,91 +1,28 @@ -"""Support for Powerview scenes from a Powerview hub.""" +"""Constants for Hunter Douglas Powerview hub.""" from aiohttp.client_exceptions import ServerDisconnectedError -from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError +from aiopvapi.helpers.aiorequest import ( + PvApiConnectionError, + PvApiEmptyData, + PvApiMaintenance, + PvApiResponseStatusError, +) DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" -HUB_ADDRESS = "address" - -SCENE_DATA = "sceneData" -SHADE_DATA = "shadeData" -ROOM_DATA = "roomData" -USER_DATA = "userData" - -MAC_ADDRESS_IN_USERDATA = "macAddress" -SERIAL_NUMBER_IN_USERDATA = "serialNumber" -HUB_NAME = "hubName" - -FIRMWARE = "firmware" -FIRMWARE_MAINPROCESSOR = "mainProcessor" -FIRMWARE_NAME = "name" -FIRMWARE_REVISION = "revision" -FIRMWARE_SUB_REVISION = "subRevision" -FIRMWARE_BUILD = "build" - REDACT_MAC_ADDRESS = "mac_address" REDACT_SERIAL_NUMBER = "serial_number" REDACT_HUB_ADDRESS = "hub_address" -SCENE_NAME = "name" -SCENE_ID = "id" -ROOM_ID_IN_SCENE = "roomId" - -SHADE_NAME = "name" -SHADE_ID = "id" -ROOM_ID_IN_SHADE = "roomId" - -ROOM_NAME = "name" -ROOM_NAME_UNICODE = "name_unicode" -ROOM_ID = "id" - -SHADE_BATTERY_LEVEL = "batteryStrength" -SHADE_BATTERY_LEVEL_MAX = 200 - -ATTR_SIGNAL_STRENGTH = "signalStrength" -ATTR_SIGNAL_STRENGTH_MAX = 4 - -STATE_ATTRIBUTE_ROOM_NAME = "roomName" +STATE_ATTRIBUTE_ROOM_NAME = "room_name" HUB_EXCEPTIONS = ( ServerDisconnectedError, TimeoutError, PvApiConnectionError, PvApiResponseStatusError, + PvApiMaintenance, + PvApiEmptyData, ) - -LEGACY_DEVICE_SUB_REVISION = 1 -LEGACY_DEVICE_REVISION = 0 -LEGACY_DEVICE_BUILD = 0 -LEGACY_DEVICE_MODEL = "PowerView Hub" - -DEFAULT_LEGACY_MAINPROCESSOR = { - FIRMWARE_REVISION: LEGACY_DEVICE_REVISION, - FIRMWARE_SUB_REVISION: LEGACY_DEVICE_SUB_REVISION, - FIRMWARE_BUILD: LEGACY_DEVICE_BUILD, - FIRMWARE_NAME: LEGACY_DEVICE_MODEL, -} - -API_PATH_FWVERSION = "api/fwversion" - -POS_KIND_NONE = 0 -POS_KIND_PRIMARY = 1 -POS_KIND_SECONDARY = 2 -POS_KIND_VANE = 3 -POS_KIND_ERROR = 4 - - -ATTR_BATTERY_KIND = "batteryKind" -BATTERY_KIND_HARDWIRED = 1 -BATTERY_KIND_BATTERY = 2 -BATTERY_KIND_RECHARGABLE = 3 - -POWER_SUPPLY_TYPE_MAP = { - BATTERY_KIND_HARDWIRED: "Hardwired Power Supply", - BATTERY_KIND_BATTERY: "Battery Wand", - BATTERY_KIND_RECHARGABLE: "Rechargeable Battery", -} -POWER_SUPPLY_TYPE_REVERSE_MAP = {v: k for k, v in POWER_SUPPLY_TYPE_MAP.items()} diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 4643536d56d..db4079f2b58 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -5,12 +5,14 @@ import asyncio from datetime import timedelta import logging +from aiopvapi.helpers.aiorequest import PvApiMaintenance +from aiopvapi.hub import Hub from aiopvapi.shades import Shades from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SHADE_DATA +from .const import HUB_EXCEPTIONS from .shade_data import PowerviewShadeData _LOGGER = logging.getLogger(__name__) @@ -19,18 +21,14 @@ _LOGGER = logging.getLogger(__name__) class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): """DataUpdateCoordinator to gather data from a powerview hub.""" - def __init__( - self, - hass: HomeAssistant, - shades: Shades, - hub_address: str, - ) -> None: + def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None: """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades + self.hub = hub super().__init__( hass, _LOGGER, - name=f"powerview hub {hub_address}", + name=f"powerview hub {hub.hub_address}", update_interval=timedelta(seconds=60), ) @@ -38,17 +36,20 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) """Fetch data from shade endpoint.""" async with asyncio.timeout(10): - shade_entries = await self.shades.get_resources() - - if isinstance(shade_entries, bool): - # hub returns boolean on a 204/423 empty response (maintenance) - # continual polling results in inevitable error - raise UpdateFailed("Powerview Hub is undergoing maintenance") + try: + shade_entries = await self.shades.get_shades() + except PvApiMaintenance as error: + # hub is undergoing maintenance, pause polling + raise UpdateFailed(error) from error + except HUB_EXCEPTIONS as error: + raise UpdateFailed( + f"Powerview Hub {self.hub.hub_address} did not return any data: {error}" + ) from error if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data") + raise UpdateFailed("No new shade data was returned") # only update if shade_entries is valid - self.data.store_group_data(shade_entries[SHADE_DATA]) + self.data.store_group_data(shade_entries) return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index f9920c26f3a..5637af57b72 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -4,21 +4,20 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import replace from datetime import datetime, timedelta import logging from math import ceil from typing import Any from aiopvapi.helpers.constants import ( - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, + ATTR_NAME, + CLOSED_POSITION, MAX_POSITION, MIN_POSITION, + MOTION_STOP, ) -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.resources.shade import BaseShade, ShadePosition from homeassistant.components.cover import ( ATTR_POSITION, @@ -32,20 +31,10 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from .const import ( - DOMAIN, - LEGACY_DEVICE_MODEL, - POS_KIND_PRIMARY, - POS_KIND_SECONDARY, - POS_KIND_VANE, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - STATE_ATTRIBUTE_ROOM_NAME, -) +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -57,14 +46,6 @@ PARALLEL_UPDATES = 1 RESYNC_DELAY = 60 -# this equates to 0.75/100 in terms of hass blind position -# some blinds in a closed position report less than 655.35 (1%) -# but larger than 0 even though they are clearly closed -# Find 1 percent of MAX_POSITION, then find 75% of that number -# The means currently 491.5125 or less is closed position -# implemented for top/down shades, but also works fine with normal shades -CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) - SCAN_INTERVAL = timedelta(minutes=10) @@ -77,42 +58,23 @@ async def async_setup_entry( coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator entities: list[ShadeEntity] = [] - for raw_shade in pv_entry.shade_data.values(): + for shade in pv_entry.shade_data.values(): # The shade may be out of sync with the hub # so we force a refresh when we add it if possible - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name with suppress(TimeoutError): async with asyncio.timeout(1): await shade.refresh() - - if ATTR_POSITION_DATA not in shade.raw_data: - _LOGGER.info( - "The %s shade was skipped because it is missing position data", - name_before_refresh, - ) - continue - coordinator.data.update_shade_positions(shade.raw_data) - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + coordinator.data.update_shade_position(shade.id, shade.current_position) + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") entities.extend( create_powerview_shade_entity( - coordinator, pv_entry.device_info, room_name, shade, name_before_refresh + coordinator, pv_entry.device_info, room_name, shade, shade.name ) ) + async_add_entities(entities) -def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hunter douglas position to hass position.""" - return round((hd_position / max_val) * 100) - - -def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hass position to hunter douglas position.""" - return int(hass_position / 100 * max_val) - - class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" @@ -135,7 +97,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): super().__init__(coordinator, device_info, room_name, shade, name) self._shade: BaseShade = shade self._scheduled_transition_update: CALLBACK_TYPE | None = None - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync: Callable[[], None] | None = None @@ -172,22 +134,22 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position, {}) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position, {}) + return replace(self._shade.close_position, velocity=self.positions.velocity) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" @@ -208,12 +170,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._async_cancel_scheduled_transition_update() - self.data.update_from_response(await self._shade.stop()) + await self._shade.stop() await self._async_force_refresh_state() @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" + """Don't allow a cover to go into an impossbile position.""" # no override required in base return target_hass_position @@ -222,21 +184,21 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): await self._async_set_cover_position(kwargs[ATTR_POSITION]) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_one = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) - async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + async def _async_execute_move(self, move: ShadePosition) -> None: """Execute a move that can affect multiple positions.""" - response = await self._shade.move(move.request) - # Process any positions we know will update as result - # of the request since the hub won't return them - for kind, position in move.new_positions.items(): - self.data.update_shade_position(self._shade.id, position, kind) - # Finally process the response - self.data.update_from_response(response) + _LOGGER.debug("Move request %s: %s", self.name, move) + response = await self._shade.move(move) + _LOGGER.debug("Move response %s: %s", self.name, response) + + # Process the response from the hub (including new positions) + self.data.update_shade_position(self._shade.id, response) async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" @@ -251,9 +213,9 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self.async_write_ha_state() @callback - def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: + def _async_update_shade_data(self, shade_data: ShadePosition) -> None: """Update the current cover position from the data.""" - self.data.update_shade_positions(shade_data) + self.data.update_shade_position(self._shade.id, shade_data) self._attr_is_opening = False self._attr_is_closing = False @@ -283,7 +245,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): est_time_to_complete_transition, ) - # Schedule an forced update for when we expect the transition + # Schedule a forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -342,8 +304,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): # The update will likely timeout and # error if are already have one in flight return - await self._shade.refresh() - self._async_update_shade_data(self._shade.raw_data) + # suppress timeouts caused by hub nightly reboot + with suppress(asyncio.TimeoutError): + async with asyncio.timeout(5): + await self._shade.refresh() + _LOGGER.debug("Process update %s: %s", self.name, self._shade.current_position) + self._async_update_shade_data(self._shade.current_position) class PowerViewShade(PowerViewShadeBase): @@ -372,31 +338,31 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @property def current_cover_tilt_position(self) -> int: """Return the current cover tile position.""" - return hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.tilt @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.primary + self.positions.tilt @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position_tilt, {}) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position_tilt, {}) + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity + ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -411,13 +377,13 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) async def _async_set_cover_tilt_position( self, target_hass_tilt_position: int ) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" final_position = self.current_cover_position + target_hass_tilt_position self._async_schedule_update_for_transition( abs(self.transition_steps - final_position) @@ -426,11 +392,19 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): self.async_write_ha_state() @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: @@ -450,49 +424,25 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): _attr_name = None @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.close_position, velocity=self.positions.velocity) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) - - @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, - {POS_KIND_VANE: MIN_POSITION}, - ) - - @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, - {POS_KIND_PRIMARY: MIN_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -506,32 +456,21 @@ class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase): """ @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - position_vane = self.positions.vane - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + tilt=self.positions.tilt, + velocity=self.positions.velocity, ) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = self.positions.primary - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @@ -558,7 +497,7 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -577,17 +516,18 @@ class PowerViewShadeTopDown(PowerViewShadeBase): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(MAX_POSITION - self.positions.primary, MAX_POSITION) + # inverted positioning + return MAX_POSITION - self.positions.primary + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(MAX_POSITION - kwargs[ATTR_POSITION]) @property def is_closed(self) -> bool: """Return if the cover is closed.""" return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the shade to a specific position.""" - await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) - class PowerViewShadeDualRailBase(PowerViewShadeBase): """Representation of a shade with top/down bottom/up capabilities. @@ -600,9 +540,7 @@ class PowerViewShadeDualRailBase(PowerViewShadeBase): @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.primary + self.positions.secondary class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @@ -629,22 +567,16 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" - cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) - return min(target_hass_position, (100 - cover_top)) + """Don't allow a cover to go into an impossbile position.""" + return min(target_hass_position, (MAX_POSITION - self.positions.secondary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = hass_position_to_hd(target_hass_position) - position_top = self.positions.secondary - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + secondary=self.positions.secondary, + velocity=self.positions.velocity, ) @@ -689,41 +621,31 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): def current_cover_position(self) -> int: """Return the current position of cover.""" # these need to be inverted to report state correctly in HA - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" # these shades share a class in parent API # override open position for top shade - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSITION2: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + return ShadePosition( + primary=MIN_POSITION, + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: """Don't allow a cover to go into an impossbile position.""" - cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) - return min(target_hass_position, (100 - cover_bottom)) + return min(target_hass_position, (MAX_POSITION - self.positions.primary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = self.positions.primary - position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + secondary=target_hass_position, + velocity=self.positions.velocity, ) @@ -739,33 +661,27 @@ class PowerViewShadeDualOverlappedBase(PowerViewShadeBase): # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + secondary = self.positions.secondary / 2 return ceil(primary + secondary) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MAX_POSITION, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -782,7 +698,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): _attr_translation_key = "combined" - # type def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -806,36 +721,28 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): """Return the current position of cover.""" # if front is open return that (other positions are impossible) # if front shade is closed get position of rear - position = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + position = (self.positions.primary / 2) + 50 if self.positions.primary == MIN_POSITION: - position = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + position = self.positions.secondary / 2 return ceil(position) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without - # tilt so no additional override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + # 0 - 50 represents the rear blockut shade if target_hass_position <= 50: target_hass_position = target_hass_position * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) target_hass_position = (target_hass_position - 50) * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @@ -879,28 +786,19 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): return False @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -952,31 +850,22 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @@ -1010,7 +899,7 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -1020,40 +909,32 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 - vane = hd_position_to_hass(self.positions.vane, self._max_tilt) - return ceil(primary + secondary + vane) + secondary = self.positions.secondary / 2 + tilt = self.positions.tilt + return ceil(primary + secondary + tilt) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_vane, - ATTR_POSKIND1: POS_KIND_VANE, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -1099,7 +980,8 @@ def create_powerview_shade_entity( shade.capability.type, (PowerViewShade,) ) _LOGGER.debug( - "%s (%s) detected as %a %s", + "%s %s (%s) detected as %a %s", + room_name, shade.name, shade.capability.type, classes, diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 78f63e16879..424d314c4b9 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,25 +1,19 @@ """The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE, BaseShade +import logging + +from aiopvapi.resources.shade import BaseShade, ShadePosition -from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_BATTERY_KIND, - BATTERY_KIND_HARDWIRED, - DOMAIN, - FIRMWARE, - FIRMWARE_BUILD, - FIRMWARE_REVISION, - FIRMWARE_SUB_REVISION, - MANUFACTURER, -) +from .const import DOMAIN, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo -from .shade_data import PowerviewShadeData, PowerviewShadePositions +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @@ -39,6 +33,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): self._room_name = room_name self._attr_unique_id = unique_id self._device_info = device_info + self._configuration_url = self.coordinator.hub.url @property def data(self) -> PowerviewShadeData: @@ -48,17 +43,14 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - firmware = self._device_info.firmware - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" return DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, identifiers={(DOMAIN, self._device_info.serial_number)}, manufacturer=MANUFACTURER, model=self._device_info.model, name=self._device_info.name, - suggested_area=self._room_name, - sw_version=sw_version, - configuration_url=f"http://{self._device_info.hub_address}/api/shades", + sw_version=self._device_info.firmware, + configuration_url=self._configuration_url, ) @@ -77,42 +69,24 @@ class ShadeEntity(HDEntity): super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade - self._is_hard_wired = bool( - shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED - ) + self._is_hard_wired = not shade.is_battery_powered() + self._configuration_url = shade.url @property - def positions(self) -> PowerviewShadePositions: + def positions(self) -> ShadePosition: """Return the PowerviewShadeData.""" - return self.data.get_shade_positions(self._shade.id) + return self.data.get_shade_position(self._shade.id) @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - - device_info = DeviceInfo( + return DeviceInfo( identifiers={(DOMAIN, self._shade.id)}, name=self._shade_name, suggested_area=self._room_name, manufacturer=MANUFACTURER, - model=str(self._shade.raw_data[ATTR_TYPE]), + model=self._shade.type_name, + sw_version=self._shade.firmware, via_device=(DOMAIN, self._device_info.serial_number), - configuration_url=( - f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}" - ), + configuration_url=self._configuration_url, ) - - for shade in self._shade.shade_types: - if str(shade.shade_type) == device_info[ATTR_MODEL]: - device_info[ATTR_MODEL] = shade.description - break - - if FIRMWARE not in self._shade.raw_data: - return device_info - - firmware = self._shade.raw_data[FIRMWARE] - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" - - device_info[ATTR_SW_VERSION] = sw_version - - return device_info diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index f62879aed78..276b10f5e8d 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -18,6 +18,6 @@ }, "iot_class": "local_polling", "loggers": ["aiopvapi"], - "requirements": ["aiopvapi==2.0.4"], - "zeroconf": ["_powerview._tcp.local."] + "requirements": ["aiopvapi==3.0.2"], + "zeroconf": ["_powerview._tcp.local.", "_powerview-g3._tcp.local."] } diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py index b7ad4a7439c..e2311eb4e4c 100644 --- a/homeassistant/components/hunterdouglas_powerview/model.py +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -2,9 +2,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.resources.room import Room +from aiopvapi.resources.scene import Scene +from aiopvapi.resources.shade import BaseShade from .coordinator import PowerviewShadeUpdateCoordinator @@ -14,9 +16,9 @@ class PowerviewEntryData: """Define class for main domain information.""" api: AioRequest - room_data: dict[str, Any] - scene_data: dict[str, Any] - shade_data: dict[str, Any] + room_data: dict[str, Room] + scene_data: dict[str, Scene] + shade_data: dict[str, BaseShade] coordinator: PowerviewShadeUpdateCoordinator device_info: PowerviewDeviceInfo @@ -28,6 +30,6 @@ class PowerviewDeviceInfo: name: str mac_address: str serial_number: str - firmware: dict[str, Any] + firmware: str | None model: str hub_address: str diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 4676a8d1505..0ba9b13d03b 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -1,8 +1,10 @@ """Support for Powerview scenes from a Powerview hub.""" from __future__ import annotations +import logging from typing import Any +from aiopvapi.helpers.constants import ATTR_NAME from aiopvapi.resources.scene import Scene as PvScene from homeassistant.components.scene import Scene @@ -10,11 +12,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import HDEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + +RESYNC_DELAY = 60 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,9 +30,8 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pvscenes: list[PowerViewScene] = [] - for raw_scene in pv_entry.scene_data.values(): - scene = PvScene(raw_scene, pv_entry.api) - room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + for scene in pv_entry.scene_data.values(): + room_name = getattr(pv_entry.room_data.get(scene.room_id), ATTR_NAME, "") pvscenes.append( PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) ) @@ -47,10 +52,11 @@ class PowerViewScene(HDEntity, Scene): ) -> None: """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) - self._scene = scene + self._scene: PvScene = scene self._attr_name = scene.name self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" - await self._scene.activate() + shades = await self._scene.activate() + _LOGGER.debug("Scene activated for shade(s) %s", shades) diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 65fe61851df..bbe4614afd1 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +import logging from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME, FUNCTION_SET_POWER +from aiopvapi.resources.shade import BaseShade from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,19 +15,13 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - DOMAIN, - POWER_SUPPLY_TYPE_MAP, - POWER_SUPPLY_TYPE_REVERSE_MAP, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True) class PowerviewSelectDescriptionMixin: @@ -33,6 +29,8 @@ class PowerviewSelectDescriptionMixin: current_fn: Callable[[BaseShade], Any] select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] + create_entity_fn: Callable[[BaseShade], bool] + options_fn: Callable[[BaseShade], list[str]] @dataclass(frozen=True) @@ -49,13 +47,10 @@ DROPDOWNS: Final = [ key="powersource", translation_key="power_source", icon="mdi:power-plug-outline", - current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( - shade.raw_data.get(ATTR_BATTERY_KIND), None - ), - options=list(POWER_SUPPLY_TYPE_MAP.values()), - select_fn=lambda shade, option: shade.set_power_source( - POWER_SUPPLY_TYPE_REVERSE_MAP.get(option) - ), + current_fn=lambda shade: shade.get_power_source(), + options_fn=lambda shade: shade.supported_power_sources(), + select_fn=lambda shade, option: shade.set_power_source(option), + create_entity_fn=lambda shade: shade.is_supported(FUNCTION_SET_POWER), ), ] @@ -67,26 +62,23 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - entities = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - if SHADE_BATTERY_LEVEL not in shade.raw_data: + entities: list[PowerViewSelect] = [] + for shade in pv_entry.shade_data.values(): + if not shade.has_battery_info(): continue - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in DROPDOWNS: - entities.append( - PowerViewSelect( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + if description.create_entity_fn(shade): + entities.append( + PowerViewSelect( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) @@ -113,6 +105,11 @@ class PowerViewSelect(ShadeEntity, SelectEntity): """Return the selected entity option to represent the entity state.""" return self.entity_description.current_fn(self._shade) + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options_fn(self._shade) + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 8e16d53ae09..02b4ae7c557 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -4,7 +4,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME +from aiopvapi.resources.shade import BaseShade from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,21 +14,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - ATTR_SIGNAL_STRENGTH, - ATTR_SIGNAL_STRENGTH_MAX, - BATTERY_KIND_HARDWIRED, - DOMAIN, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, - SHADE_BATTERY_LEVEL_MAX, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -38,8 +29,10 @@ class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" update_fn: Callable[[BaseShade], Any] + device_class_fn: Callable[[BaseShade], SensorDeviceClass | None] native_value_fn: Callable[[BaseShade], int] - create_sensor_fn: Callable[[BaseShade], bool] + native_unit_fn: Callable[[BaseShade], str | None] + create_entity_fn: Callable[[BaseShade], bool] @dataclass(frozen=True) @@ -52,29 +45,33 @@ class PowerviewSensorDescription( state_class = SensorStateClass.MEASUREMENT +def get_signal_device_class(shade: BaseShade) -> SensorDeviceClass | None: + """Get the signal value based on version of API.""" + return SensorDeviceClass.SIGNAL_STRENGTH if shade.api_version >= 3 else None + + +def get_signal_native_unit(shade: BaseShade) -> str: + """Get the unit of measurement for signal based on version of API.""" + return SIGNAL_STRENGTH_DECIBELS if shade.api_version >= 3 else PERCENTAGE + + SENSORS: Final = [ PowerviewSensorDescription( key="charge", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 - ), - create_sensor_fn=lambda shade: bool( - shade.raw_data.get(ATTR_BATTERY_KIND) != BATTERY_KIND_HARDWIRED - and SHADE_BATTERY_LEVEL in shade.raw_data - ), + device_class_fn=lambda shade: SensorDeviceClass.BATTERY, + native_unit_fn=lambda shade: PERCENTAGE, + native_value_fn=lambda shade: shade.get_battery_strength(), + create_entity_fn=lambda shade: shade.is_battery_powered(), update_fn=lambda shade: shade.refresh_battery(), ), PowerviewSensorDescription( key="signal", translation_key="signal_strength", icon="mdi:signal", - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 - ), - create_sensor_fn=lambda shade: bool(ATTR_SIGNAL_STRENGTH in shade.raw_data), + device_class_fn=get_signal_device_class, + native_unit_fn=get_signal_native_unit, + native_value_fn=lambda shade: shade.get_signal_strength(), + create_entity_fn=lambda shade: shade.has_signal_strength(), update_fn=lambda shade: shade.refresh(), entity_registry_enabled_default=False, ), @@ -89,21 +86,17 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[PowerViewSensor] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in SENSORS: - if description.create_sensor_fn(shade): + if description.create_entity_fn(shade): entities.append( PowerViewSensor( pv_entry.coordinator, pv_entry.device_info, room_name, shade, - name_before_refresh, + shade.name, description, ) ) @@ -125,17 +118,27 @@ class PowerViewSensor(ShadeEntity, SensorEntity): name: str, description: PowerviewSensorDescription, ) -> None: - """Initialize the select entity.""" + """Initialize the sensor entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description = description + self.entity_description: PowerviewSensorDescription = description self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" - self._attr_native_unit_of_measurement = description.native_unit_of_measurement @property def native_value(self) -> int: - """Get the current value in percentage.""" + """Get the current value of the sensor.""" return self.entity_description.native_value_fn(self._shade) + @property + def native_unit_of_measurement(self) -> str | None: + """Return native unit of measurement of sensor.""" + return self.entity_description.native_unit_fn(self._shade) + + @property + def device_class(self) -> SensorDeviceClass | None: + """Return the class of this entity.""" + return self.entity_description.device_class_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py index fab14b540b7..86f232c3b66 100644 --- a/homeassistant/components/hunterdouglas_powerview/shade_data.py +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -1,59 +1,25 @@ """Shade data for the Hunter Douglas PowerView integration.""" from __future__ import annotations -from collections.abc import Iterable -from dataclasses import dataclass import logging from typing import Any -from aiopvapi.helpers.constants import ( - ATTR_ID, - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, - ATTR_SHADE, -) -from aiopvapi.resources.shade import MIN_POSITION +from aiopvapi.resources.model import PowerviewData +from aiopvapi.resources.shade import BaseShade, ShadePosition -from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE from .util import async_map_data_by_id -POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) - _LOGGER = logging.getLogger(__name__) -@dataclass -class PowerviewShadeMove: - """Request to move a powerview shade.""" - - # The positions to request on the hub - request: dict[str, int] - - # The positions that will also change - # as a result of the request that the - # hub will not send back - new_positions: dict[int, int] - - -@dataclass -class PowerviewShadePositions: - """Positions for a powerview shade.""" - - primary: int = MIN_POSITION - secondary: int = MIN_POSITION - vane: int = MIN_POSITION - - class PowerviewShadeData: """Coordinate shade data between multiple api calls.""" def __init__(self) -> None: """Init the shade data.""" self._group_data_by_id: dict[int, dict[str | int, Any]] = {} - self.positions: dict[int, PowerviewShadePositions] = {} + self._shade_data_by_id: dict[int, BaseShade] = {} + self.positions: dict[int, ShadePosition] = {} def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: """Get data for the shade.""" @@ -63,17 +29,21 @@ class PowerviewShadeData: """Get data for all shades.""" return self._group_data_by_id - def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + def get_shade(self, shade_id: int) -> BaseShade: + """Get specific shade from the coordinator.""" + return self._shade_data_by_id[shade_id] + + def get_shade_position(self, shade_id: int) -> ShadePosition: """Get positions for a shade.""" if shade_id not in self.positions: - self.positions[shade_id] = PowerviewShadePositions() + self.positions[shade_id] = ShadePosition() return self.positions[shade_id] def update_from_group_data(self, shade_id: int) -> None: """Process an update from the group data.""" - self.update_shade_positions(self._group_data_by_id[shade_id]) + self.update_shade_positions(self._shade_data_by_id[shade_id]) - def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + def store_group_data(self, shade_data: PowerviewData) -> None: """Store data from the all shades endpoint. This does not update the shades or positions @@ -81,37 +51,34 @@ class PowerviewShadeData: with a shade_id will update a specific shade from the group data. """ - self._group_data_by_id = async_map_data_by_id(shade_data) + self._shade_data_by_id = shade_data.processed + self._group_data_by_id = async_map_data_by_id(shade_data.raw) - def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: - """Update a single shade position.""" - positions = self.get_shade_positions(shade_id) - if kind == POS_KIND_PRIMARY: - positions.primary = position - elif kind == POS_KIND_SECONDARY: - positions.secondary = position - elif kind == POS_KIND_VANE: - positions.vane = position + def update_shade_position(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades position.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() - def update_from_position_data( - self, shade_id: int, position_data: dict[str, Any] - ) -> None: - """Update the shade positions from the position data.""" - for position_key, kind_key in POSITIONS: - if position_key in position_data: - self.update_shade_position( - shade_id, position_data[position_key], position_data[kind_key] - ) + # ShadePosition will return None if the value is not set + if shade_data.primary is not None: + self.positions[shade_id].primary = shade_data.primary + if shade_data.secondary is not None: + self.positions[shade_id].secondary = shade_data.secondary + if shade_data.tilt is not None: + self.positions[shade_id].tilt = shade_data.tilt - def update_shade_positions(self, data: dict[int | str, Any]) -> None: + def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades velocity.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() + + # the hub will always return a velocity of 0 on initial connect, + # separate definition to store consistent value in HA + # this value is purely driven from HA + if shade_data.velocity is not None: + self.positions[shade_id].velocity = shade_data.velocity + + def update_shade_positions(self, data: BaseShade) -> None: """Update a shades from data dict.""" - _LOGGER.debug("Raw data update: %s", data) - shade_id = data[ATTR_ID] - position_data = data[ATTR_POSITION_DATA] - self.update_from_position_data(shade_id, position_data) - - def update_from_response(self, response: dict[str, Any]) -> None: - """Update from the response to a command.""" - if response and ATTR_SHADE in response: - shade_data: dict[int | str, Any] = response[ATTR_SHADE] - self.update_shade_positions(shade_data) + _LOGGER.debug("Raw data update: %s", data.raw_data) + self.update_shade_position(data.id, data.current_position) diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index 7c17788be83..a107e2c5be4 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -4,7 +4,11 @@ "user": { "title": "Connect to the PowerView Hub", "data": { - "host": "[%key:common::config_flow::data::ip%]" + "host": "[%key:common::config_flow::data::ip%]", + "api_version": "Hub Generation" + }, + "data_description": { + "api_version": "API version is detectable, but you can override and force a specific version" } }, "link": { @@ -15,6 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unsupported_device": "Only the primary powerview hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a66efa6dded..0f16977097d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -620,6 +620,11 @@ ZEROCONF = { "domain": "plugwise", }, ], + "_powerview-g3._tcp.local.": [ + { + "domain": "hunterdouglas_powerview", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/requirements_all.txt b/requirements_all.txt index da6fbe72293..6340e8a5528 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ aiopulse==0.4.4 aiopurpleair==2022.12.1 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.4 +aiopvapi==3.0.2 # homeassistant.components.pvpc_hourly_pricing aiopvpc==4.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba76d244176..50d6d3b54a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -306,7 +306,7 @@ aiopulse==0.4.4 aiopurpleair==2022.12.1 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.4 +aiopvapi==3.0.2 # homeassistant.components.pvpc_hourly_pricing aiopvpc==4.2.2 diff --git a/tests/components/hunterdouglas_powerview/__init__.py b/tests/components/hunterdouglas_powerview/__init__.py index 1cab5f9071e..034d845b110 100644 --- a/tests/components/hunterdouglas_powerview/__init__.py +++ b/tests/components/hunterdouglas_powerview/__init__.py @@ -1,3 +1 @@ """Tests for the Hunter Douglas PowerView integration.""" - -MOCK_MAC = "AA::BB::CC::DD::EE::FF" diff --git a/tests/components/hunterdouglas_powerview/conftest.py b/tests/components/hunterdouglas_powerview/conftest.py index e4e56abd56c..be7dec42dea 100644 --- a/tests/components/hunterdouglas_powerview/conftest.py +++ b/tests/components/hunterdouglas_powerview/conftest.py @@ -1,47 +1,137 @@ -"""Tests for the Hunter Douglas PowerView integration.""" -import json -from unittest.mock import patch +"""Common fixtures for Hunter Douglas Powerview tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from aiopvapi.resources.shade import ShadePosition import pytest -from tests.common import load_fixture +from homeassistant.components.hunterdouglas_powerview.const import DOMAIN - -@pytest.fixture(scope="session") -def powerview_userdata(): - """Return the userdata fixture.""" - return json.loads(load_fixture("hunterdouglas_powerview/userdata.json")) - - -@pytest.fixture(scope="session") -def powerview_fwversion(): - """Return the fwversion fixture.""" - return json.loads(load_fixture("hunterdouglas_powerview/fwversion.json")) - - -@pytest.fixture(scope="session") -def powerview_scenes(): - """Return the scenes fixture.""" - return json.loads(load_fixture("hunterdouglas_powerview/scenes.json")) +from tests.common import load_json_object_fixture, load_json_value_fixture @pytest.fixture -def mock_powerview_v2_hub(powerview_userdata, powerview_fwversion, powerview_scenes): - """Mock a Powerview v2 hub.""" +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" with patch( - "homeassistant.components.hunterdouglas_powerview.UserData.get_resources", - return_value=powerview_userdata, + "homeassistant.components.hunterdouglas_powerview.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_hunterdouglas_hub( + device_json: str, + home_json: str, + firmware_json: str, + rooms_json: str, + scenes_json: str, + shades_json: str, +) -> Generator[MagicMock, None, None]: + """Return a mocked Powerview Hub with all data populated.""" + with patch( + "homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data", + return_value=load_json_object_fixture(device_json, DOMAIN), + ), patch( + "homeassistant.components.hunterdouglas_powerview.Hub.request_home_data", + return_value=load_json_object_fixture(home_json, DOMAIN), + ), patch( + "homeassistant.components.hunterdouglas_powerview.Hub.request_raw_firmware", + return_value=load_json_object_fixture(firmware_json, DOMAIN), ), patch( "homeassistant.components.hunterdouglas_powerview.Rooms.get_resources", - return_value={"roomData": []}, + return_value=load_json_value_fixture(rooms_json, DOMAIN), ), patch( "homeassistant.components.hunterdouglas_powerview.Scenes.get_resources", - return_value=powerview_scenes, + return_value=load_json_value_fixture(scenes_json, DOMAIN), ), patch( "homeassistant.components.hunterdouglas_powerview.Shades.get_resources", - return_value={"shadeData": []}, + return_value=load_json_value_fixture(shades_json, DOMAIN), ), patch( - "homeassistant.components.hunterdouglas_powerview.ApiEntryPoint", - return_value=powerview_fwversion, + "homeassistant.components.hunterdouglas_powerview.cover.BaseShade.refresh", + ), patch( + "homeassistant.components.hunterdouglas_powerview.cover.BaseShade.current_position", + new_callable=PropertyMock, + return_value=ShadePosition(primary=0, secondary=0, tilt=0, velocity=0), ): yield + + +@pytest.fixture +def device_json(api_version: int) -> str: + """Return the request_raw_data fixture for a specific device.""" + if api_version == 1: + return "gen1/userdata.json" + if api_version == 2: + return "gen2/userdata.json" + if api_version == 3: + return "gen3/gateway/primary.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") + + +@pytest.fixture +def home_json(api_version: int) -> str: + """Return the request_home_data fixture for a specific device.""" + if api_version == 1: + return "gen1/userdata.json" + if api_version == 2: + return "gen2/userdata.json" + if api_version == 3: + return "gen3/home/home.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") + + +@pytest.fixture +def firmware_json(api_version: int) -> str: + """Return the request_raw_firmware fixture for a specific device.""" + if api_version == 1: + return "gen1/fwversion.json" + if api_version == 2: + return "gen2/fwversion.json" + if api_version == 3: + return "gen3/gateway/info.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") + + +@pytest.fixture +def rooms_json(api_version: int) -> str: + """Return the get_resources fixture for a specific device.""" + if api_version == 1: + return "gen2/rooms.json" + if api_version == 2: + return "gen2/rooms.json" + if api_version == 3: + return "gen3/home/rooms.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") + + +@pytest.fixture +def scenes_json(api_version: int) -> str: + """Return the get_resources fixture for a specific device.""" + if api_version == 1: + return "gen2/scenes.json" + if api_version == 2: + return "gen2/scenes.json" + if api_version == 3: + return "gen3/home/scenes.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") + + +@pytest.fixture +def shades_json(api_version: int) -> str: + """Return the get_resources fixture for a specific device.""" + if api_version == 1: + return "gen2/shades.json" + if api_version == 2: + return "gen2/shades.json" + if api_version == 3: + return "gen3/home/shades.json" + # Add more conditions for different api_versions if needed + raise ValueError(f"Unsupported api_version: {api_version}") diff --git a/tests/components/hunterdouglas_powerview/const.py b/tests/components/hunterdouglas_powerview/const.py new file mode 100644 index 00000000000..7254c179edf --- /dev/null +++ b/tests/components/hunterdouglas_powerview/const.py @@ -0,0 +1,98 @@ +"""Constants for Hunter Douglas Powerview tests.""" + +from ipaddress import IPv4Address + +from homeassistant import config_entries +from homeassistant.components import dhcp, zeroconf + +MOCK_MAC = "AA::BB::CC::DD::EE::FF" + +HOMEKIT_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( + ip_address="1.2.3.4", + ip_addresses=[IPv4Address("1.2.3.4")], + hostname="mock_hostname", + name="Powerview Generation 2._hap._tcp.local.", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + type="mock_type", +) + +HOMEKIT_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( + ip_address="1.2.3.4", + ip_addresses=[IPv4Address("1.2.3.4")], + hostname="mock_hostname", + name="Powerview Generation 3._hap._tcp.local.", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + type="mock_type", +) + +ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( + ip_address="1.2.3.4", + ip_addresses=[IPv4Address("1.2.3.4")], + hostname="mock_hostname", + name="Powerview Generation 2._powerview._tcp.local.", + port=None, + properties={}, + type="mock_type", +) + +ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( + ip_address="1.2.3.4", + ip_addresses=[IPv4Address("1.2.3.4")], + hostname="mock_hostname", + name="Powerview Generation 3._powerview-g3._tcp.local.", + port=None, + properties={}, + type="mock_type", +) + +DHCP_DISCOVERY_GEN2 = dhcp.DhcpServiceInfo( + hostname="Powerview Generation 2", + ip="1.2.3.4", + macaddress="aabbccddeeff", +) + +DHCP_DISCOVERY_GEN3 = dhcp.DhcpServiceInfo( + hostname="Powerview Generation 3", + ip="1.2.3.4", + macaddress="aabbccddeeff", +) + +HOMEKIT_DATA = [ + ( + config_entries.SOURCE_HOMEKIT, + HOMEKIT_DISCOVERY_GEN2, + 2, + ), + ( + config_entries.SOURCE_HOMEKIT, + HOMEKIT_DISCOVERY_GEN3, + 3, + ), +] +DHCP_DATA = [ + ( + config_entries.SOURCE_DHCP, + DHCP_DISCOVERY_GEN2, + 2, + ), + ( + config_entries.SOURCE_DHCP, + DHCP_DISCOVERY_GEN3, + 3, + ), +] +ZEROCONF_DATA = [ + ( + config_entries.SOURCE_ZEROCONF, + ZEROCONF_DISCOVERY_GEN2, + 2, + ), + ( + config_entries.SOURCE_ZEROCONF, + ZEROCONF_DISCOVERY_GEN3, + 3, + ), +] +DISCOVERY_DATA = HOMEKIT_DATA + DHCP_DATA + ZEROCONF_DATA diff --git a/tests/components/hunterdouglas_powerview/fixtures/fwversion.json b/tests/components/hunterdouglas_powerview/fixtures/gen1/fwversion.json similarity index 74% rename from tests/components/hunterdouglas_powerview/fixtures/fwversion.json rename to tests/components/hunterdouglas_powerview/fixtures/gen1/fwversion.json index 05fd878ddc6..6bfa8e99001 100644 --- a/tests/components/hunterdouglas_powerview/fixtures/fwversion.json +++ b/tests/components/hunterdouglas_powerview/fixtures/gen1/fwversion.json @@ -1,7 +1,7 @@ { "firmware": { "mainProcessor": { - "name": "PowerView Hub", + "name": "Powerview Generation 1", "revision": 1, "subRevision": 1, "build": 857 diff --git a/tests/components/hunterdouglas_powerview/fixtures/userdata_v1.json b/tests/components/hunterdouglas_powerview/fixtures/gen1/userdata.json similarity index 76% rename from tests/components/hunterdouglas_powerview/fixtures/userdata_v1.json rename to tests/components/hunterdouglas_powerview/fixtures/gen1/userdata.json index 643efc059e3..132e2721b05 100644 --- a/tests/components/hunterdouglas_powerview/fixtures/userdata_v1.json +++ b/tests/components/hunterdouglas_powerview/fixtures/gen1/userdata.json @@ -5,7 +5,7 @@ "sceneControllerCount": 0, "accessPointCount": 0, "shadeCount": 5, - "ip": "192.168.20.9", + "ip": "192.168.0.20", "groupCount": 9, "scheduledEventCount": 0, "editingEnabled": true, @@ -14,21 +14,21 @@ "sceneCount": 18, "sceneControllerMemberCount": 0, "mask": "255.255.255.0", - "hubName": "UG93ZXJWaWV3IEh1YiBHZW4gMQ==", + "hubName": "UG93ZXJ2aWV3IEdlbmVyYXRpb24gMQ==", "rfID": "0x8B2A", "remoteConnectEnabled": false, "multiSceneMemberCount": 0, "rfStatus": 0, - "serialNumber": "REMOVED", + "serialNumber": "A1B2C3D4E5G6H7", "undefinedShadeCount": 0, "sceneMemberCount": 18, "unassignedShadeCount": 0, "multiSceneCount": 0, "addressKind": "newPrimary", - "gateway": "192.168.20.1", + "gateway": "192.168.0.1", "localTimeDataSet": true, - "dns": "192.168.20.1", - "macAddress": "00:00:00:00:00:eb", + "dns": "192.168.0.1", + "macAddress": "AA:BB:CC:DD:EE:FF", "rfIDInt": 35626 } } diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/fwversion.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/fwversion.json new file mode 100644 index 00000000000..02dac8c1afe --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/fwversion.json @@ -0,0 +1,15 @@ +{ + "firmware": { + "mainProcessor": { + "name": "Powerview Generation 2", + "revision": 2, + "subRevision": 0, + "build": 1056 + }, + "radio": { + "revision": 2, + "subRevision": 0, + "build": 2610 + } + } +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/repeaters.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/repeaters.json new file mode 100644 index 00000000000..ee19c96f952 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/repeaters.json @@ -0,0 +1,41 @@ +{ + "repeaterIds": [36882, 46633, 56803], + "repeaterData": [ + { + "id": 36882, + "blinkEnabled": true, + "roomId": 57323, + "groupId": 44797, + "name": "UmVwZWF0ZXIgSGFsbHdheQ==", + "name_unicode": "Repeater Hallway", + "color": { + "brightness": 100, + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "id": 46633, + "blinkEnabled": true, + "roomId": 57323, + "groupId": 44797, + "name": "UmVwZWF0ZXIgTWFzdGVyIEJlZHJvb20=", + "name_unicode": "Repeater Master Bedroom" + }, + { + "id": 56803, + "blinkEnabled": true, + "roomId": 57323, + "groupId": 44797, + "name": "UmVwZWF0ZXIgS2l0Y2hlbg==", + "name_unicode": "Repeater Kitchen", + "color": { + "green": 0, + "red": 255, + "blue": 0, + "brightness": 100 + } + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/rooms.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/rooms.json new file mode 100644 index 00000000000..912c5e03302 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/rooms.json @@ -0,0 +1,134 @@ +{ + "roomIds": [ + 9910, 3304, 24002, 57323, 64218, 2030, 58286, 23884, 61856, 9996, 46225, + 36919, 34870, 34274 + ], + "roomData": [ + { + "order": 8, + "name": "S2l0Y2hlbg==", + "colorId": 9, + "iconId": 38, + "type": 0, + "id": 9910, + "name_unicode": "Kitchen" + }, + { + "order": 6, + "name": "U3R1ZHk=", + "colorId": 10, + "iconId": 73, + "type": 0, + "id": 3304, + "name_unicode": "Study" + }, + { + "order": 4, + "name": "QmVkbW9vciAtIDQ=", + "colorId": 9, + "iconId": 91, + "type": 0, + "id": 24002, + "name_unicode": "Bedroom - 4" + }, + { + "type": 1, + "name": "UmVwZWF0ZXJz", + "colorId": 15, + "iconId": 0, + "order": 9, + "id": 57323, + "name_unicode": "Repeaters" + }, + { + "order": 12, + "name": "TGF1bmRyeQ==", + "colorId": 3, + "iconId": 50, + "type": 0, + "id": 64218, + "name_unicode": "Laundry" + }, + { + "order": 1, + "name": "QmVkcm9vbSAtIE1hc3Rlcg==", + "colorId": 14, + "iconId": 12, + "type": 0, + "id": 2030, + "name_unicode": "Bedroom - Master" + }, + { + "order": 5, + "name": "TG91bmdlIFJvb20=", + "colorId": 14, + "iconId": 59, + "type": 0, + "id": 58286, + "name_unicode": "Lounge Room" + }, + { + "type": 2, + "name": "RGVmYXVsdCBSb29t", + "colorId": 15, + "iconId": 168, + "order": 0, + "id": 23884, + "name_unicode": "Default Room" + }, + { + "order": 2, + "name": "QmVkbW9vciAtIDI=", + "colorId": 11, + "iconId": 91, + "type": 0, + "id": 61856, + "name_unicode": "Bedroom - 2" + }, + { + "order": 10, + "name": "R2FyYWdl", + "colorId": 4, + "iconId": 0, + "type": 0, + "id": 9996, + "name_unicode": "Garage" + }, + { + "order": 3, + "name": "QmVkbW9vciAtIDM=", + "colorId": 7, + "iconId": 91, + "type": 0, + "id": 46225, + "name_unicode": "Bedroom - 3" + }, + { + "order": 13, + "name": "Q29tbW9u", + "colorId": 3, + "iconId": 50, + "type": 0, + "id": 36919, + "name_unicode": "Common" + }, + { + "order": 11, + "name": "UnVtcHVz", + "colorId": 3, + "iconId": 50, + "type": 0, + "id": 34870, + "name_unicode": "Rumpus" + }, + { + "order": 7, + "name": "RmFtaWx5IFJvb20=", + "colorId": 8, + "iconId": 17, + "type": 0, + "id": 34274, + "name_unicode": "Family Room" + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/scenemembers.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/scenemembers.json new file mode 100644 index 00000000000..9ef2cce995c --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/scenemembers.json @@ -0,0 +1,551 @@ +{ + "sceneMemberIds": [ + 64407, 13542, 43466, 881, 51360, 27003, 32733, 58908, 41516, 52855, 2125, + 18781, 2697, 59661, 58301, 38062, 46934, 60041, 56318, 42923, 19317, 62337, + 17806, 64046, 61754, 35188, 57585, 44607, 5621, 64848, 5692, 41162, 56783, + 38058, 46346, 6358, 61891, 12137, 45552, 57019, 20718, 43661, 58875, 53326, + 61328, 400, 45652, 52292, 19246, 30009 + ], + "sceneMemberData": [ + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 64407, + "sceneId": 61648, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 13542, + "sceneId": 14067, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 43466, + "sceneId": 24626, + "shadeId": 40836, + "type": 0 + }, + { + "positions": { + "position1": 0, + "posKind1": 1 + }, + "id": 881, + "sceneId": 28856, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 40, + "posKind2": 2, + "position2": 9679 + }, + "id": 51360, + "sceneId": 61648, + "shadeId": 17062, + "type": 0 + }, + { + "positions": { + "position1": 0, + "posKind1": 1 + }, + "id": 27003, + "sceneId": 48043, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 32733, + "sceneId": 36482, + "shadeId": 65396, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 58908, + "sceneId": 61648, + "shadeId": 13542, + "type": 0 + }, + { + "positions": { + "position1": 0, + "posKind1": 1 + }, + "id": 41516, + "sceneId": 48043, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 52855, + "sceneId": 61648, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 2125, + "sceneId": 36482, + "shadeId": 13542, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 18781, + "sceneId": 44767, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 30334 + }, + "id": 2697, + "sceneId": 959, + "shadeId": 40836, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 59661, + "sceneId": 48756, + "shadeId": 13542, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 58301, + "sceneId": 24626, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 27 + }, + "id": 38062, + "sceneId": 24626, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 40, + "posKind2": 2, + "position2": 0 + }, + "id": 46934, + "sceneId": 24626, + "shadeId": 13028, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 60041, + "sceneId": 48043, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 56318, + "sceneId": 24626, + "shadeId": 5359, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 16 + }, + "id": 42923, + "sceneId": 24626, + "shadeId": 13542, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 19317, + "sceneId": 59103, + "shadeId": 40836, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 180, + "posKind2": 2, + "position2": 6300 + }, + "id": 62337, + "sceneId": 19525, + "shadeId": 13028, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 44624 + }, + "id": 17806, + "sceneId": 61648, + "shadeId": 40836, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 64046, + "sceneId": 49070, + "shadeId": 5359, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 160 + }, + "id": 61754, + "sceneId": 25458, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "position1": 65535, + "posKind1": 1 + }, + "id": 35188, + "sceneId": 61648, + "shadeId": 40458, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 57585, + "sceneId": 61648, + "shadeId": 65396, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 44607, + "sceneId": 64679, + "shadeId": 40458, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 5621, + "sceneId": 59188, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 64848, + "sceneId": 61648, + "shadeId": 5359, + "type": 0 + }, + { + "positions": { + "position1": 0, + "posKind1": 1, + "posKind2": 2, + "position2": 46745 + }, + "id": 5692, + "sceneId": 61648, + "shadeId": 49782, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 41162, + "sceneId": 61648, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 56783, + "sceneId": 6789, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 47, + "posKind2": 2, + "position2": 9570 + }, + "id": 38058, + "sceneId": 22498, + "shadeId": 17062, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 46346, + "sceneId": 24626, + "shadeId": 40458, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 6358, + "sceneId": 3455, + "shadeId": 49782, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 23 + }, + "id": 61891, + "sceneId": 24626, + "shadeId": 65396, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 67 + }, + "id": 12137, + "sceneId": 24626, + "shadeId": 37688, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 45552, + "sceneId": 3455, + "shadeId": 13028, + "type": 0 + }, + { + "positions": { + "position1": 65535, + "posKind1": 1 + }, + "id": 57019, + "sceneId": 59188, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "id": 20718, + "sceneId": 24626, + "shadeId": 49782, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 65535 + }, + "id": 43661, + "sceneId": 59188, + "shadeId": 49988, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 58875, + "sceneId": 48756, + "shadeId": 65396, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0 + }, + "id": 53326, + "sceneId": 51159, + "shadeId": 40458, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 480 + }, + "id": 61328, + "sceneId": 24626, + "shadeId": 17062, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 480 + }, + "id": 400, + "sceneId": 49070, + "shadeId": 17062, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 8, + "posKind2": 2, + "position2": 6361 + }, + "id": 45652, + "sceneId": 19525, + "shadeId": 49782, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 16, + "posKind2": 2, + "position2": 47522 + }, + "id": 52292, + "sceneId": 61648, + "shadeId": 13028, + "type": 0 + }, + { + "positions": { + "posKind1": 1, + "position1": 64138 + }, + "id": 19246, + "sceneId": 59968, + "shadeId": 26355, + "type": 0 + }, + { + "positions": { + "posKind2": 2, + "position1": 0, + "position2": 0, + "posKind1": 1 + }, + "id": 30009, + "sceneId": 22498, + "shadeId": 5359, + "type": 0 + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/scenes.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/scenes.json new file mode 100644 index 00000000000..6292c3dce9a --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/scenes.json @@ -0,0 +1,206 @@ +{ + "sceneIds": [ + 49070, 44767, 6789, 3455, 36482, 59968, 19525, 14067, 48756, 59103, 61648, + 24626, 64679, 22498, 28856, 25458, 51159, 959 + ], + "sceneData": [ + { + "roomId": 58286, + "name": "Q2xvc2UgTG91bmdlIFJvb20=", + "name_unicode": "Close Lounge Room", + "colorId": 14, + "iconId": 59, + "networkNumber": 8, + "id": 49070, + "order": 18, + "hkAssist": false + }, + { + "roomId": 24002, + "name": "Q2xvc2UgQmVkIDQ=", + "name_unicode": "Close Bed 4", + "colorId": 9, + "iconId": 91, + "networkNumber": 20, + "id": 44767, + "order": 8, + "hkAssist": false + }, + { + "roomId": 61856, + "name": "Q2xvc2UgQmVkIDI=", + "name_unicode": "Close Bed 2", + "colorId": 11, + "iconId": 91, + "networkNumber": 12, + "id": 6789, + "order": 6, + "hkAssist": false + }, + { + "roomId": 2030, + "name": "Q2xvc2UgTWFzdGVyIEJlZA==", + "name_unicode": "Close Master Bed", + "colorId": 14, + "iconId": 12, + "networkNumber": 19, + "id": 3455, + "order": 5, + "hkAssist": false + }, + { + "roomId": 34274, + "name": "Q2xvc2UgRmFtaWx5", + "name_unicode": "Close Family", + "colorId": 8, + "iconId": 17, + "networkNumber": 28, + "id": 36482, + "order": 15, + "hkAssist": false + }, + { + "roomId": 24002, + "name": "T3BlbiBCZWQgNA==", + "name_unicode": "Open Bed 4", + "colorId": 9, + "iconId": 91, + "networkNumber": 16, + "id": 59968, + "order": 11, + "hkAssist": false + }, + { + "roomId": 2030, + "name": "T3BlbiBNYXN0ZXIgQmVk", + "name_unicode": "Open Master Bed", + "colorId": 14, + "iconId": 12, + "networkNumber": 7, + "id": 19525, + "order": 2, + "hkAssist": false + }, + { + "roomId": 46225, + "name": "T3BlbiBCZWQgMw==", + "name_unicode": "Open Bed 3", + "colorId": 7, + "iconId": 91, + "networkNumber": 17, + "id": 14067, + "order": 12, + "hkAssist": false + }, + { + "roomId": 34274, + "name": "T3BlbiBGYW1pbHk=", + "name_unicode": "Open Family", + "colorId": 8, + "iconId": 17, + "networkNumber": 10, + "id": 48756, + "order": 10, + "hkAssist": false + }, + { + "roomId": 3304, + "name": "Q2xvc2UgU3R1ZHk=", + "name_unicode": "Close Study", + "colorId": 10, + "iconId": 73, + "networkNumber": 14, + "id": 59103, + "order": 17, + "hkAssist": false + }, + { + "roomId": 23884, + "name": "T3BlbiBBbGw=", + "name_unicode": "Open All", + "colorId": 11, + "iconId": 0, + "networkNumber": 29, + "id": 61648, + "order": 3, + "hkAssist": false + }, + { + "roomId": 23884, + "name": "Q2xvc2UgQWxs", + "name_unicode": "Close All", + "colorId": 0, + "iconId": 0, + "networkNumber": 24, + "id": 24626, + "order": 4, + "hkAssist": false + }, + { + "roomId": 9910, + "name": "T3BlbiBLaXRjaGVu", + "name_unicode": "Open Kitchen", + "colorId": 9, + "iconId": 38, + "networkNumber": 21, + "id": 64679, + "order": 9, + "hkAssist": false + }, + { + "roomId": 58286, + "name": "T3BlbiBMb3VuZ2UgUm9vbQ==", + "name_unicode": "Open Lounge Room", + "colorId": 14, + "iconId": 59, + "networkNumber": 3, + "id": 22498, + "order": 19, + "hkAssist": false + }, + { + "roomId": 61856, + "name": "T3BlbiBCZWQgMg==", + "name_unicode": "Open Bed 2", + "colorId": 11, + "iconId": 91, + "networkNumber": 30, + "id": 28856, + "order": 13, + "hkAssist": false + }, + { + "roomId": 46225, + "name": "Q2xvc2UgQmVkIDM=", + "name_unicode": "Close Bed 3", + "colorId": 7, + "iconId": 91, + "networkNumber": 5, + "id": 25458, + "order": 7, + "hkAssist": false + }, + { + "roomId": 9910, + "name": "Q2xvc2UgS2l0Y2hlbg==", + "name_unicode": "Close Kitchen", + "colorId": 9, + "iconId": 38, + "networkNumber": 18, + "id": 51159, + "order": 14, + "hkAssist": false + }, + { + "roomId": 3304, + "name": "T3BlbiBTdHVkeQ==", + "name_unicode": "Open Study", + "colorId": 10, + "iconId": 73, + "networkNumber": 23, + "id": 959, + "order": 16, + "hkAssist": false + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/scheduledevents.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/scheduledevents.json new file mode 100644 index 00000000000..21ee054a73a --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/scheduledevents.json @@ -0,0 +1,188 @@ +{ + "scheduledEventIds": [ + 44184, 55249, 61633, 3443, 20621, 37484, 38971, 6269, 38424, 26333, 46810, + 20372 + ], + "scheduledEventData": [ + { + "enabled": false, + "sceneId": 59968, + "daySunday": false, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": false, + "eventType": 0, + "hour": 7, + "minute": 0, + "id": 44184 + }, + { + "enabled": false, + "sceneId": 24626, + "daySunday": true, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": true, + "eventType": 2, + "hour": 0, + "minute": 0, + "id": 55249 + }, + { + "enabled": false, + "sceneId": 25458, + "daySunday": true, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": false, + "daySaturday": true, + "eventType": 2, + "hour": 0, + "minute": -123, + "id": 61633 + }, + { + "enabled": false, + "sceneId": 49070, + "daySunday": false, + "dayMonday": false, + "dayTuesday": false, + "dayWednesday": true, + "dayThursday": false, + "dayFriday": true, + "daySaturday": false, + "eventType": 1, + "hour": 0, + "minute": -52, + "id": 3443 + }, + { + "enabled": false, + "sceneId": 59103, + "daySunday": false, + "dayMonday": false, + "dayTuesday": true, + "dayWednesday": false, + "dayThursday": false, + "dayFriday": true, + "daySaturday": false, + "eventType": 2, + "hour": 0, + "minute": 203, + "id": 20621 + }, + { + "enabled": true, + "sceneId": 64679, + "daySunday": true, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": true, + "eventType": 0, + "hour": 7, + "minute": 0, + "id": 37484 + }, + { + "enabled": false, + "sceneId": 14067, + "daySunday": false, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": false, + "eventType": 0, + "hour": 7, + "minute": 0, + "id": 38971 + }, + { + "enabled": false, + "sceneId": 19525, + "daySunday": true, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": true, + "eventType": 0, + "hour": 10, + "minute": 0, + "id": 6269 + }, + { + "enabled": false, + "sceneId": 3455, + "daySunday": true, + "dayMonday": false, + "dayTuesday": false, + "dayWednesday": false, + "dayThursday": false, + "dayFriday": false, + "daySaturday": true, + "eventType": 0, + "hour": 20, + "minute": 0, + "id": 38424 + }, + { + "enabled": false, + "sceneId": 59968, + "daySunday": true, + "dayMonday": false, + "dayTuesday": false, + "dayWednesday": false, + "dayThursday": false, + "dayFriday": false, + "daySaturday": false, + "eventType": 1, + "hour": 0, + "minute": 0, + "id": 26333 + }, + { + "enabled": false, + "sceneId": 59968, + "daySunday": false, + "dayMonday": false, + "dayTuesday": false, + "dayWednesday": false, + "dayThursday": true, + "dayFriday": false, + "daySaturday": false, + "eventType": 0, + "hour": 8, + "minute": 0, + "id": 46810 + }, + { + "enabled": false, + "sceneId": 36482, + "daySunday": true, + "dayMonday": true, + "dayTuesday": true, + "dayWednesday": true, + "dayThursday": true, + "dayFriday": true, + "daySaturday": true, + "eventType": 1, + "hour": 0, + "minute": 419, + "id": 20372 + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/shades.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/shades.json new file mode 100644 index 00000000000..6852c37d883 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/shades.json @@ -0,0 +1,422 @@ +{ + "shadeIds": [ + 49782, 13542, 6539, 37688, 5359, 26355, 13028, 65396, 40458, 17062, 40836, + 49988 + ], + "shadeData": [ + { + "id": 13542, + "type": 6, + "batteryStatus": 0, + "batteryStrength": 0, + "roomId": 34274, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "RmFtaWx5IExlZnQ=", + "groupId": 11497, + "signalStrength": 2, + "capabilities": 0, + "batteryKind": "unassigned", + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0 + }, + "name_unicode": "Family Left", + "shade_api_class": "ShadeBottomUp" + }, + { + "id": 6539, + "type": 44, + "batteryStatus": 0, + "batteryStrength": 0, + "roomId": 34274, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "RmFtaWx5IENlbnRyZQ==", + "groupId": 11497, + "signalStrength": 2, + "capabilities": 0, + "batteryKind": "unassigned", + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 3, + "position3": 0 + }, + "name_unicode": "Family Centre", + "shade_api_class": "ShadeBottomUpTiltOnClosed180" + }, + { + "id": 65396, + "type": 18, + "batteryStatus": 3, + "batteryStrength": 168, + "roomId": 34274, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "RmFtaWx5IFJpZ2h0", + "groupId": 11497, + "signalStrength": 4, + "capabilities": 1, + "batteryKind": 1, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 3, + "position3": 0 + }, + "name_unicode": "Family Right", + "shade_api_class": "ShadeBottomUpTiltOnClosed90" + }, + { + "id": 37688, + "type": 51, + "batteryStatus": 3, + "batteryStrength": 169, + "roomId": 61856, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "QmVkIDI=", + "groupId": 47813, + "signalStrength": 2, + "capabilities": 2, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 3, + "position3": 0 + }, + "name_unicode": "Bed 2", + "shade_api_class": "ShadeBottomUpTiltAnywhere" + }, + { + "id": 49988, + "type": 71, + "batteryStatus": 3, + "batteryStrength": 169, + "roomId": 46225, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "QmVkIDM=", + "groupId": 30562, + "signalStrength": 4, + "capabilities": 3, + "batteryKind": 3, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0 + }, + "name_unicode": "Bed 3", + "shade_api_class": "ShadeVertical" + }, + { + "id": 26355, + "type": 56, + "batteryStatus": 3, + "batteryStrength": 168, + "roomId": 24002, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 336 + }, + "name": "QmVkIDQ=", + "groupId": 15150, + "signalStrength": 2, + "capabilities": 4, + "batteryKind": "unassigned", + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 1, + "position2": 0 + }, + "name_unicode": "Bed 4", + "shade_api_class": "ShadeVerticalTiltAnywhere" + }, + { + "id": 17062, + "type": 66, + "capabilities": 5, + "batteryKind": 3, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "batteryStatus": 3, + "batteryStrength": 184, + "roomId": 58286, + "name": "TG91bmdlIFJvb20gTGVmdA==", + "groupId": 32458, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 366 + }, + "positions": { + "posKind1": 3, + "position1": 0 + }, + "signalStrength": 4, + "name_unicode": "Lounge Room Left", + "shade_api_class": "ShadeTiltOnly" + }, + { + "id": 5359, + "type": 7, + "capabilities": 6, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "batteryStatus": 3, + "batteryStrength": 182, + "roomId": 58286, + "name": "TG91bmdlIFJvb20gUmlnaHQ=", + "groupId": 32458, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "signalStrength": 4, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 366 + }, + "name_unicode": "Lounge Room Right", + "shade_api_class": "ShadeTopDown" + }, + { + "id": 49782, + "type": 8, + "batteryStatus": 3, + "batteryStrength": 183, + "roomId": 2030, + "name": "TWFzdGVyIExlZnQ=", + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "groupId": 44256, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 366 + }, + "signalStrength": 2, + "capabilities": 7, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "name_unicode": "Master Left", + "shade_api_class": "ShadeTopDownBottomUp" + }, + { + "id": 13028, + "type": 79, + "batteryStatus": 3, + "batteryStrength": 183, + "roomId": 2030, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 366 + }, + "name": "TWFzdGVyIFJpZ2h0", + "groupId": 44256, + "signalStrength": 4, + "capabilities": 8, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "name_unicode": "Master Right", + "shade_api_class": "ShadeDualOverlapped" + }, + { + "id": 40458, + "type": 38, + "batteryStatus": 0, + "batteryStrength": 0, + "roomId": 9910, + "firmware": { + "revision": 1, + "subRevision": 4, + "build": 1701 + }, + "name": "S2l0Y2hlbiBSb2xsZXI=", + "motor": { + "revision": 48, + "subRevision": 50, + "build": 11825 + }, + "groupId": 37320, + "signalStrength": 2, + "capabilities": 9, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "name_unicode": "Kitchen Roller", + "shade_api_class": "ShadeDualOverlappedTilt90" + }, + { + "id": 40836, + "type": 999, + "capabilities": 10, + "batteryKind": 2, + "smartPowerSupply": { + "status": 0, + "id": 0, + "port": 0 + }, + "batteryStatus": 3, + "batteryStrength": 183, + "roomId": 3304, + "firmware": { + "revision": 1, + "subRevision": 8, + "build": 1944 + }, + "motor": { + "revision": 0, + "subRevision": 0, + "build": 366 + }, + "name": "U3R1ZHkgRHVldHRl", + "groupId": 41844, + "positions": { + "posKind1": 1, + "position1": 0, + "posKind2": 2, + "position2": 0 + }, + "signalStrength": 4, + "name_unicode": "Study Duette", + "shade_api_class": "ShadeDualOverlappedTilt180" + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen2/userdata.json b/tests/components/hunterdouglas_powerview/fixtures/gen2/userdata.json new file mode 100644 index 00000000000..86bce261b34 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen2/userdata.json @@ -0,0 +1,53 @@ +{ + "userData": { + "hubName": "UG93ZXJ2aWV3IEdlbmVyYXRpb24gMg==", + "localTimeDataSet": true, + "enableScheduledEvents": true, + "editingEnabled": true, + "setupCompleted": false, + "gateway": "192.168.0.1", + "dns": "192.168.0.1", + "staticIp": false, + "_id": "zX7RBfmotYK8LwcD", + "color": { + "red": 0, + "green": 0, + "blue": 255, + "brightness": 50 + }, + "autoBackup": true, + "ip": "192.168.0.21", + "macAddress": "AA:BB:CC:DD:EE:FF", + "mask": "255.255.255.0", + "firmware": { + "mainProcessor": { + "name": "Powerview Generation 2", + "revision": 2, + "subRevision": 0, + "build": 1056 + }, + "radio": { + "revision": 2, + "subRevision": 0, + "build": 2610 + } + }, + "serialNumber": "A1B2C3D4E5G6H7", + "rfIDInt": 59742, + "rfID": "0xE95E", + "rfStatus": 0, + "ssid": "Hub-E5:G6:H7", + "wireless": false, + "times": { + "timezone": "Australia/Sydney", + "localSunriseTimeInMinutes": 372, + "localSunsetTimeInMinutes": 1071, + "currentOffset": 36000, + "longitude": 149.1244, + "latitude": -35.3081 + }, + "rcUp": true, + "brand": "HD", + "remoteConnectEnabled": true + } +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/info.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/info.json new file mode 100644 index 00000000000..c198b90633c --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/info.json @@ -0,0 +1,4 @@ +{ + "fwVersion": "3.1.472", + "serialNumber": "A1B2C3D4E5G6H7" +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/primary.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/primary.json new file mode 100644 index 00000000000..ae88b6b8cc9 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/primary.json @@ -0,0 +1,79 @@ +{ + "config": { + "rev": 1, + "hwVersion": "3.0.4", + "model": "Pro", + "brand": "HD", + "serialNumber": "A1B2C3D4E5G6H7", + "firmware": { + "mainProcessor": { + "revision": 3, + "subRevision": 1, + "build": 472, + "name": "Powerview Generation 3" + }, + "radios": [ + { + "revision": 3, + "subRevision": 0, + "build": 39 + }, + { + "revision": 3, + "subRevision": 0, + "build": 39 + } + ] + }, + "rangeMapper": { + "minRSSI": -90, + "levelingSeconds": 4 + }, + "homeAssoc": 60325, + "color": { + "red": 0, + "green": 0, + "blue": 255, + "brightness": 50 + }, + "cloudConfig": { + "homeId": "G3pH6kP87QX5rWFD2vL", + "gatewayId": 385, + "collection": "homes", + "cloudEnv": 1 + }, + "networkConfig": { + "staticIpEnabled": false, + "staticIp": { + "ip_address": "0.0.0.0", + "gateway_ip": "0.0.0.0", + "netmask": "0.0.0.0", + "dns": ["0.0.0.0"] + } + }, + "networkStatus": { + "ipAddress": "192.168.0.20", + "activeInterface": "eth0", + "primaryMacAddress": "AA:BB:CC:DD:EE:FF", + "dns": [], + "eth0": { + "name": "eth0", + "ip_address": "192.168.0.20", + "mac_address": "AA:BB:CC:DD:EE:FF", + "gateway_ip": "192.168.0.1", + "netmask": "255.255.255.0", + "type": "Wired" + }, + "wlan0": {}, + "internetState": "Connected", + "ssid": "Hub-E5:G6:H7" + }, + "mgwConfig": { + "primary": true + }, + "mgwStatus": { + "running": true + }, + "ip": "192.168.0.20" + } +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/secondary.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/secondary.json new file mode 100644 index 00000000000..40061bb91fa --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/gateway/secondary.json @@ -0,0 +1,79 @@ +{ + "config": { + "rev": 1, + "hwVersion": "3.0.4", + "model": "Pro", + "brand": "HD", + "serialNumber": "Z9Y8X7W6V5U4T3", + "firmware": { + "mainProcessor": { + "revision": 3, + "subRevision": 1, + "build": 472, + "name": "Powerview Generation 3" + }, + "radios": [ + { + "revision": 3, + "subRevision": 0, + "build": 39 + }, + { + "revision": 3, + "subRevision": 0, + "build": 39 + } + ] + }, + "rangeMapper": { + "minRSSI": -90, + "levelingSeconds": 4 + }, + "homeAssoc": 60325, + "color": { + "red": 0, + "green": 0, + "blue": 255, + "brightness": 50 + }, + "cloudConfig": { + "homeId": "G3pH6kP87QX5rWFD2vL", + "gatewayId": 431, + "collection": "homes", + "cloudEnv": 1 + }, + "networkConfig": { + "staticIpEnabled": false, + "staticIp": { + "ip_address": "0.0.0.0", + "gateway_ip": "0.0.0.0", + "netmask": "0.0.0.0", + "dns": ["0.0.0.0"] + } + }, + "networkStatus": { + "ipAddress": "192.168.0.21", + "activeInterface": "eth0", + "primaryMacAddress": "GG:HH:II:JJ:KK:LL", + "dns": [], + "eth0": { + "name": "eth0", + "ip_address": "192.168.0.21", + "mac_address": "GG:HH:II:JJ:KK:LL", + "gateway_ip": "192.168.0.1", + "netmask": "255.255.255.0", + "type": "Wired" + }, + "wlan0": {}, + "internetState": "Connected", + "ssid": "Hub-V5:U4:T3" + }, + "mgwConfig": { + "primary": false + }, + "mgwStatus": { + "running": true + }, + "ip": "192.168.0.21" + } +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/home/automations.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/automations.json new file mode 100644 index 00000000000..1bfbce242ca --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/automations.json @@ -0,0 +1,101 @@ +[ + { + "id": 432, + "type": 14, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 195, + "sceneId": 330, + "errorShd_Ids": [] + }, + { + "id": 433, + "type": 10, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 69, + "sceneId": 328, + "errorShd_Ids": [] + }, + { + "id": 434, + "type": 14, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 228, + "sceneId": 314, + "errorShd_Ids": [] + }, + { + "id": 439, + "type": 14, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 136, + "sceneId": 280, + "errorShd_Ids": [] + }, + { + "id": 441, + "type": 10, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 21, + "sceneId": 278, + "errorShd_Ids": [] + }, + { + "id": 443, + "type": 10, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 56, + "sceneId": 299, + "errorShd_Ids": [121] + }, + { + "id": 444, + "type": 10, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 244, + "sceneId": 344, + "errorShd_Ids": [] + }, + { + "id": 445, + "type": 10, + "enabled": true, + "days": 127, + "hour": 0, + "min": 0, + "bleId": 70, + "sceneId": 292, + "errorShd_Ids": [] + }, + { + "id": 437, + "type": 14, + "enabled": true, + "days": 127, + "hour": 1, + "min": 0, + "bleId": 2, + "sceneId": 220, + "errorShd_Ids": [] + } +] diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/home/home.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/home.json new file mode 100644 index 00000000000..939143a72c5 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/home.json @@ -0,0 +1,1294 @@ +{ + "gHome": false, + "aHome": false, + "rooms": [ + { + "icon": "12", + "shades": [ + { + "id": 413, + "type": 6, + "name": "RmFtaWx5IExlZnQ=", + "ptName": "Family Left", + "motion": null, + "capabilities": 0, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -71, + "bleName": "R23:FC42", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUp" + }, + { + "id": 22, + "type": 44, + "name": "RmFtaWx5IENlbnRyZQ==", + "ptName": "Family Centre", + "motion": null, + "capabilities": 0, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:BD25", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltOnClosed180" + }, + { + "id": 6539, + "type": 18, + "name": "RmFtaWx5IFJpZ2h0", + "ptName": "Family Right", + "motion": null, + "capabilities": 1, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:BD25", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltOnClosed90" + } + ], + "name": "Family Room", + "type": 0, + "groups": [], + "color": "4", + "_id": 217 + }, + { + "name": "Bedroom 2", + "groups": [], + "type": 0, + "color": "5", + "_id": 236, + "icon": "7", + "shades": [ + { + "id": 10, + "type": 51, + "name": "QmVkIDI=", + "ptName": "Bed 2", + "motion": null, + "capabilities": 2, + "powerType": 2, + "batteryStatus": 3, + "roomId": 236, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -66, + "bleName": "R23:CE7A", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltAnywhere" + } + ] + }, + { + "icon": "160", + "shades": [ + { + "id": 46, + "type": 71, + "name": "QmVkIDM=", + "ptName": "Bed 3", + "motion": null, + "capabilities": 3, + "powerType": 2, + "batteryStatus": 3, + "roomId": 252, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -72, + "bleName": "R23:77EC", + "shadeGroupIds": [], + "shade_api_class": "ShadeVertical" + } + ], + "color": "11", + "_id": 252, + "groups": [], + "type": 0, + "name": "Bedroom 3" + }, + { + "icon": "116", + "type": 0, + "shades": [ + { + "id": 173, + "type": 56, + "name": "QmVkIDQ=", + "ptName": "Bed 4", + "motion": null, + "capabilities": 4, + "powerType": 2, + "batteryStatus": 3, + "roomId": 277, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -83, + "bleName": "R23:20CC", + "shadeGroupIds": [], + "shade_api_class": "ShadeVerticalTiltAnywhere" + } + ], + "_id": 277, + "color": "12", + "name": "Bedroom 4", + "groups": [] + }, + { + "type": 0, + "color": "7", + "shades": [ + { + "id": 110, + "type": 66, + "name": "TG91bmdlIFJvb20gTGVmdA==", + "ptName": "Lounge Room Left", + "motion": null, + "capabilities": 5, + "powerType": 2, + "batteryStatus": 3, + "roomId": 291, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -71, + "bleName": "R23:9A5B", + "shadeGroupIds": [], + "shade_api_class": "ShadeTiltOnly" + }, + { + "id": 51, + "type": 7, + "name": "TG91bmdlIFJvb20gUmlnaHQ=", + "ptName": "Lounge Room Right", + "motion": null, + "capabilities": 6, + "powerType": 2, + "batteryStatus": 3, + "roomId": 252, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:1043", + "shadeGroupIds": [], + "shade_api_class": "ShadeTopDown" + } + ], + "_id": 291, + "groups": [], + "name": "Lounge Room", + "icon": "54" + }, + { + "color": "1", + "groups": [], + "type": 0, + "shades": [ + { + "id": 118, + "type": 8, + "name": "TWFzdGVyIExlZnQ=", + "ptName": "Master Left", + "motion": null, + "capabilities": 7, + "powerType": 2, + "batteryStatus": 3, + "roomId": 298, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -80, + "bleName": "R23:E2CD", + "shadeGroupIds": [], + "shade_api_class": "ShadeTopDownBottomUp" + }, + { + "id": 13, + "type": 79, + "name": "TWFzdGVyIFJpZ2h0", + "ptName": "Master Right", + "motion": null, + "capabilities": 8, + "powerType": 2, + "batteryStatus": 2, + "roomId": 236, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -60, + "bleName": "R23:AFDD", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlapped" + } + ], + "name": "Master Bedroom", + "icon": "100", + "_id": 298 + }, + { + "color": "6", + "shades": [ + { + "id": 180, + "type": 38, + "name": "S2l0Y2hlbiBSb2xsZXI=", + "ptName": "Kitchen Roller", + "motion": null, + "capabilities": 9, + "powerType": 2, + "batteryStatus": 3, + "roomId": 311, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 359 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:B07F", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlappedTilt90" + } + ], + "name": "Kitchen", + "type": 0, + "groups": [], + "icon": "12", + "_id": 311 + }, + { + "color": "9", + "type": 0, + "_id": 327, + "shades": [ + { + "id": 192, + "type": 999, + "name": "U3R1ZHkgRHVldHRl", + "ptName": "Study Duette", + "motion": null, + "capabilities": 10, + "powerType": 2, + "batteryStatus": 3, + "roomId": 327, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 359 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -66, + "bleName": "R23:E63C", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlappedTilt180" + } + ], + "icon": "123", + "name": "Study", + "groups": [] + }, + { + "color": "15", + "icon": "162", + "type": 2, + "name": "Default Room", + "groups": [], + "_id": 216, + "shades": [] + } + ], + "hkHome": "u", + "modApp": "IOS: com.hunterdouglas.powerview v14970", + "createdDate": "03-03-2023", + "owner": "powerview@homeassistant.com", + "createdBy": "HDIT", + "automations": [ + { + "bleId": 136, + "hour": 0, + "min": 0, + "type": 14, + "_id": 439, + "errorShd_Ids": [], + "days": 127, + "enabled": true, + "scene_Id": 280 + }, + { + "hour": 0, + "_id": 434, + "days": 127, + "min": 0, + "bleId": 228, + "type": 14, + "scene_Id": 314, + "enabled": true, + "errorShd_Ids": [] + }, + { + "_id": 441, + "days": 127, + "scene_Id": 278, + "min": 0, + "hour": 0, + "bleId": 21, + "enabled": true, + "errorShd_Ids": [], + "type": 10 + }, + { + "errorShd_Ids": [], + "_id": 433, + "bleId": 69, + "enabled": true, + "type": 10, + "hour": 0, + "min": 0, + "days": 127, + "scene_Id": 328 + }, + { + "min": 0, + "hour": 0, + "type": 14, + "errorShd_Ids": [], + "days": 127, + "scene_Id": 330, + "enabled": true, + "bleId": 195, + "_id": 432 + }, + { + "type": 10, + "errorShd_Ids": [], + "hour": 0, + "bleId": 70, + "enabled": true, + "scene_Id": 292, + "days": 127, + "min": 0, + "_id": 445 + }, + { + "scene_Id": 344, + "_id": 444, + "bleId": 244, + "hour": 0, + "min": 0, + "enabled": true, + "days": 127, + "type": 10, + "errorShd_Ids": [] + }, + { + "min": 0, + "type": 10, + "_id": 443, + "scene_Id": 299, + "enabled": true, + "bleId": 56, + "days": 127, + "errorShd_Ids": [], + "hour": 0 + }, + { + "scene_Id": 220, + "type": 14, + "hour": 1, + "min": 0, + "_id": 437, + "enabled": true, + "bleId": 2, + "days": 127, + "errorShd_Ids": [] + } + ], + "home": { + "key": "A1B2C3D4E5", + "country": "US", + "name": "Springfield Ranch", + "tz": "America/Chicago", + "gtwsNeedReconfig": false, + "autosEnabled": true, + "loc": { + "latitude": 39.7817, + "longitude": 89.6501 + }, + "power": 2 + }, + "gateways": [ + { + "_id": 431, + "mac": "AA:BB:CC:DD:EE:FF", + "radio2V": "3.0.39", + "model": "Pro", + "serial": "A1B2C3D4E5G6H7", + "name": "Powerview Generation 3", + "v": "3.1.472", + "aux": true, + "ssid": "Hub-E5:G6:H7", + "shd_Ids": [413, 22, 10, 13, 46, 51, 173], + "ip": "192.168.0.20", + "radio1V": "3.0.39" + }, + { + "model": "Pro", + "serial": "Z9Y8X7W6V5U4T3", + "mac": "GG:HH:II:JJ:KK:LL", + "radio2V": "3.0.39", + "ssid": "Hub-V5:U4:T3", + "aux": false, + "_id": 385, + "name": "Powerview Generation 3", + "ip": "192.168.0.21", + "v": "3.1.472", + "radio1V": "3.0.39", + "shd_Ids": [110, 118, 180, 192] + } + ], + "scenes": [ + { + "bleId": 45057, + "room_Id": 291, + "members": [ + { + "shd_Id": 51, + "pos": { + "pos1": 10000 + }, + "_id": 414 + }, + { + "pos": { + "pos1": 10000 + }, + "_id": 219, + "shd_Id": 110 + } + ], + "icon": "183", + "modDate": { + "seconds": 1677890844, + "nanoseconds": 199723000 + }, + "color": "4", + "_id": 218, + "name": "Close Lounge Room" + }, + { + "_id": 220, + "bleId": 45058, + "icon": "185", + "name": "Close Bed 4", + "members": [ + { + "_id": 415, + "shd_Id": 173, + "pos": { + "pos1": 0 + } + } + ], + "modDate": { + "seconds": 1681857815, + "nanoseconds": 275506000 + }, + "room_Id": 277, + "color": "4" + }, + { + "color": "5", + "_id": 237, + "icon": "183", + "members": [ + { + "shd_Id": 236, + "_id": 241, + "pos": { + "pos1": 10000 + } + } + ], + "bleId": 45057, + "modDate": { + "seconds": 1677890844, + "nanoseconds": 199887000 + }, + "room_Id": 277, + "name": "Close Bed 2" + }, + { + "icon": "185", + "name": "Close Master Bed", + "modDate": { + "seconds": 1677890844, + "nanoseconds": 199939000 + }, + "room_Id": 298, + "members": [ + { + "pos": { + "pos1": 0 + }, + "_id": 240, + "shd_Id": 118 + }, + { + "_id": 242, + "pos": { + "pos1": 0 + }, + "shd_Id": 13 + } + ], + "_id": 239, + "color": "5", + "bleId": 45058 + }, + { + "name": "Close Family", + "icon": "183", + "room_Id": 217, + "modDate": { + "seconds": 1677890844, + "nanoseconds": 199985000 + }, + "_id": 253, + "color": "11", + "members": [ + { + "shd_Id": 413, + "pos": { + "pos1": 10000 + }, + "_id": 257 + }, + { + "pos": { + "pos1": 10000 + }, + "shd_Id": 22, + "_id": 254 + }, + { + "pos": { + "pos1": 10000 + }, + "shd_Id": 6539, + "_id": 255 + } + ], + "bleId": 45057 + }, + { + "bleId": 45058, + "room_Id": 277, + "color": "11", + "_id": 255, + "name": "Open Bed 4", + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200057000 + }, + "icon": "185", + "members": [ + { + "_id": 258, + "pos": { + "pos1": 0 + }, + "shd_Id": 173 + } + ] + }, + { + "name": "Open Master Bed", + "icon": "183", + "room_Id": 298, + "_id": 278, + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200124000 + }, + "bleId": 45057, + "members": [ + { + "_id": 279, + "pos": { + "pos1": 10000 + }, + "shd_Id": 118 + }, + { + "_id": 280, + "pos": { + "pos1": 10000 + }, + "shd_Id": 13 + } + ], + "color": "12" + }, + { + "members": [ + { + "_id": 281, + "shd_Id": 46, + "pos": { + "pos1": 0 + } + } + ], + "room_Id": 252, + "name": "Open Bed 3", + "icon": "185", + "color": "12", + "bleId": 45058, + "_id": 280, + "modDate": { + "seconds": 1681857936, + "nanoseconds": 86256000 + } + }, + { + "color": "7", + "members": [ + { + "shd_Id": 413, + "pos": { + "pos1": 10000 + }, + "_id": 293 + }, + { + "shd_Id": 22, + "pos": { + "pos1": 10000 + }, + "_id": 294 + }, + { + "shd_Id": 6539, + "pos": { + "pos1": 10000 + }, + "_id": 295 + } + ], + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200176000 + }, + "room_Id": 217, + "name": "Open Family", + "_id": 292, + "icon": "183", + "bleId": 45057 + }, + { + "name": "Close Study", + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200206000 + }, + "room_Id": 327, + "color": "7", + "icon": "185", + "members": [ + { + "_id": 296, + "pos": { + "pos1": 0 + }, + "shd_Id": 192 + } + ], + "bleId": 45058, + "_id": 294 + }, + { + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200237000 + }, + "name": "Open All", + "color": "1", + "_id": 299, + "room_Id": 298, + "members": [ + { + "_id": 300, + "shd_Id": 10, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 301, + "shd_Id": 13, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 302, + "shd_Id": 22, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 303, + "shd_Id": 46, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 304, + "shd_Id": 51, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 305, + "shd_Id": 110, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 306, + "shd_Id": 118, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 307, + "shd_Id": 173, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 308, + "shd_Id": 180, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 309, + "shd_Id": 192, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 310, + "shd_Id": 413, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 311, + "shd_Id": 6539, + "pos": { + "pos1": 10000 + } + } + ], + "icon": "183", + "bleId": 45057 + }, + { + "name": "Close All", + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200292000 + }, + "members": [ + { + "_id": 312, + "shd_Id": 10, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 313, + "shd_Id": 13, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 314, + "shd_Id": 22, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 315, + "shd_Id": 46, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 316, + "shd_Id": 51, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 317, + "shd_Id": 110, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 318, + "shd_Id": 118, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 319, + "shd_Id": 173, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 320, + "shd_Id": 180, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 321, + "shd_Id": 192, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 322, + "shd_Id": 413, + "pos": { + "pos1": 10000 + } + }, + { + "_id": 323, + "shd_Id": 6539, + "pos": { + "pos1": 10000 + } + } + ], + "bleId": 45058, + "color": "1", + "_id": 301, + "room_Id": 298, + "icon": "185" + }, + { + "members": [ + { + "pos": { + "pos1": 10000 + }, + "shd_Id": 180, + "_id": 324 + } + ], + "name": "Open Kitchen", + "_id": 312, + "room_Id": 311, + "modDate": { + "seconds": 1681857535, + "nanoseconds": 565021000 + }, + "color": "6", + "icon": "183", + "bleId": 45057 + }, + { + "modDate": { + "seconds": 1681857630, + "nanoseconds": 350224000 + }, + "color": "6", + "icon": "185", + "members": [ + { + "_id": 325, + "shd_Id": 110, + "pos": { + "pos1": 0 + } + }, + { + "_id": 326, + "shd_Id": 51, + "pos": { + "pos1": 0 + } + } + ], + "name": "Open Lounge Room", + "bleId": 45058, + "_id": 314, + "room_Id": 291 + }, + { + "icon": "183", + "_id": 328, + "name": "Open Bed 2", + "color": "9", + "members": [ + { + "shd_Id": 236, + "pos": { + "pos1": 10000 + }, + "_id": 329 + } + ], + "modDate": { + "seconds": 1681857204, + "nanoseconds": 252732000 + }, + "bleId": 45057, + "room_Id": 236 + }, + { + "icon": "185", + "room_Id": 252, + "_id": 330, + "modDate": { + "seconds": 1681857210, + "nanoseconds": 22480000 + }, + "bleId": 45058, + "color": "9", + "name": "Close Bed 3", + "members": [ + { + "_id": 331, + "pos": { + "pos1": 0 + }, + "shd_Id": 46 + } + ] + }, + { + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200587000 + }, + "_id": 344, + "name": "Close Kitchen", + "members": [ + { + "_id": 332, + "pos": { + "pos1": 0 + }, + "shd_Id": 180 + } + ], + "bleId": 45057, + "color": "10", + "icon": "183", + "room_Id": 311 + }, + { + "icon": "185", + "name": "Open Study", + "members": [ + { + "_id": 333, + "pos": { + "pos1": 0 + }, + "shd_Id": 192 + } + ], + "bleId": 45058, + "room_Id": 327, + "_id": 346, + "modDate": { + "seconds": 1677890844, + "nanoseconds": 200633000 + }, + "color": "10" + } + ], + "remotes": [ + { + "bleName": "PR:6B48", + "_id": 269, + "v": 74, + "name": "Family Remote", + "members": [ + { + "group": 1, + "_id": 271, + "shd_Id": 413 + }, + { + "group": 1, + "_id": 270, + "shd_Id": 22 + }, + { + "group": 1, + "_id": 271, + "shd_Id": 6539 + } + ], + "accelGlow": true, + "mac": "" + }, + { + "name": "Bed 2 Remote", + "_id": 247, + "v": 74, + "mac": "", + "bleName": "PR:3C4F", + "members": [ + { + "shd_Id": 10, + "_id": 248, + "group": 1 + } + ], + "accelGlow": true + }, + { + "accelGlow": true, + "mac": "", + "_id": 354, + "name": "Bed 3 Remote", + "v": 74, + "members": [ + { + "shd_Id": 46, + "_id": 249, + "group": 1 + } + ], + "bleName": "PR:71FA" + }, + { + "bleName": "PR:A062", + "name": "Master Bedroom Remote", + "mac": "", + "v": 74, + "_id": 230, + "members": [ + { + "shd_Id": 118, + "group": 1, + "_id": 419 + }, + { + "_id": 420, + "group": 2, + "shd_Id": 13 + } + ], + "accelGlow": true + }, + { + "name": "Bed 4 Remote", + "mac": "", + "bleName": "PR:27DD", + "_id": 338, + "accelGlow": true, + "v": 74, + "members": [ + { + "shd_Id": 173, + "_id": 339, + "group": 1 + } + ] + }, + { + "mac": "", + "_id": 322, + "name": "Lounge Room Remote", + "accelGlow": true, + "members": [ + { + "group": 1, + "shd_Id": 110, + "_id": 323 + }, + { + "group": 1, + "shd_Id": 51, + "_id": 324 + } + ], + "bleName": "PR:285F", + "v": 78 + }, + { + "v": 74, + "bleName": "PR:9986", + "name": "Kitchen Remote", + "_id": 370, + "accelGlow": true, + "mac": "", + "members": [ + { + "group": 1, + "shd_Id": 180, + "_id": 325 + } + ] + }, + { + "bleName": "PR:7022", + "mac": "", + "accelGlow": true, + "name": "Study Remote", + "members": [ + { + "group": 1, + "shd_Id": 192, + "_id": 326 + } + ], + "_id": 382, + "v": 74 + } + ], + "_schemaVersion": 31, + "id": "A1B2C3D4E5G6H7", + "modDate": { + "seconds": 1685703057, + "nanoseconds": 772341000 + } +} diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/home/rooms.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/rooms.json new file mode 100644 index 00000000000..4fd5ea8f8a2 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/rooms.json @@ -0,0 +1,83 @@ +[ + { + "id": 217, + "name": "RmFtaWx5IFJvb20=", + "ptName": "Family Room", + "color": "4", + "icon": "12", + "type": 0, + "shadeGroups": [] + }, + { + "id": 236, + "name": "QmVkcm9vbSAy", + "ptName": "Bedroom 2", + "color": "5", + "icon": "7", + "type": 0, + "shadeGroups": [] + }, + { + "id": 252, + "name": "QmVkcm9vbSAz", + "ptName": "Bedroom 3", + "color": "11", + "icon": "160", + "type": 0, + "shadeGroups": [] + }, + { + "id": 277, + "name": "QmVkcm9vbSA0", + "ptName": "Bedroom 4", + "color": "12", + "icon": "116", + "type": 0, + "shadeGroups": [] + }, + { + "id": 291, + "name": "TG91bmdlIFJvb20=", + "ptName": "Lounge Room", + "color": "7", + "icon": "54", + "type": 0, + "shadeGroups": [] + }, + { + "id": 298, + "name": "TWFzdGVyIEJlZG1vcmQ=", + "ptName": "Master Bedroom", + "color": "1", + "icon": "100", + "type": 0, + "shadeGroups": [] + }, + { + "id": 311, + "name": "S2l0Y2hlbg==", + "ptName": "Kitchen", + "color": "6", + "icon": "12", + "type": 0, + "shadeGroups": [] + }, + { + "id": 327, + "name": "U3R1ZHk=", + "ptName": "Study", + "color": "9", + "icon": "123", + "type": 0, + "shadeGroups": [] + }, + { + "id": 216, + "name": "RGVmYXVsdCBSb29t", + "ptName": "Default Room", + "color": "15", + "icon": "162", + "type": 2, + "shadeGroups": [] + } +] diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/home/scenes.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/scenes.json new file mode 100644 index 00000000000..5644cfe14a6 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/scenes.json @@ -0,0 +1,182 @@ +[ + { + "id": 218, + "name": "Q2xvc2UgTG91bmdlIFJvb20=", + "ptName": "Close Lounge Room", + "networkNumber": 45057, + "color": "4", + "icon": "183", + "roomIds": [291], + "shadeIds": [110, 51] + }, + { + "id": 220, + "name": "Q2xvc2UgQmVkIDQ=", + "ptName": "Close Bed 4", + "networkNumber": 45058, + "color": "4", + "icon": "185", + "roomIds": [277], + "shadeIds": [173] + }, + { + "id": 237, + "name": "Q2xvc2UgQmVkIDI=", + "ptName": "Close Bed 2", + "networkNumber": 45057, + "color": "5", + "icon": "183", + "roomIds": [277], + "shadeIds": [236] + }, + { + "id": 239, + "name": "Q2xvc2UgTWFzdGVyIEJlZA==", + "ptName": "Close Master Bed", + "networkNumber": 45058, + "color": "5", + "icon": "185", + "roomIds": [298], + "shadeIds": [118, 13] + }, + { + "id": 253, + "name": "Q2xvc2UgRmFtaWx5", + "ptName": "Close Family", + "networkNumber": 45057, + "color": "11", + "icon": "183", + "roomIds": [217], + "shadeIds": [413, 22, 6539] + }, + { + "id": 255, + "name": "T3BlbiBCZWQgNA==", + "ptName": "Open Bed 4", + "networkNumber": 45058, + "color": "11", + "icon": "185", + "roomIds": [277], + "shadeIds": [173] + }, + { + "id": 278, + "name": "T3BlbiBNYXN0ZXIgQmVk", + "ptName": "Open Master Bed", + "networkNumber": 45057, + "color": "12", + "icon": "183", + "roomIds": [298], + "shadeIds": [118, 13] + }, + { + "id": 280, + "name": "T3BlbiBCZWQgMw==", + "ptName": "Open Bed 3", + "networkNumber": 45058, + "color": "12", + "icon": "185", + "roomIds": [252], + "shadeIds": [46] + }, + { + "id": 292, + "name": "T3BlbiBGYW1pbHk=", + "ptName": "Open Family", + "networkNumber": 45057, + "color": "7", + "icon": "183", + "roomIds": [217], + "shadeIds": [413, 22, 6539] + }, + { + "id": 294, + "name": "Q2xvc2UgU3R1ZHk=", + "ptName": "Close Study", + "networkNumber": 45058, + "color": "7", + "icon": "185", + "roomIds": [327], + "shadeIds": [192] + }, + { + "id": 299, + "name": "T3BlbiBBbGw=", + "ptName": "Open All", + "networkNumber": 45057, + "color": "1", + "icon": "183", + "roomIds": [217, 236, 252, 277, 291, 298, 311, 327, 216], + "shadeIds": [10, 13, 22, 46, 51, 110, 118, 173, 180, 192, 413, 6539] + }, + { + "id": 301, + "name": "Q2xvc2UgQWxs", + "ptName": "Close All", + "networkNumber": 45058, + "color": "1", + "icon": "185", + "roomIds": [217, 236, 252, 277, 291, 298, 311, 327, 216], + "shadeIds": [10, 13, 22, 46, 51, 110, 118, 173, 180, 192, 413, 6539] + }, + { + "id": 312, + "name": "T3BlbiBLaXRjaGVu", + "ptName": "Open Kitchen", + "networkNumber": 45057, + "color": "6", + "icon": "183", + "roomIds": [311], + "shadeIds": [180] + }, + { + "id": 314, + "name": "T3BlbiBMb3VuZ2UgUm9vbQ==", + "ptName": "Open Lounge Room", + "networkNumber": 45058, + "color": "6", + "icon": "185", + "roomIds": [291], + "shadeIds": [110, 51] + }, + { + "id": 328, + "name": "T3BlbiBCZWQgMg==", + "ptName": "Open Bed 2", + "networkNumber": 45057, + "color": "9", + "icon": "183", + "roomIds": [236], + "shadeIds": [236] + }, + { + "id": 330, + "name": "Q2xvc2UgQmVkIDM=", + "ptName": "Close Bed 3", + "networkNumber": 45058, + "color": "9", + "icon": "185", + "roomIds": [252], + "shadeIds": [46] + }, + { + "id": 344, + "name": "Q2xvc2UgS2l0Y2hlbg==", + "ptName": "Close Kitchen", + "networkNumber": 45057, + "color": "10", + "icon": "183", + "roomIds": [311], + "shadeIds": [180] + }, + { + "id": 346, + "name": "T3BlbiBTdHVkeQ==", + "ptName": "Open Study", + "networkNumber": 45058, + "color": "10", + "icon": "185", + "roomIds": [327], + "shadeIds": [192] + } +] diff --git a/tests/components/hunterdouglas_powerview/fixtures/gen3/home/shades.json b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/shades.json new file mode 100644 index 00000000000..ef9fdbd5c61 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/gen3/home/shades.json @@ -0,0 +1,314 @@ +[ + { + "id": 413, + "type": 6, + "name": "RmFtaWx5IExlZnQ=", + "ptName": "Family Left", + "motion": null, + "capabilities": 0, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -71, + "bleName": "R23:FC42", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUp" + }, + { + "id": 22, + "type": 44, + "name": "RmFtaWx5IENlbnRyZQ==", + "ptName": "Family Centre", + "motion": null, + "capabilities": 0, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:BD25", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltOnClosed180" + }, + { + "id": 6539, + "type": 18, + "name": "RmFtaWx5IFJpZ2h0", + "ptName": "Family Right", + "motion": null, + "capabilities": 1, + "powerType": 2, + "batteryStatus": 3, + "roomId": 217, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:BD25", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltOnClosed90" + }, + { + "id": 10, + "type": 51, + "name": "QmVkIDI=", + "ptName": "Bed 2", + "motion": null, + "capabilities": 2, + "powerType": 2, + "batteryStatus": 3, + "roomId": 236, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -66, + "bleName": "R23:CE7A", + "shadeGroupIds": [], + "shade_api_class": "ShadeBottomUpTiltAnywhere" + }, + { + "id": 46, + "type": 71, + "name": "QmVkIDM=", + "ptName": "Bed 3", + "motion": null, + "capabilities": 3, + "powerType": 2, + "batteryStatus": 3, + "roomId": 252, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -72, + "bleName": "R23:77EC", + "shadeGroupIds": [], + "shade_api_class": "ShadeVertical" + }, + { + "id": 173, + "type": 56, + "name": "QmVkIDQ=", + "ptName": "Bed 4", + "motion": null, + "capabilities": 4, + "powerType": 2, + "batteryStatus": 3, + "roomId": 277, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -83, + "bleName": "R23:20CC", + "shadeGroupIds": [], + "shade_api_class": "ShadeVerticalTiltAnywhere" + }, + { + "id": 110, + "type": 66, + "name": "TG91bmdlIFJvb20gTGVmdA==", + "ptName": "Lounge Room Left", + "motion": null, + "capabilities": 5, + "powerType": 2, + "batteryStatus": 3, + "roomId": 291, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -71, + "bleName": "R23:9A5B", + "shadeGroupIds": [], + "shade_api_class": "ShadeTiltOnly" + }, + { + "id": 51, + "type": 7, + "name": "TG91bmdlIFJvb20gUmlnaHQ=", + "ptName": "Lounge Room Right", + "motion": null, + "capabilities": 6, + "powerType": 2, + "batteryStatus": 3, + "roomId": 252, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:1043", + "shadeGroupIds": [], + "shade_api_class": "ShadeTopDown" + }, + { + "id": 118, + "type": 8, + "name": "TWFzdGVyIExlZnQ=", + "ptName": "Master Left", + "motion": null, + "capabilities": 7, + "powerType": 2, + "batteryStatus": 3, + "roomId": 298, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -80, + "bleName": "R23:E2CD", + "shadeGroupIds": [], + "shade_api_class": "ShadeTopDownBottomUp" + }, + { + "id": 13, + "type": 79, + "name": "TWFzdGVyIFJpZ2h0", + "ptName": "Master Right", + "motion": null, + "capabilities": 8, + "powerType": 2, + "batteryStatus": 2, + "roomId": 236, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 309 + }, + "positions": { + "primary": 1, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -60, + "bleName": "R23:AFDD", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlapped" + }, + { + "id": 180, + "type": 38, + "name": "S2l0Y2hlbiBSb2xsZXI=", + "ptName": "Kitchen Roller", + "motion": null, + "capabilities": 9, + "powerType": 2, + "batteryStatus": 3, + "roomId": 311, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 359 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -74, + "bleName": "R23:B07F", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlappedTilt90" + }, + { + "id": 192, + "type": 999, + "name": "U3R1ZHkgRHVldHRl", + "ptName": "Study Duette", + "motion": null, + "capabilities": 10, + "powerType": 2, + "batteryStatus": 3, + "roomId": 327, + "firmware": { + "revision": 3, + "subRevision": 0, + "build": 359 + }, + "positions": { + "primary": 0, + "secondary": 0, + "tilt": 0, + "velocity": 0 + }, + "signalStrength": -66, + "bleName": "R23:E63C", + "shadeGroupIds": [], + "shade_api_class": "ShadeDualOverlappedTilt180" + } +] diff --git a/tests/components/hunterdouglas_powerview/fixtures/userdata.json b/tests/components/hunterdouglas_powerview/fixtures/userdata.json deleted file mode 100644 index 40660915fad..00000000000 --- a/tests/components/hunterdouglas_powerview/fixtures/userdata.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "userData": { - "_id": "abc", - "color": { - "green": 0, - "blue": 255, - "brightness": 5, - "red": 0 - }, - "autoBackup": false, - "ip": "192.168.1.72", - "macAddress": "aa:bb:cc:dd:ee:ff", - "mask": "255.255.255.0", - "gateway": "192.168.1.1", - "dns": "192.168.1.3", - "firmware": { - "mainProcessor": { - "name": "PV Hub2.0", - "revision": 2, - "subRevision": 0, - "build": 1024 - }, - "radio": { - "revision": 2, - "subRevision": 0, - "build": 2610 - } - }, - "serialNumber": "ABC123", - "rfIDInt": 64789, - "rfID": "0xFD15", - "rfStatus": 0, - "brand": "HD", - "wireless": false, - "hubName": "QWxleGFuZGVySEQ=", - "localTimeDataSet": true, - "enableScheduledEvents": true, - "editingEnabled": true, - "setupCompleted": false, - "staticIp": false, - "times": { - "timezone": "America/Chicago", - "localSunriseTimeInMinutes": 0, - "localSunsetTimeInMinutes": 0, - "currentOffset": -18000 - }, - "rcUp": true, - "remoteConnectEnabled": true - } -} diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 321ffce1766..2eaf194ee00 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,187 +1,70 @@ """Test the Hunter Douglas Powerview config flow.""" -from ipaddress import ip_address -import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import MOCK_MAC +from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA -from tests.common import MockConfigEntry, load_fixture - -ZEROCONF_HOST = "1.2.3.4" -HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address(ZEROCONF_HOST), - ip_addresses=[ip_address(ZEROCONF_HOST)], - hostname="mock_hostname", - name="Hunter Douglas Powerview Hub._hap._tcp.local.", - port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, - type="mock_type", -) - -ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address(ZEROCONF_HOST), - ip_addresses=[ip_address(ZEROCONF_HOST)], - hostname="mock_hostname", - name="Hunter Douglas Powerview Hub._powerview._tcp.local.", - port=None, - properties={}, - type="mock_type", -) - -DHCP_DISCOVERY_INFO = dhcp.DhcpServiceInfo( - hostname="Hunter Douglas Powerview Hub", - ip="1.2.3.4", - macaddress="aabbccddeeff", -) - -DISCOVERY_DATA = [ - ( - config_entries.SOURCE_HOMEKIT, - HOMEKIT_DISCOVERY_INFO, - ), - ( - config_entries.SOURCE_DHCP, - DHCP_DISCOVERY_INFO, - ), - (config_entries.SOURCE_ZEROCONF, ZEROCONF_DISCOVERY_INFO), -] +from tests.common import MockConfigEntry, load_json_object_fixture -def _get_mock_powerview_userdata(userdata=None, get_resources=None): - mock_powerview_userdata = MagicMock() - if not userdata: - userdata = json.loads(load_fixture("hunterdouglas_powerview/userdata.json")) - if get_resources: - mock_powerview_userdata.get_resources = AsyncMock(side_effect=get_resources) - else: - mock_powerview_userdata.get_resources = AsyncMock(return_value=userdata) - return mock_powerview_userdata - - -def _get_mock_powerview_legacy_userdata(userdata=None, get_resources=None): - mock_powerview_userdata_legacy = MagicMock() - if not userdata: - userdata = json.loads(load_fixture("hunterdouglas_powerview/userdata_v1.json")) - if get_resources: - mock_powerview_userdata_legacy.get_resources = AsyncMock( - side_effect=get_resources - ) - else: - mock_powerview_userdata_legacy.get_resources = AsyncMock(return_value=userdata) - return mock_powerview_userdata_legacy - - -def _get_mock_powerview_fwversion(fwversion=None, get_resources=None): - mock_powerview_fwversion = MagicMock() - if not fwversion: - fwversion = json.loads(load_fixture("hunterdouglas_powerview/fwversion.json")) - if get_resources: - mock_powerview_fwversion.get_resources = AsyncMock(side_effect=get_resources) - else: - mock_powerview_fwversion.get_resources = AsyncMock(return_value=fwversion) - return mock_powerview_fwversion - - -async def test_user_form(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize("api_version", [1, 2, 3]) +async def test_user_form( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + api_version: int, +) -> None: """Test we get the user form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} - mock_powerview_userdata = _get_mock_powerview_userdata() - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ), patch( - "homeassistant.components.hunterdouglas_powerview.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"host": "1.2.3.4"}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"Powerview Generation {api_version}" + assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result2["result"].unique_id == "A1B2C3D4E5G6H7" - assert result2["type"] == "create_entry" - assert result2["title"] == "AlexanderHD" - assert result2["data"] == { - "host": "1.2.3.4", - } assert len(mock_setup_entry.mock_calls) == 1 result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result3["type"] == "form" + assert result3["type"] == FlowResultType.FORM assert result3["errors"] == {} result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], - {"host": "1.2.3.4"}, + {CONF_HOST: "1.2.3.4"}, ) - assert result4["type"] == "abort" + assert result4["type"] == FlowResultType.ABORT + assert result4["reason"] == "already_configured" -async def test_user_form_legacy(hass: HomeAssistant) -> None: - """Test we get the user form with a legacy device.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - mock_powerview_userdata = _get_mock_powerview_legacy_userdata() - mock_powerview_fwversion = _get_mock_powerview_fwversion() - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ), patch( - "homeassistant.components.hunterdouglas_powerview.ApiEntryPoint", - return_value=mock_powerview_fwversion, - ), patch( - "homeassistant.components.hunterdouglas_powerview.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"host": "1.2.3.4"}, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "PowerView Hub Gen 1" - assert result2["data"] == { - "host": "1.2.3.4", - } - assert len(mock_setup_entry.mock_calls) == 1 - - result3 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result3["type"] == "form" - assert result3["errors"] == {} - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {"host": "1.2.3.4"}, - ) - assert result4["type"] == "abort" - - -@pytest.mark.parametrize(("source", "discovery_info"), DISCOVERY_DATA) +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize(("source", "discovery_info", "api_version"), DISCOVERY_DATA) async def test_form_homekit_and_dhcp_cannot_connect( - hass: HomeAssistant, source, discovery_info + hass: HomeAssistant, + mock_setup_entry: MagicMock, + source: str, + discovery_info: dhcp.DhcpServiceInfo, + api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -190,10 +73,9 @@ async def test_form_homekit_and_dhcp_cannot_connect( ) ignored_config_entry.add_to_hass(hass) - mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError) with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, + "homeassistant.components.hunterdouglas_powerview.Hub.query_firmware", + side_effect=TimeoutError, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -201,13 +83,35 @@ async def test_form_homekit_and_dhcp_cannot_connect( data=discovery_info, ) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "cannot_connect" + # test we can recover from the failed entry + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=discovery_info, + ) -@pytest.mark.parametrize(("source", "discovery_info"), DISCOVERY_DATA) + result3 = await hass.config_entries.flow.async_configure(result2["flow_id"], {}) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == f"Powerview Generation {api_version}" + assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result3["result"].unique_id == "A1B2C3D4E5G6H7" + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize(("source", "discovery_info", "api_version"), DISCOVERY_DATA) async def test_form_homekit_and_dhcp( - hass: HomeAssistant, source, discovery_info + hass: HomeAssistant, + mock_setup_entry: MagicMock, + source: str, + discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo, + api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -216,39 +120,28 @@ async def test_form_homekit_and_dhcp( ) ignored_config_entry.add_to_hass(hass) - mock_powerview_userdata = _get_mock_powerview_userdata() - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": source}, - data=discovery_info, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=discovery_info, + ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "link" assert result["errors"] is None assert result["description_placeholders"] == { - "host": "1.2.3.4", - "name": "Hunter Douglas Powerview Hub", + CONF_HOST: "1.2.3.4", + CONF_NAME: f"Powerview Generation {api_version}", + CONF_API_VERSION: api_version, } - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ), patch( - "homeassistant.components.hunterdouglas_powerview.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == "Hunter Douglas Powerview Hub" - assert result2["data"] == {"host": "1.2.3.4"} - assert result2["result"].unique_id == "ABC123" + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"Powerview Generation {api_version}" + assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result2["result"].unique_id == "A1B2C3D4E5G6H7" assert len(mock_setup_entry.mock_calls) == 1 @@ -257,95 +150,199 @@ async def test_form_homekit_and_dhcp( context={"source": source}, data=discovery_info, ) - assert result3["type"] == "abort" + assert result3["type"] == FlowResultType.ABORT -async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize( + ("homekit_source", "homekit_discovery", "api_version"), HOMEKIT_DATA +) +@pytest.mark.parametrize( + ("dhcp_source", "dhcp_discovery", "dhcp_api_version"), DHCP_DATA +) +async def test_discovered_by_homekit_and_dhcp( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + homekit_source: str, + homekit_discovery: zeroconf.ZeroconfServiceInfo, + api_version: int, + dhcp_source: str, + dhcp_discovery: dhcp.DhcpServiceInfo, + dhcp_api_version: int, +) -> None: """Test we get the form with homekit and abort for dhcp source when we get both.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=homekit_discovery, + ) - mock_powerview_userdata = _get_mock_powerview_userdata() - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=HOMEKIT_DISCOVERY_INFO, - ) - - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "link" - with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DHCP_DISCOVERY_INFO, - ) + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp_discovery, + ) - assert result2["type"] == "abort" + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "already_in_progress" -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize("api_version", [1, 2, 3]) +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + api_version: int, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError) + # Simulate a timeout error with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, + "homeassistant.components.hunterdouglas_powerview.Hub.query_firmware", + side_effect=TimeoutError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.2.3.4"}, + {CONF_HOST: "1.2.3.4"}, ) - assert result2["type"] == "form" + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + # Now try again without the patch in place to make sure we can recover + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) -async def test_form_no_data(hass: HomeAssistant) -> None: + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == f"Powerview Generation {api_version}" + assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result3["result"].unique_id == "A1B2C3D4E5G6H7" + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize("api_version", [1, 2, 3]) +async def test_form_no_data( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + api_version: int, +) -> None: """Test we handle no data being returned from the hub.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}}) with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, + "homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data", + return_value={}, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Hub.request_home_data", + return_value={}, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.2.3.4"}, + {CONF_HOST: "1.2.3.4"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Now try again without the patch in place to make sure we can recover + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == f"Powerview Generation {api_version}" + assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result3["result"].unique_id == "A1B2C3D4E5G6H7" + + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_unknown_exception(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize("api_version", [1, 2, 3]) +async def test_form_unknown_exception( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + api_version: int, +) -> None: """Test we handle unknown exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}}) + # Simulate a transient error with patch( - "homeassistant.components.hunterdouglas_powerview.UserData", - return_value=mock_powerview_userdata, + "homeassistant.components.hunterdouglas_powerview.config_flow.Hub.query_firmware", + side_effect=SyntaxError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.2.3.4"}, + {CONF_HOST: "1.2.3.4"}, ) - assert result2["type"] == "form" + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + + # Now try again without the patch in place to make sure we can recover + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"Powerview Generation {api_version}" + assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result2["result"].unique_id == "A1B2C3D4E5G6H7" + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_hunterdouglas_hub") +@pytest.mark.parametrize("api_version", [3]) # only gen 3 present secondary hubs +async def test_form_unsupported_device( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + api_version: int, +) -> None: + """Test unsupported device failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Simulate a gen 3 secondary hub + with patch( + "homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data", + return_value=load_json_object_fixture("gen3/gateway/secondary.json", DOMAIN), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unsupported_device"} + + # Now try again without the patch in place to make sure we can recover + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.2.3.4"}, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == f"Powerview Generation {api_version}" + assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version} + assert result3["result"].unique_id == "A1B2C3D4E5G6H7" + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hunterdouglas_powerview/test_scene.py b/tests/components/hunterdouglas_powerview/test_scene.py index b4dd4491a72..5f24bbc36ea 100644 --- a/tests/components/hunterdouglas_powerview/test_scene.py +++ b/tests/components/hunterdouglas_powerview/test_scene.py @@ -1,26 +1,111 @@ """Test the Hunter Douglas Powerview scene platform.""" from unittest.mock import patch +import pytest + from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from . import MOCK_MAC +from .const import MOCK_MAC from tests.common import MockConfigEntry -async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None: +@pytest.mark.parametrize("api_version", [1, 2, 3]) +async def test_scenes( + hass: HomeAssistant, + mock_hunterdouglas_hub: None, + api_version: int, +) -> None: """Test the scenes.""" entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 - assert hass.states.get("scene.alexanderhd_one").state == STATE_UNKNOWN - assert hass.states.get("scene.alexanderhd_two").state == STATE_UNKNOWN + assert hass.states.async_entity_ids_count(SCENE_DOMAIN) == 18 + assert ( + hass.states.get( + f"scene.powerview_generation_{api_version}_close_lounge_room" + ).state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_4").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_2").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get( + f"scene.powerview_generation_{api_version}_close_master_bed" + ).state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_family").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_4").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get( + f"scene.powerview_generation_{api_version}_open_master_bed" + ).state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_3").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_family").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_study").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_all").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_all").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_kitchen").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get( + f"scene.powerview_generation_{api_version}_open_lounge_room" + ).state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_2").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_3").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_close_kitchen").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get(f"scene.powerview_generation_{api_version}_open_study").state + == STATE_UNKNOWN + ) with patch( "homeassistant.components.hunterdouglas_powerview.scene.PvScene.activate" @@ -28,7 +113,7 @@ async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None: await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "scene.alexanderhd_one"}, + {"entity_id": f"scene.powerview_generation_{api_version}_open_study"}, blocking=True, ) await hass.async_block_till_done()