Powerview Gen 3 functionality (#110158)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
kingy444 2024-02-16 01:27:11 +11:00 committed by GitHub
parent d6efdc47a5
commit 3529eb6044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 4787 additions and 1010 deletions

View File

@ -3,40 +3,23 @@ import asyncio
import logging import logging
from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.api_base import ApiEntryPoint from aiopvapi.hub import Hub
from aiopvapi.helpers.tools import base64_to_unicode from aiopvapi.resources.model import PowerviewData
from aiopvapi.rooms import Rooms from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
from aiopvapi.userdata import UserData
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from .const import ( from .const import DOMAIN, HUB_EXCEPTIONS
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 .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
from .shade_data import PowerviewShadeData from .shade_data import PowerviewShadeData
from .util import async_map_data_by_id
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -58,46 +41,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config = entry.data config = entry.data
hub_address = config[CONF_HOST] 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) 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: try:
async with asyncio.timeout(10): async with asyncio.timeout(10):
device_info = await async_get_device_info(pv_request, hub_address) hub = Hub(pv_request)
await hub.query_firmware()
async with asyncio.timeout(10): device_info = await async_get_device_info(hub)
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])
except HUB_EXCEPTIONS as err: except HUB_EXCEPTIONS as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Connection error to PowerView hub: {hub_address}: {err}" f"Connection error to PowerView hub {hub_address}: {err}"
) from 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: if not device_info:
raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") 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()) coordinator.async_set_updated_data(PowerviewShadeData())
# populate raw shade data into the coordinator for diagnostics # 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( hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData(
api=pv_request, api=pv_request,
room_data=room_data, room_data=room_data.processed,
scene_data=scene_data, scene_data=scene_data.processed,
shade_data=shade_data, shade_data=shade_data.processed,
coordinator=coordinator, coordinator=coordinator,
device_info=device_info, device_info=device_info,
) )
@ -107,39 +114,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_get_device_info( async def async_get_device_info(hub: Hub) -> PowerviewDeviceInfo:
pv_request: AioRequest, hub_address: str
) -> PowerviewDeviceInfo:
"""Determine device info.""" """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( return PowerviewDeviceInfo(
name=base64_to_unicode(userdata_data[HUB_NAME]), name=hub.name,
mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], mac_address=hub.mac_address,
serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], serial_number=hub.serial_number,
firmware=main_processor_info, firmware=hub.firmware,
model=main_processor_info[FIRMWARE_NAME], model=hub.model,
hub_address=hub_address, hub_address=hub.ip,
) )
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok

View File

@ -5,7 +5,14 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final 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 ( from homeassistant.components.button import (
ButtonDeviceClass, ButtonDeviceClass,
@ -17,7 +24,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity from .entity import ShadeEntity
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
@ -27,7 +34,8 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData
class PowerviewButtonDescriptionMixin: class PowerviewButtonDescriptionMixin:
"""Mixin to describe a Button entity.""" """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) @dataclass(frozen=True)
@ -37,18 +45,20 @@ class PowerviewButtonDescription(
"""Class to describe a Button entity.""" """Class to describe a Button entity."""
BUTTONS: Final = [ BUTTONS_SHADE: Final = [
PowerviewButtonDescription( PowerviewButtonDescription(
key="calibrate", key="calibrate",
translation_key="calibrate", translation_key="calibrate",
icon="mdi:swap-vertical-circle-outline", icon="mdi:swap-vertical-circle-outline",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
create_entity_fn=lambda shade: shade.is_supported(MOTION_CALIBRATE),
press_action=lambda shade: shade.calibrate(), press_action=lambda shade: shade.calibrate(),
), ),
PowerviewButtonDescription( PowerviewButtonDescription(
key="identify", key="identify",
device_class=ButtonDeviceClass.IDENTIFY, device_class=ButtonDeviceClass.IDENTIFY,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
create_entity_fn=lambda shade: shade.is_supported(MOTION_JOG),
press_action=lambda shade: shade.jog(), press_action=lambda shade: shade.jog(),
), ),
PowerviewButtonDescription( PowerviewButtonDescription(
@ -56,6 +66,7 @@ BUTTONS: Final = [
translation_key="favorite", translation_key="favorite",
icon="mdi:heart", icon="mdi:heart",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
create_entity_fn=lambda shade: shade.is_supported(MOTION_FAVORITE),
press_action=lambda shade: shade.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] pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id]
entities: list[ButtonEntity] = [] entities: list[ButtonEntity] = []
for raw_shade in pv_entry.shade_data.values(): for shade in pv_entry.shade_data.values():
shade: BaseShade = PvShade(raw_shade, pv_entry.api) room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "")
name_before_refresh = shade.name for description in BUTTONS_SHADE:
room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) if description.create_entity_fn(shade):
room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.append(
PowerviewShadeButton(
for description in BUTTONS: pv_entry.coordinator,
entities.append( pv_entry.device_info,
PowerviewButton( room_name,
pv_entry.coordinator, shade,
pv_entry.device_info, shade.name,
room_name, description,
shade, )
name_before_refresh,
description,
) )
)
async_add_entities(entities) async_add_entities(entities)
class PowerviewButton(ShadeEntity, ButtonEntity): class PowerviewShadeButton(ShadeEntity, ButtonEntity):
"""Representation of an advanced feature button.""" """Representation of an advanced feature button."""
def __init__( def __init__(

View File

@ -3,14 +3,15 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any
from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.hub import Hub
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core, exceptions from homeassistant import config_entries, core, exceptions
from homeassistant.components import dhcp, zeroconf 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.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -19,9 +20,9 @@ from .const import DOMAIN, HUB_EXCEPTIONS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
HAP_SUFFIX = "._hap._tcp.local." 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]: 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: try:
async with asyncio.timeout(10): 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: except HUB_EXCEPTIONS as err:
raise CannotConnect from 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 info that you want to store in the config entry.
return { return {
"title": device_info.name, "title": device_info.name,
"unique_id": device_info.serial_number, "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.""" """Handle a config flow for Hunter Douglas PowerView."""
VERSION = 1 VERSION = 1
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the powerview config flow.""" """Initialize the powerview config flow."""
self.powerview_config: dict[str, str] = {} self.powerview_config: dict = {}
self.discovered_ip: str | None = None self.discovered_ip: str | None = None
self.discovered_name: str | None = None self.discovered_name: str | None = None
self.data_schema: dict = {vol.Required(CONF_HOST): str}
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors: dict[str, Any] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
info, error = await self._async_validate_or_error(user_input[CONF_HOST]) info, error = await self._async_validate_or_error(user_input[CONF_HOST])
if info and not error: 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"]) await self.async_set_unique_id(info["unique_id"])
return self.async_create_entry( 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 errors["base"] = error
return self.async_show_form( 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( async def _async_validate_or_error(
@ -85,6 +112,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
info = await validate_input(self.hass, host) info = await validate_input(self.hass, host)
except CannotConnect: except CannotConnect:
return None, "cannot_connect" return None, "cannot_connect"
except UnsupportedDevice:
return None, "unsupported_device"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
return None, "unknown" return None, "unknown"
@ -102,7 +131,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult: ) -> FlowResult:
"""Handle zeroconf discovery.""" """Handle zeroconf discovery."""
self.discovered_ip = discovery_info.host 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 self.discovered_name = name
return await self.async_step_discovery_confirm() return await self.async_step_discovery_confirm()
@ -137,6 +167,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.powerview_config = { self.powerview_config = {
CONF_HOST: self.discovered_ip, CONF_HOST: self.discovered_ip,
CONF_NAME: self.discovered_name, CONF_NAME: self.discovered_name,
CONF_API_VERSION: info[CONF_API_VERSION],
} }
return await self.async_step_link() return await self.async_step_link()
@ -147,7 +178,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(
title=self.powerview_config[CONF_NAME], 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() self._set_confirm_only()
@ -159,3 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class CannotConnect(exceptions.HomeAssistantError): class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""
class UnsupportedDevice(exceptions.HomeAssistantError):
"""Error to indicate the device is not supported."""

View File

@ -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 aiohttp.client_exceptions import ServerDisconnectedError
from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError from aiopvapi.helpers.aiorequest import (
PvApiConnectionError,
PvApiEmptyData,
PvApiMaintenance,
PvApiResponseStatusError,
)
DOMAIN = "hunterdouglas_powerview" DOMAIN = "hunterdouglas_powerview"
MANUFACTURER = "Hunter Douglas" 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_MAC_ADDRESS = "mac_address"
REDACT_SERIAL_NUMBER = "serial_number" REDACT_SERIAL_NUMBER = "serial_number"
REDACT_HUB_ADDRESS = "hub_address" REDACT_HUB_ADDRESS = "hub_address"
SCENE_NAME = "name" STATE_ATTRIBUTE_ROOM_NAME = "room_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"
HUB_EXCEPTIONS = ( HUB_EXCEPTIONS = (
ServerDisconnectedError, ServerDisconnectedError,
TimeoutError, TimeoutError,
PvApiConnectionError, PvApiConnectionError,
PvApiResponseStatusError, 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()}

View File

@ -5,12 +5,14 @@ import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import SHADE_DATA from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -19,18 +21,14 @@ _LOGGER = logging.getLogger(__name__)
class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]):
"""DataUpdateCoordinator to gather data from a powerview hub.""" """DataUpdateCoordinator to gather data from a powerview hub."""
def __init__( def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None:
self,
hass: HomeAssistant,
shades: Shades,
hub_address: str,
) -> None:
"""Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub."""
self.shades = shades self.shades = shades
self.hub = hub
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=f"powerview hub {hub_address}", name=f"powerview hub {hub.hub_address}",
update_interval=timedelta(seconds=60), update_interval=timedelta(seconds=60),
) )
@ -38,17 +36,20 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData])
"""Fetch data from shade endpoint.""" """Fetch data from shade endpoint."""
async with asyncio.timeout(10): async with asyncio.timeout(10):
shade_entries = await self.shades.get_resources() try:
shade_entries = await self.shades.get_shades()
if isinstance(shade_entries, bool): except PvApiMaintenance as error:
# hub returns boolean on a 204/423 empty response (maintenance) # hub is undergoing maintenance, pause polling
# continual polling results in inevitable error raise UpdateFailed(error) from error
raise UpdateFailed("Powerview Hub is undergoing maintenance") 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: 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 # 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 return self.data

View File

@ -4,21 +4,20 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from dataclasses import replace
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from math import ceil from math import ceil
from typing import Any from typing import Any
from aiopvapi.helpers.constants import ( from aiopvapi.helpers.constants import (
ATTR_POSITION1, ATTR_NAME,
ATTR_POSITION2, CLOSED_POSITION,
ATTR_POSITION_DATA,
ATTR_POSKIND1,
ATTR_POSKIND2,
MAX_POSITION, MAX_POSITION,
MIN_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 ( from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
@ -32,20 +31,10 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from .const import ( from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME
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 .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity from .entity import ShadeEntity
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
from .shade_data import PowerviewShadeMove
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -57,14 +46,6 @@ PARALLEL_UPDATES = 1
RESYNC_DELAY = 60 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) SCAN_INTERVAL = timedelta(minutes=10)
@ -77,42 +58,23 @@ async def async_setup_entry(
coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator
entities: list[ShadeEntity] = [] 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 # The shade may be out of sync with the hub
# so we force a refresh when we add it if possible # 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): with suppress(TimeoutError):
async with asyncio.timeout(1): async with asyncio.timeout(1):
await shade.refresh() await shade.refresh()
coordinator.data.update_shade_position(shade.id, shade.current_position)
if ATTR_POSITION_DATA not in shade.raw_data: room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "")
_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, "")
entities.extend( entities.extend(
create_powerview_shade_entity( 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) 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): class PowerViewShadeBase(ShadeEntity, CoverEntity):
"""Representation of a powerview shade.""" """Representation of a powerview shade."""
@ -135,7 +97,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
super().__init__(coordinator, device_info, room_name, shade, name) super().__init__(coordinator, device_info, room_name, shade, name)
self._shade: BaseShade = shade self._shade: BaseShade = shade
self._scheduled_transition_update: CALLBACK_TYPE | None = None 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._attr_supported_features |= CoverEntityFeature.STOP
self._forced_resync: Callable[[], None] | None = None self._forced_resync: Callable[[], None] | None = None
@ -172,22 +134,22 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
@property @property
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
"""Return the current position of cover.""" """Return the current position of cover."""
return hd_position_to_hass(self.positions.primary, MAX_POSITION) return self.positions.primary
@property @property
def transition_steps(self) -> int: def transition_steps(self) -> int:
"""Return the steps to make a move.""" """Return the steps to make a move."""
return hd_position_to_hass(self.positions.primary, MAX_POSITION) return self.positions.primary
@property @property
def open_position(self) -> PowerviewShadeMove: def open_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """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 @property
def close_position(self) -> PowerviewShadeMove: def close_position(self) -> ShadePosition:
"""Return the close position and required additional positions.""" """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: async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover.""" """Close the cover."""
@ -208,12 +170,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover.""" """Stop the cover."""
self._async_cancel_scheduled_transition_update() 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() await self._async_force_refresh_state()
@callback @callback
def _clamp_cover_limit(self, target_hass_position: int) -> int: 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 # no override required in base
return target_hass_position return target_hass_position
@ -222,21 +184,21 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
await self._async_set_cover_position(kwargs[ATTR_POSITION]) await self._async_set_cover_position(kwargs[ATTR_POSITION])
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_one = hass_position_to_hd(target_hass_position) """Return a ShadePosition."""
return PowerviewShadeMove( return ShadePosition(
{ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} 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.""" """Execute a move that can affect multiple positions."""
response = await self._shade.move(move.request) _LOGGER.debug("Move request %s: %s", self.name, move)
# Process any positions we know will update as result response = await self._shade.move(move)
# of the request since the hub won't return them _LOGGER.debug("Move response %s: %s", self.name, response)
for kind, position in move.new_positions.items():
self.data.update_shade_position(self._shade.id, position, kind) # Process the response from the hub (including new positions)
# Finally process the response self.data.update_shade_position(self._shade.id, response)
self.data.update_from_response(response)
async def _async_set_cover_position(self, target_hass_position: int) -> None: async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position.""" """Move the shade to a position."""
@ -251,9 +213,9 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
self.async_write_ha_state() self.async_write_ha_state()
@callback @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.""" """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_opening = False
self._attr_is_closing = False self._attr_is_closing = False
@ -283,7 +245,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
est_time_to_complete_transition, 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. # to be completed.
self._scheduled_transition_update = async_call_later( self._scheduled_transition_update = async_call_later(
self.hass, self.hass,
@ -342,8 +304,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
# The update will likely timeout and # The update will likely timeout and
# error if are already have one in flight # error if are already have one in flight
return return
await self._shade.refresh() # suppress timeouts caused by hub nightly reboot
self._async_update_shade_data(self._shade.raw_data) 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): class PowerViewShade(PowerViewShadeBase):
@ -372,31 +338,31 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase):
| CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.SET_TILT_POSITION | 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._attr_supported_features |= CoverEntityFeature.STOP_TILT
self._max_tilt = self._shade.shade_limits.tilt_max self._max_tilt = self._shade.shade_limits.tilt_max
@property @property
def current_cover_tilt_position(self) -> int: def current_cover_tilt_position(self) -> int:
"""Return the current cover tile position.""" """Return the current cover tile position."""
return hd_position_to_hass(self.positions.vane, self._max_tilt) return self.positions.tilt
@property @property
def transition_steps(self) -> int: def transition_steps(self) -> int:
"""Return the steps to make a move.""" """Return the steps to make a move."""
return hd_position_to_hass( return self.positions.primary + self.positions.tilt
self.positions.primary, MAX_POSITION
) + hd_position_to_hass(self.positions.vane, self._max_tilt)
@property @property
def open_tilt_position(self) -> PowerviewShadeMove: def open_tilt_position(self) -> ShadePosition:
"""Return the open tilt position and required additional positions.""" """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 @property
def close_tilt_position(self) -> PowerviewShadeMove: def close_tilt_position(self) -> ShadePosition:
"""Return the close tilt position and required additional positions.""" """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: async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt.""" """Close the cover tilt."""
@ -411,13 +377,13 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase):
self.async_write_ha_state() self.async_write_ha_state()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: 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]) await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION])
async def _async_set_cover_tilt_position( async def _async_set_cover_tilt_position(
self, target_hass_tilt_position: int self, target_hass_tilt_position: int
) -> None: ) -> 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 final_position = self.current_cover_position + target_hass_tilt_position
self._async_schedule_update_for_transition( self._async_schedule_update_for_transition(
abs(self.transition_steps - final_position) abs(self.transition_steps - final_position)
@ -426,11 +392,19 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase):
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
"""Return a PowerviewShadeMove.""" """Return a ShadePosition."""
position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) return ShadePosition(
return PowerviewShadeMove( primary=target_hass_position,
{ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, {} 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: async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
@ -450,49 +424,25 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase):
_attr_name = None _attr_name = None
@property @property
def open_position(self) -> PowerviewShadeMove: def open_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """Return the open position and required additional positions."""
return PowerviewShadeMove( return replace(self._shade.open_position, velocity=self.positions.velocity)
self._shade.open_position, {POS_KIND_VANE: MIN_POSITION}
)
@property @property
def close_position(self) -> PowerviewShadeMove: def close_position(self) -> ShadePosition:
"""Return the close position and required additional positions.""" """Return the close position and required additional positions."""
return PowerviewShadeMove( return replace(self._shade.close_position, velocity=self.positions.velocity)
self._shade.close_position, {POS_KIND_VANE: MIN_POSITION}
)
@property @property
def open_tilt_position(self) -> PowerviewShadeMove: def open_tilt_position(self) -> ShadePosition:
"""Return the open tilt position and required additional positions.""" """Return the open tilt position and required additional positions."""
return PowerviewShadeMove( return replace(self._shade.open_position_tilt, velocity=self.positions.velocity)
self._shade.open_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION}
)
@property @property
def close_tilt_position(self) -> PowerviewShadeMove: def close_tilt_position(self) -> ShadePosition:
"""Return the close tilt position and required additional positions.""" """Return the close tilt position and required additional positions."""
return PowerviewShadeMove( return replace(
self._shade.close_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} self._shade.close_position_tilt, velocity=self.positions.velocity
)
@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},
) )
@ -506,32 +456,21 @@ class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase):
""" """
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) """Return a ShadePosition."""
position_vane = self.positions.vane return ShadePosition(
return PowerviewShadeMove( primary=target_hass_position,
{ tilt=self.positions.tilt,
ATTR_POSITION1: position_shade, velocity=self.positions.velocity,
ATTR_POSITION2: position_vane,
ATTR_POSKIND1: POS_KIND_PRIMARY,
ATTR_POSKIND2: POS_KIND_VANE,
},
{},
) )
@callback @callback
def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition:
"""Return a PowerviewShadeMove.""" """Return a ShadePosition."""
position_shade = self.positions.primary return ShadePosition(
position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) primary=self.positions.primary,
return PowerviewShadeMove( tilt=target_hass_tilt_position,
{ velocity=self.positions.velocity,
ATTR_POSITION1: position_shade,
ATTR_POSITION2: position_vane,
ATTR_POSKIND1: POS_KIND_PRIMARY,
ATTR_POSKIND2: POS_KIND_VANE,
},
{},
) )
@ -558,7 +497,7 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase):
| CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.SET_TILT_POSITION | 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._attr_supported_features |= CoverEntityFeature.STOP_TILT
self._max_tilt = self._shade.shade_limits.tilt_max self._max_tilt = self._shade.shade_limits.tilt_max
@ -577,17 +516,18 @@ class PowerViewShadeTopDown(PowerViewShadeBase):
@property @property
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
"""Return the current position of cover.""" """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 @property
def is_closed(self) -> bool: def is_closed(self) -> bool:
"""Return if the cover is closed.""" """Return if the cover is closed."""
return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION 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): class PowerViewShadeDualRailBase(PowerViewShadeBase):
"""Representation of a shade with top/down bottom/up capabilities. """Representation of a shade with top/down bottom/up capabilities.
@ -600,9 +540,7 @@ class PowerViewShadeDualRailBase(PowerViewShadeBase):
@property @property
def transition_steps(self) -> int: def transition_steps(self) -> int:
"""Return the steps to make a move.""" """Return the steps to make a move."""
return hd_position_to_hass( return self.positions.primary + self.positions.secondary
self.positions.primary, MAX_POSITION
) + hd_position_to_hass(self.positions.secondary, MAX_POSITION)
class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase):
@ -629,22 +567,16 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase):
@callback @callback
def _clamp_cover_limit(self, target_hass_position: int) -> int: 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."""
cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) return min(target_hass_position, (MAX_POSITION - self.positions.secondary))
return min(target_hass_position, (100 - cover_top))
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_bottom = hass_position_to_hd(target_hass_position) """Return a ShadePosition."""
position_top = self.positions.secondary return ShadePosition(
return PowerviewShadeMove( primary=target_hass_position,
{ secondary=self.positions.secondary,
ATTR_POSITION1: position_bottom, velocity=self.positions.velocity,
ATTR_POSITION2: position_top,
ATTR_POSKIND1: POS_KIND_PRIMARY,
ATTR_POSKIND2: POS_KIND_SECONDARY,
},
{},
) )
@ -689,41 +621,31 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase):
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
"""Return the current position of cover.""" """Return the current position of cover."""
# these need to be inverted to report state correctly in HA # 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 @property
def open_position(self) -> PowerviewShadeMove: def open_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """Return the open position and required additional positions."""
# these shades share a class in parent API # these shades share a class in parent API
# override open position for top shade # override open position for top shade
return PowerviewShadeMove( return ShadePosition(
{ primary=MIN_POSITION,
ATTR_POSITION1: MIN_POSITION, secondary=MAX_POSITION,
ATTR_POSITION2: MAX_POSITION, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_PRIMARY,
ATTR_POSKIND2: POS_KIND_SECONDARY,
},
{},
) )
@callback @callback
def _clamp_cover_limit(self, target_hass_position: int) -> int: def _clamp_cover_limit(self, target_hass_position: int) -> int:
"""Don't allow a cover to go into an impossbile position.""" """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, (MAX_POSITION - self.positions.primary))
return min(target_hass_position, (100 - cover_bottom))
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_bottom = self.positions.primary """Return a ShadePosition."""
position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) return ShadePosition(
return PowerviewShadeMove( primary=self.positions.primary,
{ secondary=target_hass_position,
ATTR_POSITION1: position_bottom, velocity=self.positions.velocity,
ATTR_POSITION2: position_top,
ATTR_POSKIND1: POS_KIND_PRIMARY,
ATTR_POSKIND2: POS_KIND_SECONDARY,
},
{},
) )
@ -739,33 +661,27 @@ class PowerViewShadeDualOverlappedBase(PowerViewShadeBase):
# poskind 1 represents the second half of the shade in hass # poskind 1 represents the second half of the shade in hass
# front must be fully closed before rear can move # front must be fully closed before rear can move
# 51 - 100 is equiv to 1-100 on other shades - one motor, two shades # 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 # poskind 2 represents the shade first half of the shade in hass
# rear (opaque) must be fully open before front can move # rear (opaque) must be fully open before front can move
# 51 - 100 is equiv to 1-100 on other shades - one motor, two shades # 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) return ceil(primary + secondary)
@property @property
def open_position(self) -> PowerviewShadeMove: def open_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """Return the open position and required additional positions."""
return PowerviewShadeMove( return ShadePosition(
{ primary=MAX_POSITION,
ATTR_POSITION1: MAX_POSITION, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_PRIMARY,
},
{POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@property @property
def close_position(self) -> PowerviewShadeMove: def close_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """Return the open position and required additional positions."""
return PowerviewShadeMove( return ShadePosition(
{ secondary=MIN_POSITION,
ATTR_POSITION1: MIN_POSITION, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_SECONDARY,
},
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@ -782,7 +698,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase):
_attr_translation_key = "combined" _attr_translation_key = "combined"
# type
def __init__( def __init__(
self, self,
coordinator: PowerviewShadeUpdateCoordinator, coordinator: PowerviewShadeUpdateCoordinator,
@ -806,36 +721,28 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase):
"""Return the current position of cover.""" """Return the current position of cover."""
# if front is open return that (other positions are impossible) # if front is open return that (other positions are impossible)
# if front shade is closed get position of rear # 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: 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) return ceil(position)
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) """Return a ShadePosition."""
# note we set POS_KIND_VANE: MIN_POSITION here even with shades without # 0 - 50 represents the rear blockut shade
# 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
if target_hass_position <= 50: if target_hass_position <= 50:
target_hass_position = target_hass_position * 2 target_hass_position = target_hass_position * 2
return PowerviewShadeMove( return ShadePosition(
{ secondary=target_hass_position,
ATTR_POSITION1: position_shade, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_SECONDARY,
},
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
# 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade)
target_hass_position = (target_hass_position - 50) * 2 target_hass_position = (target_hass_position - 50) * 2
return PowerviewShadeMove( return ShadePosition(
{ primary=target_hass_position,
ATTR_POSITION1: position_shade, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_PRIMARY,
},
{POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@ -879,28 +786,19 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase):
return False return False
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) """Return a ShadePosition."""
# note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional return ShadePosition(
# override is required for differences between type 8/9/10 primary=target_hass_position,
# this just stores the value in the coordinator for future reference velocity=self.positions.velocity,
return PowerviewShadeMove(
{
ATTR_POSITION1: position_shade,
ATTR_POSKIND1: POS_KIND_PRIMARY,
},
{POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@property @property
def close_position(self) -> PowerviewShadeMove: def close_position(self) -> ShadePosition:
"""Return the close position and required additional positions.""" """Return the close position and required additional positions."""
return PowerviewShadeMove( return ShadePosition(
{ primary=MIN_POSITION,
ATTR_POSITION1: MIN_POSITION, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_PRIMARY,
},
{POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@ -952,31 +850,22 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase):
@property @property
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
"""Return the current position of cover.""" """Return the current position of cover."""
return hd_position_to_hass(self.positions.secondary, MAX_POSITION) return self.positions.secondary
@callback @callback
def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: def _get_shade_move(self, target_hass_position: int) -> ShadePosition:
position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) """Return a ShadePosition."""
# note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional return ShadePosition(
# override is required for differences between type 8/9/10 secondary=target_hass_position,
# this just stores the value in the coordinator for future reference velocity=self.positions.velocity,
return PowerviewShadeMove(
{
ATTR_POSITION1: position_shade,
ATTR_POSKIND1: POS_KIND_SECONDARY,
},
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@property @property
def open_position(self) -> PowerviewShadeMove: def open_position(self) -> ShadePosition:
"""Return the open position and required additional positions.""" """Return the open position and required additional positions."""
return PowerviewShadeMove( return ShadePosition(
{ secondary=MAX_POSITION,
ATTR_POSITION1: MAX_POSITION, velocity=self.positions.velocity,
ATTR_POSKIND1: POS_KIND_SECONDARY,
},
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION},
) )
@ -1010,7 +899,7 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi
| CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.SET_TILT_POSITION | 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._attr_supported_features |= CoverEntityFeature.STOP_TILT
self._max_tilt = self._shade.shade_limits.tilt_max 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 # poskind 1 represents the second half of the shade in hass
# front must be fully closed before rear can move # front must be fully closed before rear can move
# 51 - 100 is equiv to 1-100 on other shades - one motor, two shades # 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 # poskind 2 represents the shade first half of the shade in hass
# rear (opaque) must be fully open before front can move # rear (opaque) must be fully open before front can move
# 51 - 100 is equiv to 1-100 on other shades - one motor, two shades # 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
vane = hd_position_to_hass(self.positions.vane, self._max_tilt) tilt = self.positions.tilt
return ceil(primary + secondary + vane) return ceil(primary + secondary + tilt)
@callback @callback
def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition:
"""Return a PowerviewShadeMove.""" """Return a ShadePosition."""
position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) return ShadePosition(
return PowerviewShadeMove( tilt=target_hass_tilt_position,
{ velocity=self.positions.velocity,
ATTR_POSITION1: position_vane,
ATTR_POSKIND1: POS_KIND_VANE,
},
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION},
) )
@property @property
def open_tilt_position(self) -> PowerviewShadeMove: def open_tilt_position(self) -> ShadePosition:
"""Return the open tilt position and required additional positions.""" """Return the open tilt position and required additional positions."""
return PowerviewShadeMove( return replace(self._shade.open_position_tilt, velocity=self.positions.velocity)
self._shade.open_position_tilt,
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION},
)
@property @property
def close_tilt_position(self) -> PowerviewShadeMove: def close_tilt_position(self) -> ShadePosition:
"""Return the open tilt position and required additional positions.""" """Return the open tilt position and required additional positions."""
return PowerviewShadeMove( return replace(
self._shade.open_position_tilt, self._shade.close_position_tilt, velocity=self.positions.velocity
{POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION},
) )
@ -1099,7 +980,8 @@ def create_powerview_shade_entity(
shade.capability.type, (PowerViewShade,) shade.capability.type, (PowerViewShade,)
) )
_LOGGER.debug( _LOGGER.debug(
"%s (%s) detected as %a %s", "%s %s (%s) detected as %a %s",
room_name,
shade.name, shade.name,
shade.capability.type, shade.capability.type,
classes, classes,

View File

@ -1,25 +1,19 @@
"""The powerview integration base entity.""" """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 import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import DOMAIN, MANUFACTURER
ATTR_BATTERY_KIND,
BATTERY_KIND_HARDWIRED,
DOMAIN,
FIRMWARE,
FIRMWARE_BUILD,
FIRMWARE_REVISION,
FIRMWARE_SUB_REVISION,
MANUFACTURER,
)
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData, PowerviewShadePositions from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)
class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]):
@ -39,6 +33,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]):
self._room_name = room_name self._room_name = room_name
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._device_info = device_info self._device_info = device_info
self._configuration_url = self.coordinator.hub.url
@property @property
def data(self) -> PowerviewShadeData: def data(self) -> PowerviewShadeData:
@ -48,17 +43,14 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the device_info of the device.""" """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( return DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)},
identifiers={(DOMAIN, self._device_info.serial_number)}, identifiers={(DOMAIN, self._device_info.serial_number)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=self._device_info.model, model=self._device_info.model,
name=self._device_info.name, name=self._device_info.name,
suggested_area=self._room_name, sw_version=self._device_info.firmware,
sw_version=sw_version, configuration_url=self._configuration_url,
configuration_url=f"http://{self._device_info.hub_address}/api/shades",
) )
@ -77,42 +69,24 @@ class ShadeEntity(HDEntity):
super().__init__(coordinator, device_info, room_name, shade.id) super().__init__(coordinator, device_info, room_name, shade.id)
self._shade_name = shade_name self._shade_name = shade_name
self._shade = shade self._shade = shade
self._is_hard_wired = bool( self._is_hard_wired = not shade.is_battery_powered()
shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED self._configuration_url = shade.url
)
@property @property
def positions(self) -> PowerviewShadePositions: def positions(self) -> ShadePosition:
"""Return the PowerviewShadeData.""" """Return the PowerviewShadeData."""
return self.data.get_shade_positions(self._shade.id) return self.data.get_shade_position(self._shade.id)
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the device_info of the device.""" """Return the device_info of the device."""
return DeviceInfo(
device_info = DeviceInfo(
identifiers={(DOMAIN, self._shade.id)}, identifiers={(DOMAIN, self._shade.id)},
name=self._shade_name, name=self._shade_name,
suggested_area=self._room_name, suggested_area=self._room_name,
manufacturer=MANUFACTURER, 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), via_device=(DOMAIN, self._device_info.serial_number),
configuration_url=( configuration_url=self._configuration_url,
f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}"
),
) )
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

View File

@ -18,6 +18,6 @@
}, },
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiopvapi"], "loggers": ["aiopvapi"],
"requirements": ["aiopvapi==2.0.4"], "requirements": ["aiopvapi==3.0.2"],
"zeroconf": ["_powerview._tcp.local."] "zeroconf": ["_powerview._tcp.local.", "_powerview-g3._tcp.local."]
} }

View File

@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest 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 from .coordinator import PowerviewShadeUpdateCoordinator
@ -14,9 +16,9 @@ class PowerviewEntryData:
"""Define class for main domain information.""" """Define class for main domain information."""
api: AioRequest api: AioRequest
room_data: dict[str, Any] room_data: dict[str, Room]
scene_data: dict[str, Any] scene_data: dict[str, Scene]
shade_data: dict[str, Any] shade_data: dict[str, BaseShade]
coordinator: PowerviewShadeUpdateCoordinator coordinator: PowerviewShadeUpdateCoordinator
device_info: PowerviewDeviceInfo device_info: PowerviewDeviceInfo
@ -28,6 +30,6 @@ class PowerviewDeviceInfo:
name: str name: str
mac_address: str mac_address: str
serial_number: str serial_number: str
firmware: dict[str, Any] firmware: str | None
model: str model: str
hub_address: str hub_address: str

View File

@ -1,8 +1,10 @@
"""Support for Powerview scenes from a Powerview hub.""" """Support for Powerview scenes from a Powerview hub."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from aiopvapi.helpers.constants import ATTR_NAME
from aiopvapi.resources.scene import Scene as PvScene from aiopvapi.resources.scene import Scene as PvScene
from homeassistant.components.scene import Scene from homeassistant.components.scene import Scene
@ -10,11 +12,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .coordinator import PowerviewShadeUpdateCoordinator
from .entity import HDEntity from .entity import HDEntity
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
_LOGGER = logging.getLogger(__name__)
RESYNC_DELAY = 60
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 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] pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id]
pvscenes: list[PowerViewScene] = [] pvscenes: list[PowerViewScene] = []
for raw_scene in pv_entry.scene_data.values(): for scene in pv_entry.scene_data.values():
scene = PvScene(raw_scene, pv_entry.api) room_name = getattr(pv_entry.room_data.get(scene.room_id), ATTR_NAME, "")
room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "")
pvscenes.append( pvscenes.append(
PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene)
) )
@ -47,10 +52,11 @@ class PowerViewScene(HDEntity, Scene):
) -> None: ) -> None:
"""Initialize the scene.""" """Initialize the scene."""
super().__init__(coordinator, device_info, room_name, scene.id) super().__init__(coordinator, device_info, room_name, scene.id)
self._scene = scene self._scene: PvScene = scene
self._attr_name = scene.name self._attr_name = scene.name
self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name}
async def async_activate(self, **kwargs: Any) -> None: async def async_activate(self, **kwargs: Any) -> None:
"""Activate scene. Try to get entities into requested state.""" """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)

View File

@ -3,9 +3,11 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Any, Final 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.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -13,19 +15,13 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import DOMAIN
ATTR_BATTERY_KIND,
DOMAIN,
POWER_SUPPLY_TYPE_MAP,
POWER_SUPPLY_TYPE_REVERSE_MAP,
ROOM_ID_IN_SHADE,
ROOM_NAME_UNICODE,
SHADE_BATTERY_LEVEL,
)
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity from .entity import ShadeEntity
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class PowerviewSelectDescriptionMixin: class PowerviewSelectDescriptionMixin:
@ -33,6 +29,8 @@ class PowerviewSelectDescriptionMixin:
current_fn: Callable[[BaseShade], Any] current_fn: Callable[[BaseShade], Any]
select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]]
create_entity_fn: Callable[[BaseShade], bool]
options_fn: Callable[[BaseShade], list[str]]
@dataclass(frozen=True) @dataclass(frozen=True)
@ -49,13 +47,10 @@ DROPDOWNS: Final = [
key="powersource", key="powersource",
translation_key="power_source", translation_key="power_source",
icon="mdi:power-plug-outline", icon="mdi:power-plug-outline",
current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( current_fn=lambda shade: shade.get_power_source(),
shade.raw_data.get(ATTR_BATTERY_KIND), None options_fn=lambda shade: shade.supported_power_sources(),
), select_fn=lambda shade, option: shade.set_power_source(option),
options=list(POWER_SUPPLY_TYPE_MAP.values()), create_entity_fn=lambda shade: shade.is_supported(FUNCTION_SET_POWER),
select_fn=lambda shade, option: shade.set_power_source(
POWER_SUPPLY_TYPE_REVERSE_MAP.get(option)
),
), ),
] ]
@ -67,26 +62,23 @@ async def async_setup_entry(
pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id]
entities = [] entities: list[PowerViewSelect] = []
for raw_shade in pv_entry.shade_data.values(): for shade in pv_entry.shade_data.values():
shade: BaseShade = PvShade(raw_shade, pv_entry.api) if not shade.has_battery_info():
if SHADE_BATTERY_LEVEL not in shade.raw_data:
continue continue
name_before_refresh = shade.name room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_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 DROPDOWNS: for description in DROPDOWNS:
entities.append( if description.create_entity_fn(shade):
PowerViewSelect( entities.append(
pv_entry.coordinator, PowerViewSelect(
pv_entry.device_info, pv_entry.coordinator,
room_name, pv_entry.device_info,
shade, room_name,
name_before_refresh, shade,
description, shade.name,
description,
)
) )
)
async_add_entities(entities) async_add_entities(entities)
@ -113,6 +105,11 @@ class PowerViewSelect(ShadeEntity, SelectEntity):
"""Return the selected entity option to represent the entity state.""" """Return the selected entity option to represent the entity state."""
return self.entity_description.current_fn(self._shade) 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: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.entity_description.select_fn(self._shade, option) await self.entity_description.select_fn(self._shade, option)

View File

@ -4,7 +4,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final 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 ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -13,21 +14,11 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import DOMAIN
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 .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity from .entity import ShadeEntity
from .model import PowerviewDeviceInfo, PowerviewEntryData from .model import PowerviewDeviceInfo, PowerviewEntryData
@ -38,8 +29,10 @@ class PowerviewSensorDescriptionMixin:
"""Mixin to describe a Sensor entity.""" """Mixin to describe a Sensor entity."""
update_fn: Callable[[BaseShade], Any] update_fn: Callable[[BaseShade], Any]
device_class_fn: Callable[[BaseShade], SensorDeviceClass | None]
native_value_fn: Callable[[BaseShade], int] 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) @dataclass(frozen=True)
@ -52,29 +45,33 @@ class PowerviewSensorDescription(
state_class = SensorStateClass.MEASUREMENT 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 = [ SENSORS: Final = [
PowerviewSensorDescription( PowerviewSensorDescription(
key="charge", key="charge",
device_class=SensorDeviceClass.BATTERY, device_class_fn=lambda shade: SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE, native_unit_fn=lambda shade: PERCENTAGE,
native_value_fn=lambda shade: round( native_value_fn=lambda shade: shade.get_battery_strength(),
shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 create_entity_fn=lambda shade: shade.is_battery_powered(),
),
create_sensor_fn=lambda shade: bool(
shade.raw_data.get(ATTR_BATTERY_KIND) != BATTERY_KIND_HARDWIRED
and SHADE_BATTERY_LEVEL in shade.raw_data
),
update_fn=lambda shade: shade.refresh_battery(), update_fn=lambda shade: shade.refresh_battery(),
), ),
PowerviewSensorDescription( PowerviewSensorDescription(
key="signal", key="signal",
translation_key="signal_strength", translation_key="signal_strength",
icon="mdi:signal", icon="mdi:signal",
native_unit_of_measurement=PERCENTAGE, device_class_fn=get_signal_device_class,
native_value_fn=lambda shade: round( native_unit_fn=get_signal_native_unit,
shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 native_value_fn=lambda shade: shade.get_signal_strength(),
), create_entity_fn=lambda shade: shade.has_signal_strength(),
create_sensor_fn=lambda shade: bool(ATTR_SIGNAL_STRENGTH in shade.raw_data),
update_fn=lambda shade: shade.refresh(), update_fn=lambda shade: shade.refresh(),
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
@ -89,21 +86,17 @@ async def async_setup_entry(
pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id]
entities: list[PowerViewSensor] = [] entities: list[PowerViewSensor] = []
for raw_shade in pv_entry.shade_data.values(): for shade in pv_entry.shade_data.values():
shade: BaseShade = PvShade(raw_shade, pv_entry.api) room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "")
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 SENSORS: for description in SENSORS:
if description.create_sensor_fn(shade): if description.create_entity_fn(shade):
entities.append( entities.append(
PowerViewSensor( PowerViewSensor(
pv_entry.coordinator, pv_entry.coordinator,
pv_entry.device_info, pv_entry.device_info,
room_name, room_name,
shade, shade,
name_before_refresh, shade.name,
description, description,
) )
) )
@ -125,17 +118,27 @@ class PowerViewSensor(ShadeEntity, SensorEntity):
name: str, name: str,
description: PowerviewSensorDescription, description: PowerviewSensorDescription,
) -> None: ) -> None:
"""Initialize the select entity.""" """Initialize the sensor entity."""
super().__init__(coordinator, device_info, room_name, shade, name) super().__init__(coordinator, device_info, room_name, shade, name)
self.entity_description = description self.entity_description = description
self.entity_description: PowerviewSensorDescription = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._attr_native_unit_of_measurement = description.native_unit_of_measurement
@property @property
def native_value(self) -> int: 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) 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 # pylint: disable-next=hass-missing-super-call
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""

View File

@ -1,59 +1,25 @@
"""Shade data for the Hunter Douglas PowerView integration.""" """Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
from aiopvapi.helpers.constants import ( from aiopvapi.resources.model import PowerviewData
ATTR_ID, from aiopvapi.resources.shade import BaseShade, ShadePosition
ATTR_POSITION1,
ATTR_POSITION2,
ATTR_POSITION_DATA,
ATTR_POSKIND1,
ATTR_POSKIND2,
ATTR_SHADE,
)
from aiopvapi.resources.shade import MIN_POSITION
from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE
from .util import async_map_data_by_id from .util import async_map_data_by_id
POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2))
_LOGGER = logging.getLogger(__name__) _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: class PowerviewShadeData:
"""Coordinate shade data between multiple api calls.""" """Coordinate shade data between multiple api calls."""
def __init__(self) -> None: def __init__(self) -> None:
"""Init the shade data.""" """Init the shade data."""
self._group_data_by_id: dict[int, dict[str | int, Any]] = {} 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]: def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade.""" """Get data for the shade."""
@ -63,17 +29,21 @@ class PowerviewShadeData:
"""Get data for all shades.""" """Get data for all shades."""
return self._group_data_by_id 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.""" """Get positions for a shade."""
if shade_id not in self.positions: if shade_id not in self.positions:
self.positions[shade_id] = PowerviewShadePositions() self.positions[shade_id] = ShadePosition()
return self.positions[shade_id] return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None: def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data.""" """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. """Store data from the all shades endpoint.
This does not update the shades or positions This does not update the shades or positions
@ -81,37 +51,34 @@ class PowerviewShadeData:
with a shade_id will update a specific shade with a shade_id will update a specific shade
from the group data. 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: def update_shade_position(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shade position.""" """Update a single shades position."""
positions = self.get_shade_positions(shade_id) if shade_id not in self.positions:
if kind == POS_KIND_PRIMARY: self.positions[shade_id] = ShadePosition()
positions.primary = position
elif kind == POS_KIND_SECONDARY:
positions.secondary = position
elif kind == POS_KIND_VANE:
positions.vane = position
def update_from_position_data( # ShadePosition will return None if the value is not set
self, shade_id: int, position_data: dict[str, Any] if shade_data.primary is not None:
) -> None: self.positions[shade_id].primary = shade_data.primary
"""Update the shade positions from the position data.""" if shade_data.secondary is not None:
for position_key, kind_key in POSITIONS: self.positions[shade_id].secondary = shade_data.secondary
if position_key in position_data: if shade_data.tilt is not None:
self.update_shade_position( self.positions[shade_id].tilt = shade_data.tilt
shade_id, position_data[position_key], position_data[kind_key]
)
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.""" """Update a shades from data dict."""
_LOGGER.debug("Raw data update: %s", data) _LOGGER.debug("Raw data update: %s", data.raw_data)
shade_id = data[ATTR_ID] self.update_shade_position(data.id, data.current_position)
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)

View File

@ -4,7 +4,11 @@
"user": { "user": {
"title": "Connect to the PowerView Hub", "title": "Connect to the PowerView Hub",
"data": { "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": { "link": {
@ -15,6 +19,7 @@
"flow_title": "{name} ({host})", "flow_title": "{name} ({host})",
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {

View File

@ -620,6 +620,11 @@ ZEROCONF = {
"domain": "plugwise", "domain": "plugwise",
}, },
], ],
"_powerview-g3._tcp.local.": [
{
"domain": "hunterdouglas_powerview",
},
],
"_powerview._tcp.local.": [ "_powerview._tcp.local.": [
{ {
"domain": "hunterdouglas_powerview", "domain": "hunterdouglas_powerview",

View File

@ -333,7 +333,7 @@ aiopulse==0.4.4
aiopurpleair==2022.12.1 aiopurpleair==2022.12.1
# homeassistant.components.hunterdouglas_powerview # homeassistant.components.hunterdouglas_powerview
aiopvapi==2.0.4 aiopvapi==3.0.2
# homeassistant.components.pvpc_hourly_pricing # homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2 aiopvpc==4.2.2

View File

@ -306,7 +306,7 @@ aiopulse==0.4.4
aiopurpleair==2022.12.1 aiopurpleair==2022.12.1
# homeassistant.components.hunterdouglas_powerview # homeassistant.components.hunterdouglas_powerview
aiopvapi==2.0.4 aiopvapi==3.0.2
# homeassistant.components.pvpc_hourly_pricing # homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2 aiopvpc==4.2.2

View File

@ -1,3 +1 @@
"""Tests for the Hunter Douglas PowerView integration.""" """Tests for the Hunter Douglas PowerView integration."""
MOCK_MAC = "AA::BB::CC::DD::EE::FF"

View File

@ -1,47 +1,137 @@
"""Tests for the Hunter Douglas PowerView integration.""" """Common fixtures for Hunter Douglas Powerview tests."""
import json
from unittest.mock import patch
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from aiopvapi.resources.shade import ShadePosition
import pytest import pytest
from tests.common import load_fixture from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
from tests.common import load_json_object_fixture, load_json_value_fixture
@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"))
@pytest.fixture @pytest.fixture
def mock_powerview_v2_hub(powerview_userdata, powerview_fwversion, powerview_scenes): def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock a Powerview v2 hub.""" """Override async_setup_entry."""
with patch( with patch(
"homeassistant.components.hunterdouglas_powerview.UserData.get_resources", "homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=powerview_userdata, 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( ), patch(
"homeassistant.components.hunterdouglas_powerview.Rooms.get_resources", "homeassistant.components.hunterdouglas_powerview.Rooms.get_resources",
return_value={"roomData": []}, return_value=load_json_value_fixture(rooms_json, DOMAIN),
), patch( ), patch(
"homeassistant.components.hunterdouglas_powerview.Scenes.get_resources", "homeassistant.components.hunterdouglas_powerview.Scenes.get_resources",
return_value=powerview_scenes, return_value=load_json_value_fixture(scenes_json, DOMAIN),
), patch( ), patch(
"homeassistant.components.hunterdouglas_powerview.Shades.get_resources", "homeassistant.components.hunterdouglas_powerview.Shades.get_resources",
return_value={"shadeData": []}, return_value=load_json_value_fixture(shades_json, DOMAIN),
), patch( ), patch(
"homeassistant.components.hunterdouglas_powerview.ApiEntryPoint", "homeassistant.components.hunterdouglas_powerview.cover.BaseShade.refresh",
return_value=powerview_fwversion, ), patch(
"homeassistant.components.hunterdouglas_powerview.cover.BaseShade.current_position",
new_callable=PropertyMock,
return_value=ShadePosition(primary=0, secondary=0, tilt=0, velocity=0),
): ):
yield 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}")

View File

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

View File

@ -1,7 +1,7 @@
{ {
"firmware": { "firmware": {
"mainProcessor": { "mainProcessor": {
"name": "PowerView Hub", "name": "Powerview Generation 1",
"revision": 1, "revision": 1,
"subRevision": 1, "subRevision": 1,
"build": 857 "build": 857

View File

@ -5,7 +5,7 @@
"sceneControllerCount": 0, "sceneControllerCount": 0,
"accessPointCount": 0, "accessPointCount": 0,
"shadeCount": 5, "shadeCount": 5,
"ip": "192.168.20.9", "ip": "192.168.0.20",
"groupCount": 9, "groupCount": 9,
"scheduledEventCount": 0, "scheduledEventCount": 0,
"editingEnabled": true, "editingEnabled": true,
@ -14,21 +14,21 @@
"sceneCount": 18, "sceneCount": 18,
"sceneControllerMemberCount": 0, "sceneControllerMemberCount": 0,
"mask": "255.255.255.0", "mask": "255.255.255.0",
"hubName": "UG93ZXJWaWV3IEh1YiBHZW4gMQ==", "hubName": "UG93ZXJ2aWV3IEdlbmVyYXRpb24gMQ==",
"rfID": "0x8B2A", "rfID": "0x8B2A",
"remoteConnectEnabled": false, "remoteConnectEnabled": false,
"multiSceneMemberCount": 0, "multiSceneMemberCount": 0,
"rfStatus": 0, "rfStatus": 0,
"serialNumber": "REMOVED", "serialNumber": "A1B2C3D4E5G6H7",
"undefinedShadeCount": 0, "undefinedShadeCount": 0,
"sceneMemberCount": 18, "sceneMemberCount": 18,
"unassignedShadeCount": 0, "unassignedShadeCount": 0,
"multiSceneCount": 0, "multiSceneCount": 0,
"addressKind": "newPrimary", "addressKind": "newPrimary",
"gateway": "192.168.20.1", "gateway": "192.168.0.1",
"localTimeDataSet": true, "localTimeDataSet": true,
"dns": "192.168.20.1", "dns": "192.168.0.1",
"macAddress": "00:00:00:00:00:eb", "macAddress": "AA:BB:CC:DD:EE:FF",
"rfIDInt": 35626 "rfIDInt": 35626
} }
} }

View File

@ -0,0 +1,15 @@
{
"firmware": {
"mainProcessor": {
"name": "Powerview Generation 2",
"revision": 2,
"subRevision": 0,
"build": 1056
},
"radio": {
"revision": 2,
"subRevision": 0,
"build": 2610
}
}
}

View File

@ -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
}
}
]
}

View File

@ -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"
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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"
}
]
}

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
{
"fwVersion": "3.1.472",
"serialNumber": "A1B2C3D4E5G6H7"
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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": []
}
]

File diff suppressed because it is too large Load Diff

View File

@ -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": []
}
]

View File

@ -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]
}
]

View File

@ -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"
}
]

View File

@ -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
}
}

View File

@ -1,187 +1,70 @@
"""Test the Hunter Douglas Powerview config flow.""" """Test the Hunter Douglas Powerview config flow."""
from ipaddress import ip_address from unittest.mock import MagicMock, patch
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import dhcp, zeroconf from homeassistant.components import dhcp, zeroconf
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN 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.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 from tests.common import MockConfigEntry, load_json_object_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),
]
def _get_mock_powerview_userdata(userdata=None, get_resources=None): @pytest.mark.usefixtures("mock_hunterdouglas_hub")
mock_powerview_userdata = MagicMock() @pytest.mark.parametrize("api_version", [1, 2, 3])
if not userdata: async def test_user_form(
userdata = json.loads(load_fixture("hunterdouglas_powerview/userdata.json")) hass: HomeAssistant,
if get_resources: mock_setup_entry: MagicMock,
mock_powerview_userdata.get_resources = AsyncMock(side_effect=get_resources) api_version: int,
else: ) -> None:
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:
"""Test we get the user form.""" """Test we get the user form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == "form" assert result["type"] == FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
mock_powerview_userdata = _get_mock_powerview_userdata() result2 = await hass.config_entries.flow.async_configure(
with patch( result["flow_id"],
"homeassistant.components.hunterdouglas_powerview.UserData", {CONF_HOST: "1.2.3.4"},
return_value=mock_powerview_userdata, )
), patch( await hass.async_block_till_done()
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True, assert result2["type"] == FlowResultType.CREATE_ENTRY
) as mock_setup_entry: assert result2["title"] == f"Powerview Generation {api_version}"
result2 = await hass.config_entries.flow.async_configure( assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
result["flow_id"], assert result2["result"].unique_id == "A1B2C3D4E5G6H7"
{"host": "1.2.3.4"},
)
await hass.async_block_till_done()
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 assert len(mock_setup_entry.mock_calls) == 1
result3 = await hass.config_entries.flow.async_init( result3 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result3["type"] == "form" assert result3["type"] == FlowResultType.FORM
assert result3["errors"] == {} assert result3["errors"] == {}
result4 = await hass.config_entries.flow.async_configure( result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"], 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: @pytest.mark.usefixtures("mock_hunterdouglas_hub")
"""Test we get the user form with a legacy device.""" @pytest.mark.parametrize(("source", "discovery_info", "api_version"), DISCOVERY_DATA)
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)
async def test_form_homekit_and_dhcp_cannot_connect( 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: ) -> None:
"""Test we get the form with homekit and dhcp source.""" """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) ignored_config_entry.add_to_hass(hass)
mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError)
with patch( with patch(
"homeassistant.components.hunterdouglas_powerview.UserData", "homeassistant.components.hunterdouglas_powerview.Hub.query_firmware",
return_value=mock_powerview_userdata, side_effect=TimeoutError,
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
@ -201,13 +83,35 @@ async def test_form_homekit_and_dhcp_cannot_connect(
data=discovery_info, data=discovery_info,
) )
assert result["type"] == "abort" assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect" 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( 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: ) -> None:
"""Test we get the form with homekit and dhcp source.""" """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) ignored_config_entry.add_to_hass(hass)
mock_powerview_userdata = _get_mock_powerview_userdata() result = await hass.config_entries.flow.async_init(
with patch( DOMAIN,
"homeassistant.components.hunterdouglas_powerview.UserData", context={"source": source},
return_value=mock_powerview_userdata, 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["step_id"] == "link"
assert result["errors"] is None assert result["errors"] is None
assert result["description_placeholders"] == { assert result["description_placeholders"] == {
"host": "1.2.3.4", CONF_HOST: "1.2.3.4",
"name": "Hunter Douglas Powerview Hub", CONF_NAME: f"Powerview Generation {api_version}",
CONF_API_VERSION: api_version,
} }
with patch( result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
"homeassistant.components.hunterdouglas_powerview.UserData", await hass.async_block_till_done()
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()
assert result2["type"] == "create_entry" assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Hunter Douglas Powerview Hub" assert result2["title"] == f"Powerview Generation {api_version}"
assert result2["data"] == {"host": "1.2.3.4"} assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result2["result"].unique_id == "ABC123" assert result2["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -257,95 +150,199 @@ async def test_form_homekit_and_dhcp(
context={"source": source}, context={"source": source},
data=discovery_info, 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.""" """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() assert result["type"] == FlowResultType.FORM
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["step_id"] == "link" assert result["step_id"] == "link"
with patch( result2 = await hass.config_entries.flow.async_init(
"homeassistant.components.hunterdouglas_powerview.UserData", DOMAIN,
return_value=mock_powerview_userdata, context={"source": config_entries.SOURCE_DHCP},
): data=dhcp_discovery,
result2 = await hass.config_entries.flow.async_init( )
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY_INFO,
)
assert result2["type"] == "abort" assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "already_in_progress" 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.""" """Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError) # Simulate a timeout error
with patch( with patch(
"homeassistant.components.hunterdouglas_powerview.UserData", "homeassistant.components.hunterdouglas_powerview.Hub.query_firmware",
return_value=mock_powerview_userdata, side_effect=TimeoutError,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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"} 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.""" """Test we handle no data being returned from the hub."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}})
with patch( with patch(
"homeassistant.components.hunterdouglas_powerview.UserData", "homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data",
return_value=mock_powerview_userdata, return_value={},
), patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_home_data",
return_value={},
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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"} 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.""" """Test we handle unknown exception."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}}) # Simulate a transient error
with patch( with patch(
"homeassistant.components.hunterdouglas_powerview.UserData", "homeassistant.components.hunterdouglas_powerview.config_flow.Hub.query_firmware",
return_value=mock_powerview_userdata, side_effect=SyntaxError,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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"} 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

View File

@ -1,26 +1,111 @@
"""Test the Hunter Douglas Powerview scene platform.""" """Test the Hunter Douglas Powerview scene platform."""
from unittest.mock import patch from unittest.mock import patch
import pytest
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON
from homeassistant.const import STATE_UNKNOWN from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import MOCK_MAC from .const import MOCK_MAC
from tests.common import MockConfigEntry 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.""" """Test the scenes."""
entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC) entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC)
entry.add_to_hass(hass) entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2 assert hass.states.async_entity_ids_count(SCENE_DOMAIN) == 18
assert hass.states.get("scene.alexanderhd_one").state == STATE_UNKNOWN assert (
assert hass.states.get("scene.alexanderhd_two").state == STATE_UNKNOWN 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( with patch(
"homeassistant.components.hunterdouglas_powerview.scene.PvScene.activate" "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( await hass.services.async_call(
SCENE_DOMAIN, SCENE_DOMAIN,
SERVICE_TURN_ON, SERVICE_TURN_ON,
{"entity_id": "scene.alexanderhd_one"}, {"entity_id": f"scene.powerview_generation_{api_version}_open_study"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()