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
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.api_base import ApiEntryPoint
from aiopvapi.helpers.tools import base64_to_unicode
from aiopvapi.hub import Hub
from aiopvapi.resources.model import PowerviewData
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades
from aiopvapi.userdata import UserData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import (
API_PATH_FWVERSION,
DEFAULT_LEGACY_MAINPROCESSOR,
DOMAIN,
FIRMWARE,
FIRMWARE_MAINPROCESSOR,
FIRMWARE_NAME,
HUB_EXCEPTIONS,
HUB_NAME,
MAC_ADDRESS_IN_USERDATA,
ROOM_DATA,
SCENE_DATA,
SERIAL_NUMBER_IN_USERDATA,
SHADE_DATA,
USER_DATA,
)
from .const import DOMAIN, HUB_EXCEPTIONS
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_map_data_by_id
PARALLEL_UPDATES = 1
@ -58,46 +41,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config = entry.data
hub_address = config[CONF_HOST]
api_version = config.get(CONF_API_VERSION, None)
_LOGGER.debug("Connecting %s at %s with v%s api", DOMAIN, hub_address, api_version)
websession = async_get_clientsession(hass)
pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession)
pv_request = AioRequest(
hub_address, loop=hass.loop, websession=websession, api_version=api_version
)
try:
async with asyncio.timeout(10):
device_info = await async_get_device_info(pv_request, hub_address)
async with asyncio.timeout(10):
rooms = Rooms(pv_request)
room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA])
async with asyncio.timeout(10):
scenes = Scenes(pv_request)
scene_data = async_map_data_by_id(
(await scenes.get_resources())[SCENE_DATA]
)
async with asyncio.timeout(10):
shades = Shades(pv_request)
shade_entries = await shades.get_resources()
shade_data = async_map_data_by_id(shade_entries[SHADE_DATA])
hub = Hub(pv_request)
await hub.query_firmware()
device_info = await async_get_device_info(hub)
except HUB_EXCEPTIONS as err:
raise ConfigEntryNotReady(
f"Connection error to PowerView hub: {hub_address}: {err}"
f"Connection error to PowerView hub {hub_address}: {err}"
) from err
if hub.role != "Primary":
# this should be caught in config_flow, but account for a hub changing roles
# this will only happen manually by a user
_LOGGER.error(
"%s (%s) is performing role of %s Hub. "
"Only the Primary Hub can manage shades",
hub.name,
hub.hub_address,
hub.role,
)
return False
try:
async with asyncio.timeout(10):
rooms = Rooms(pv_request)
room_data: PowerviewData = await rooms.get_rooms()
async with asyncio.timeout(10):
scenes = Scenes(pv_request)
scene_data: PowerviewData = await scenes.get_scenes()
async with asyncio.timeout(10):
shades = Shades(pv_request)
shade_data: PowerviewData = await shades.get_shades()
except HUB_EXCEPTIONS as err:
raise ConfigEntryNotReady(
f"Connection error to PowerView hub {hub_address}: {err}"
) from err
if not device_info:
raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}")
coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address)
if CONF_API_VERSION not in config:
new_data = {**entry.data}
new_data[CONF_API_VERSION] = hub.api_version
hass.config_entries.async_update_entry(entry, data=new_data)
coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub)
coordinator.async_set_updated_data(PowerviewShadeData())
# populate raw shade data into the coordinator for diagnostics
coordinator.data.store_group_data(shade_entries[SHADE_DATA])
coordinator.data.store_group_data(shade_data)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData(
api=pv_request,
room_data=room_data,
scene_data=scene_data,
shade_data=shade_data,
room_data=room_data.processed,
scene_data=scene_data.processed,
shade_data=shade_data.processed,
coordinator=coordinator,
device_info=device_info,
)
@ -107,39 +114,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_get_device_info(
pv_request: AioRequest, hub_address: str
) -> PowerviewDeviceInfo:
async def async_get_device_info(hub: Hub) -> PowerviewDeviceInfo:
"""Determine device info."""
userdata = UserData(pv_request)
resources = await userdata.get_resources()
userdata_data = resources[USER_DATA]
if FIRMWARE in userdata_data:
main_processor_info = userdata_data[FIRMWARE][FIRMWARE_MAINPROCESSOR]
elif userdata_data:
# Legacy devices
fwversion = ApiEntryPoint(pv_request, API_PATH_FWVERSION)
resources = await fwversion.get_resources()
if FIRMWARE in resources:
main_processor_info = resources[FIRMWARE][FIRMWARE_MAINPROCESSOR]
else:
main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR
return PowerviewDeviceInfo(
name=base64_to_unicode(userdata_data[HUB_NAME]),
mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA],
serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA],
firmware=main_processor_info,
model=main_processor_info[FIRMWARE_NAME],
hub_address=hub_address,
name=hub.name,
mac_address=hub.mac_address,
serial_number=hub.serial_number,
firmware=hub.firmware,
model=hub.model,
hub_address=hub.ip,
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

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

View File

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

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 aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError
from aiopvapi.helpers.aiorequest import (
PvApiConnectionError,
PvApiEmptyData,
PvApiMaintenance,
PvApiResponseStatusError,
)
DOMAIN = "hunterdouglas_powerview"
MANUFACTURER = "Hunter Douglas"
HUB_ADDRESS = "address"
SCENE_DATA = "sceneData"
SHADE_DATA = "shadeData"
ROOM_DATA = "roomData"
USER_DATA = "userData"
MAC_ADDRESS_IN_USERDATA = "macAddress"
SERIAL_NUMBER_IN_USERDATA = "serialNumber"
HUB_NAME = "hubName"
FIRMWARE = "firmware"
FIRMWARE_MAINPROCESSOR = "mainProcessor"
FIRMWARE_NAME = "name"
FIRMWARE_REVISION = "revision"
FIRMWARE_SUB_REVISION = "subRevision"
FIRMWARE_BUILD = "build"
REDACT_MAC_ADDRESS = "mac_address"
REDACT_SERIAL_NUMBER = "serial_number"
REDACT_HUB_ADDRESS = "hub_address"
SCENE_NAME = "name"
SCENE_ID = "id"
ROOM_ID_IN_SCENE = "roomId"
SHADE_NAME = "name"
SHADE_ID = "id"
ROOM_ID_IN_SHADE = "roomId"
ROOM_NAME = "name"
ROOM_NAME_UNICODE = "name_unicode"
ROOM_ID = "id"
SHADE_BATTERY_LEVEL = "batteryStrength"
SHADE_BATTERY_LEVEL_MAX = 200
ATTR_SIGNAL_STRENGTH = "signalStrength"
ATTR_SIGNAL_STRENGTH_MAX = 4
STATE_ATTRIBUTE_ROOM_NAME = "roomName"
STATE_ATTRIBUTE_ROOM_NAME = "room_name"
HUB_EXCEPTIONS = (
ServerDisconnectedError,
TimeoutError,
PvApiConnectionError,
PvApiResponseStatusError,
PvApiMaintenance,
PvApiEmptyData,
)
LEGACY_DEVICE_SUB_REVISION = 1
LEGACY_DEVICE_REVISION = 0
LEGACY_DEVICE_BUILD = 0
LEGACY_DEVICE_MODEL = "PowerView Hub"
DEFAULT_LEGACY_MAINPROCESSOR = {
FIRMWARE_REVISION: LEGACY_DEVICE_REVISION,
FIRMWARE_SUB_REVISION: LEGACY_DEVICE_SUB_REVISION,
FIRMWARE_BUILD: LEGACY_DEVICE_BUILD,
FIRMWARE_NAME: LEGACY_DEVICE_MODEL,
}
API_PATH_FWVERSION = "api/fwversion"
POS_KIND_NONE = 0
POS_KIND_PRIMARY = 1
POS_KIND_SECONDARY = 2
POS_KIND_VANE = 3
POS_KIND_ERROR = 4
ATTR_BATTERY_KIND = "batteryKind"
BATTERY_KIND_HARDWIRED = 1
BATTERY_KIND_BATTERY = 2
BATTERY_KIND_RECHARGABLE = 3
POWER_SUPPLY_TYPE_MAP = {
BATTERY_KIND_HARDWIRED: "Hardwired Power Supply",
BATTERY_KIND_BATTERY: "Battery Wand",
BATTERY_KIND_RECHARGABLE: "Rechargeable Battery",
}
POWER_SUPPLY_TYPE_REVERSE_MAP = {v: k for k, v in POWER_SUPPLY_TYPE_MAP.items()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,59 +1,25 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import logging
from typing import Any
from aiopvapi.helpers.constants import (
ATTR_ID,
ATTR_POSITION1,
ATTR_POSITION2,
ATTR_POSITION_DATA,
ATTR_POSKIND1,
ATTR_POSKIND2,
ATTR_SHADE,
)
from aiopvapi.resources.shade import MIN_POSITION
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE
from .util import async_map_data_by_id
POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2))
_LOGGER = logging.getLogger(__name__)
@dataclass
class PowerviewShadeMove:
"""Request to move a powerview shade."""
# The positions to request on the hub
request: dict[str, int]
# The positions that will also change
# as a result of the request that the
# hub will not send back
new_positions: dict[int, int]
@dataclass
class PowerviewShadePositions:
"""Positions for a powerview shade."""
primary: int = MIN_POSITION
secondary: int = MIN_POSITION
vane: int = MIN_POSITION
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._group_data_by_id: dict[int, dict[str | int, Any]] = {}
self.positions: dict[int, PowerviewShadePositions] = {}
self._shade_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
@ -63,17 +29,21 @@ class PowerviewShadeData:
"""Get data for all shades."""
return self._group_data_by_id
def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions:
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
self.positions[shade_id] = PowerviewShadePositions()
self.positions[shade_id] = ShadePosition()
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
self.update_shade_positions(self._group_data_by_id[shade_id])
self.update_shade_positions(self._shade_data_by_id[shade_id])
def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None:
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions
@ -81,37 +51,34 @@ class PowerviewShadeData:
with a shade_id will update a specific shade
from the group data.
"""
self._group_data_by_id = async_map_data_by_id(shade_data)
self._shade_data_by_id = shade_data.processed
self._group_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, position: int, kind: int) -> None:
"""Update a single shade position."""
positions = self.get_shade_positions(shade_id)
if kind == POS_KIND_PRIMARY:
positions.primary = position
elif kind == POS_KIND_SECONDARY:
positions.secondary = position
elif kind == POS_KIND_VANE:
positions.vane = position
def update_shade_position(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades position."""
if shade_id not in self.positions:
self.positions[shade_id] = ShadePosition()
def update_from_position_data(
self, shade_id: int, position_data: dict[str, Any]
) -> None:
"""Update the shade positions from the position data."""
for position_key, kind_key in POSITIONS:
if position_key in position_data:
self.update_shade_position(
shade_id, position_data[position_key], position_data[kind_key]
)
# ShadePosition will return None if the value is not set
if shade_data.primary is not None:
self.positions[shade_id].primary = shade_data.primary
if shade_data.secondary is not None:
self.positions[shade_id].secondary = shade_data.secondary
if shade_data.tilt is not None:
self.positions[shade_id].tilt = shade_data.tilt
def update_shade_positions(self, data: dict[int | str, Any]) -> None:
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
if shade_id not in self.positions:
self.positions[shade_id] = ShadePosition()
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.positions[shade_id].velocity = shade_data.velocity
def update_shade_positions(self, data: BaseShade) -> None:
"""Update a shades from data dict."""
_LOGGER.debug("Raw data update: %s", data)
shade_id = data[ATTR_ID]
position_data = data[ATTR_POSITION_DATA]
self.update_from_position_data(shade_id, position_data)
def update_from_response(self, response: dict[str, Any]) -> None:
"""Update from the response to a command."""
if response and ATTR_SHADE in response:
shade_data: dict[int | str, Any] = response[ATTR_SHADE]
self.update_shade_positions(shade_data)
_LOGGER.debug("Raw data update: %s", data.raw_data)
self.update_shade_position(data.id, data.current_position)

View File

@ -4,7 +4,11 @@
"user": {
"title": "Connect to the PowerView Hub",
"data": {
"host": "[%key:common::config_flow::data::ip%]"
"host": "[%key:common::config_flow::data::ip%]",
"api_version": "Hub Generation"
},
"data_description": {
"api_version": "API version is detectable, but you can override and force a specific version"
}
},
"link": {
@ -15,6 +19,7 @@
"flow_title": "{name} ({host})",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unsupported_device": "Only the primary powerview hub can be added",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {

View File

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

View File

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

View File

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

View File

@ -1,3 +1 @@
"""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."""
import json
from unittest.mock import patch
"""Common fixtures for Hunter Douglas Powerview tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from aiopvapi.resources.shade import ShadePosition
import pytest
from tests.common import load_fixture
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
@pytest.fixture(scope="session")
def powerview_userdata():
"""Return the userdata fixture."""
return json.loads(load_fixture("hunterdouglas_powerview/userdata.json"))
@pytest.fixture(scope="session")
def powerview_fwversion():
"""Return the fwversion fixture."""
return json.loads(load_fixture("hunterdouglas_powerview/fwversion.json"))
@pytest.fixture(scope="session")
def powerview_scenes():
"""Return the scenes fixture."""
return json.loads(load_fixture("hunterdouglas_powerview/scenes.json"))
from tests.common import load_json_object_fixture, load_json_value_fixture
@pytest.fixture
def mock_powerview_v2_hub(powerview_userdata, powerview_fwversion, powerview_scenes):
"""Mock a Powerview v2 hub."""
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData.get_resources",
return_value=powerview_userdata,
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_hunterdouglas_hub(
device_json: str,
home_json: str,
firmware_json: str,
rooms_json: str,
scenes_json: str,
shades_json: str,
) -> Generator[MagicMock, None, None]:
"""Return a mocked Powerview Hub with all data populated."""
with patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data",
return_value=load_json_object_fixture(device_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_home_data",
return_value=load_json_object_fixture(home_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_raw_firmware",
return_value=load_json_object_fixture(firmware_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.Rooms.get_resources",
return_value={"roomData": []},
return_value=load_json_value_fixture(rooms_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.Scenes.get_resources",
return_value=powerview_scenes,
return_value=load_json_value_fixture(scenes_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.Shades.get_resources",
return_value={"shadeData": []},
return_value=load_json_value_fixture(shades_json, DOMAIN),
), patch(
"homeassistant.components.hunterdouglas_powerview.ApiEntryPoint",
return_value=powerview_fwversion,
"homeassistant.components.hunterdouglas_powerview.cover.BaseShade.refresh",
), patch(
"homeassistant.components.hunterdouglas_powerview.cover.BaseShade.current_position",
new_callable=PropertyMock,
return_value=ShadePosition(primary=0, secondary=0, tilt=0, velocity=0),
):
yield
@pytest.fixture
def device_json(api_version: int) -> str:
"""Return the request_raw_data fixture for a specific device."""
if api_version == 1:
return "gen1/userdata.json"
if api_version == 2:
return "gen2/userdata.json"
if api_version == 3:
return "gen3/gateway/primary.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")
@pytest.fixture
def home_json(api_version: int) -> str:
"""Return the request_home_data fixture for a specific device."""
if api_version == 1:
return "gen1/userdata.json"
if api_version == 2:
return "gen2/userdata.json"
if api_version == 3:
return "gen3/home/home.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")
@pytest.fixture
def firmware_json(api_version: int) -> str:
"""Return the request_raw_firmware fixture for a specific device."""
if api_version == 1:
return "gen1/fwversion.json"
if api_version == 2:
return "gen2/fwversion.json"
if api_version == 3:
return "gen3/gateway/info.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")
@pytest.fixture
def rooms_json(api_version: int) -> str:
"""Return the get_resources fixture for a specific device."""
if api_version == 1:
return "gen2/rooms.json"
if api_version == 2:
return "gen2/rooms.json"
if api_version == 3:
return "gen3/home/rooms.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")
@pytest.fixture
def scenes_json(api_version: int) -> str:
"""Return the get_resources fixture for a specific device."""
if api_version == 1:
return "gen2/scenes.json"
if api_version == 2:
return "gen2/scenes.json"
if api_version == 3:
return "gen3/home/scenes.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")
@pytest.fixture
def shades_json(api_version: int) -> str:
"""Return the get_resources fixture for a specific device."""
if api_version == 1:
return "gen2/shades.json"
if api_version == 2:
return "gen2/shades.json"
if api_version == 3:
return "gen3/home/shades.json"
# Add more conditions for different api_versions if needed
raise ValueError(f"Unsupported api_version: {api_version}")

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": {
"mainProcessor": {
"name": "PowerView Hub",
"name": "Powerview Generation 1",
"revision": 1,
"subRevision": 1,
"build": 857

View File

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

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."""
from ipaddress import ip_address
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components import dhcp, zeroconf
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import MOCK_MAC
from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA
from tests.common import MockConfigEntry, load_fixture
ZEROCONF_HOST = "1.2.3.4"
HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
ip_address=ip_address(ZEROCONF_HOST),
ip_addresses=[ip_address(ZEROCONF_HOST)],
hostname="mock_hostname",
name="Hunter Douglas Powerview Hub._hap._tcp.local.",
port=None,
properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC},
type="mock_type",
)
ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
ip_address=ip_address(ZEROCONF_HOST),
ip_addresses=[ip_address(ZEROCONF_HOST)],
hostname="mock_hostname",
name="Hunter Douglas Powerview Hub._powerview._tcp.local.",
port=None,
properties={},
type="mock_type",
)
DHCP_DISCOVERY_INFO = dhcp.DhcpServiceInfo(
hostname="Hunter Douglas Powerview Hub",
ip="1.2.3.4",
macaddress="aabbccddeeff",
)
DISCOVERY_DATA = [
(
config_entries.SOURCE_HOMEKIT,
HOMEKIT_DISCOVERY_INFO,
),
(
config_entries.SOURCE_DHCP,
DHCP_DISCOVERY_INFO,
),
(config_entries.SOURCE_ZEROCONF, ZEROCONF_DISCOVERY_INFO),
]
from tests.common import MockConfigEntry, load_json_object_fixture
def _get_mock_powerview_userdata(userdata=None, get_resources=None):
mock_powerview_userdata = MagicMock()
if not userdata:
userdata = json.loads(load_fixture("hunterdouglas_powerview/userdata.json"))
if get_resources:
mock_powerview_userdata.get_resources = AsyncMock(side_effect=get_resources)
else:
mock_powerview_userdata.get_resources = AsyncMock(return_value=userdata)
return mock_powerview_userdata
def _get_mock_powerview_legacy_userdata(userdata=None, get_resources=None):
mock_powerview_userdata_legacy = MagicMock()
if not userdata:
userdata = json.loads(load_fixture("hunterdouglas_powerview/userdata_v1.json"))
if get_resources:
mock_powerview_userdata_legacy.get_resources = AsyncMock(
side_effect=get_resources
)
else:
mock_powerview_userdata_legacy.get_resources = AsyncMock(return_value=userdata)
return mock_powerview_userdata_legacy
def _get_mock_powerview_fwversion(fwversion=None, get_resources=None):
mock_powerview_fwversion = MagicMock()
if not fwversion:
fwversion = json.loads(load_fixture("hunterdouglas_powerview/fwversion.json"))
if get_resources:
mock_powerview_fwversion.get_resources = AsyncMock(side_effect=get_resources)
else:
mock_powerview_fwversion.get_resources = AsyncMock(return_value=fwversion)
return mock_powerview_fwversion
async def test_user_form(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize("api_version", [1, 2, 3])
async def test_user_form(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
api_version: int,
) -> None:
"""Test we get the user form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
mock_powerview_userdata = _get_mock_powerview_userdata()
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
), patch(
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.2.3.4"},
)
await hass.async_block_till_done()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == f"Powerview Generation {api_version}"
assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result2["result"].unique_id == "A1B2C3D4E5G6H7"
assert result2["type"] == "create_entry"
assert result2["title"] == "AlexanderHD"
assert result2["data"] == {
"host": "1.2.3.4",
}
assert len(mock_setup_entry.mock_calls) == 1
result3 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result3["type"] == "form"
assert result3["type"] == FlowResultType.FORM
assert result3["errors"] == {}
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{"host": "1.2.3.4"},
{CONF_HOST: "1.2.3.4"},
)
assert result4["type"] == "abort"
assert result4["type"] == FlowResultType.ABORT
assert result4["reason"] == "already_configured"
async def test_user_form_legacy(hass: HomeAssistant) -> None:
"""Test we get the user form with a legacy device."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
mock_powerview_userdata = _get_mock_powerview_legacy_userdata()
mock_powerview_fwversion = _get_mock_powerview_fwversion()
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
), patch(
"homeassistant.components.hunterdouglas_powerview.ApiEntryPoint",
return_value=mock_powerview_fwversion,
), patch(
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.2.3.4"},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "PowerView Hub Gen 1"
assert result2["data"] == {
"host": "1.2.3.4",
}
assert len(mock_setup_entry.mock_calls) == 1
result3 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result3["type"] == "form"
assert result3["errors"] == {}
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{"host": "1.2.3.4"},
)
assert result4["type"] == "abort"
@pytest.mark.parametrize(("source", "discovery_info"), DISCOVERY_DATA)
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize(("source", "discovery_info", "api_version"), DISCOVERY_DATA)
async def test_form_homekit_and_dhcp_cannot_connect(
hass: HomeAssistant, source, discovery_info
hass: HomeAssistant,
mock_setup_entry: MagicMock,
source: str,
discovery_info: dhcp.DhcpServiceInfo,
api_version: int,
) -> None:
"""Test we get the form with homekit and dhcp source."""
@ -190,10 +73,9 @@ async def test_form_homekit_and_dhcp_cannot_connect(
)
ignored_config_entry.add_to_hass(hass)
mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError)
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
"homeassistant.components.hunterdouglas_powerview.Hub.query_firmware",
side_effect=TimeoutError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -201,13 +83,35 @@ async def test_form_homekit_and_dhcp_cannot_connect(
data=discovery_info,
)
assert result["type"] == "abort"
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
# test we can recover from the failed entry
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=discovery_info,
)
@pytest.mark.parametrize(("source", "discovery_info"), DISCOVERY_DATA)
result3 = await hass.config_entries.flow.async_configure(result2["flow_id"], {})
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == f"Powerview Generation {api_version}"
assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result3["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize(("source", "discovery_info", "api_version"), DISCOVERY_DATA)
async def test_form_homekit_and_dhcp(
hass: HomeAssistant, source, discovery_info
hass: HomeAssistant,
mock_setup_entry: MagicMock,
source: str,
discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo,
api_version: int,
) -> None:
"""Test we get the form with homekit and dhcp source."""
@ -216,39 +120,28 @@ async def test_form_homekit_and_dhcp(
)
ignored_config_entry.add_to_hass(hass)
mock_powerview_userdata = _get_mock_powerview_userdata()
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=discovery_info,
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=discovery_info,
)
assert result["type"] == "form"
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "link"
assert result["errors"] is None
assert result["description_placeholders"] == {
"host": "1.2.3.4",
"name": "Hunter Douglas Powerview Hub",
CONF_HOST: "1.2.3.4",
CONF_NAME: f"Powerview Generation {api_version}",
CONF_API_VERSION: api_version,
}
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
), patch(
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "Hunter Douglas Powerview Hub"
assert result2["data"] == {"host": "1.2.3.4"}
assert result2["result"].unique_id == "ABC123"
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == f"Powerview Generation {api_version}"
assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result2["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1
@ -257,95 +150,199 @@ async def test_form_homekit_and_dhcp(
context={"source": source},
data=discovery_info,
)
assert result3["type"] == "abort"
assert result3["type"] == FlowResultType.ABORT
async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize(
("homekit_source", "homekit_discovery", "api_version"), HOMEKIT_DATA
)
@pytest.mark.parametrize(
("dhcp_source", "dhcp_discovery", "dhcp_api_version"), DHCP_DATA
)
async def test_discovered_by_homekit_and_dhcp(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
homekit_source: str,
homekit_discovery: zeroconf.ZeroconfServiceInfo,
api_version: int,
dhcp_source: str,
dhcp_discovery: dhcp.DhcpServiceInfo,
dhcp_api_version: int,
) -> None:
"""Test we get the form with homekit and abort for dhcp source when we get both."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HOMEKIT},
data=homekit_discovery,
)
mock_powerview_userdata = _get_mock_powerview_userdata()
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HOMEKIT},
data=HOMEKIT_DISCOVERY_INFO,
)
assert result["type"] == "form"
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "link"
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY_INFO,
)
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=dhcp_discovery,
)
assert result2["type"] == "abort"
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "already_in_progress"
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize("api_version", [1, 2, 3])
async def test_form_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
api_version: int,
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_powerview_userdata = _get_mock_powerview_userdata(get_resources=TimeoutError)
# Simulate a timeout error
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
"homeassistant.components.hunterdouglas_powerview.Hub.query_firmware",
side_effect=TimeoutError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.2.3.4"},
{CONF_HOST: "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
# Now try again without the patch in place to make sure we can recover
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
async def test_form_no_data(hass: HomeAssistant) -> None:
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == f"Powerview Generation {api_version}"
assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result3["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize("api_version", [1, 2, 3])
async def test_form_no_data(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
api_version: int,
) -> None:
"""Test we handle no data being returned from the hub."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}})
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
"homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data",
return_value={},
), patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_home_data",
return_value={},
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.2.3.4"},
{CONF_HOST: "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
# Now try again without the patch in place to make sure we can recover
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == f"Powerview Generation {api_version}"
assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result3["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_unknown_exception(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize("api_version", [1, 2, 3])
async def test_form_unknown_exception(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
api_version: int,
) -> None:
"""Test we handle unknown exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_powerview_userdata = _get_mock_powerview_userdata(userdata={"userData": {}})
# Simulate a transient error
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
"homeassistant.components.hunterdouglas_powerview.config_flow.Hub.query_firmware",
side_effect=SyntaxError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.2.3.4"},
{CONF_HOST: "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
# Now try again without the patch in place to make sure we can recover
result2 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == f"Powerview Generation {api_version}"
assert result2["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result2["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_hunterdouglas_hub")
@pytest.mark.parametrize("api_version", [3]) # only gen 3 present secondary hubs
async def test_form_unsupported_device(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
api_version: int,
) -> None:
"""Test unsupported device failure."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# Simulate a gen 3 secondary hub
with patch(
"homeassistant.components.hunterdouglas_powerview.Hub.request_raw_data",
return_value=load_json_object_fixture("gen3/gateway/secondary.json", DOMAIN),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unsupported_device"}
# Now try again without the patch in place to make sure we can recover
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_HOST: "1.2.3.4"},
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == f"Powerview Generation {api_version}"
assert result3["data"] == {CONF_HOST: "1.2.3.4", CONF_API_VERSION: api_version}
assert result3["result"].unique_id == "A1B2C3D4E5G6H7"
assert len(mock_setup_entry.mock_calls) == 1

View File

@ -1,26 +1,111 @@
"""Test the Hunter Douglas Powerview scene platform."""
from unittest.mock import patch
import pytest
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from . import MOCK_MAC
from .const import MOCK_MAC
from tests.common import MockConfigEntry
async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None:
@pytest.mark.parametrize("api_version", [1, 2, 3])
async def test_scenes(
hass: HomeAssistant,
mock_hunterdouglas_hub: None,
api_version: int,
) -> None:
"""Test the scenes."""
entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2
assert hass.states.get("scene.alexanderhd_one").state == STATE_UNKNOWN
assert hass.states.get("scene.alexanderhd_two").state == STATE_UNKNOWN
assert hass.states.async_entity_ids_count(SCENE_DOMAIN) == 18
assert (
hass.states.get(
f"scene.powerview_generation_{api_version}_close_lounge_room"
).state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_4").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_2").state
== STATE_UNKNOWN
)
assert (
hass.states.get(
f"scene.powerview_generation_{api_version}_close_master_bed"
).state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_family").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_4").state
== STATE_UNKNOWN
)
assert (
hass.states.get(
f"scene.powerview_generation_{api_version}_open_master_bed"
).state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_3").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_family").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_study").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_all").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_all").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_kitchen").state
== STATE_UNKNOWN
)
assert (
hass.states.get(
f"scene.powerview_generation_{api_version}_open_lounge_room"
).state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_bed_2").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_bed_3").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_close_kitchen").state
== STATE_UNKNOWN
)
assert (
hass.states.get(f"scene.powerview_generation_{api_version}_open_study").state
== STATE_UNKNOWN
)
with patch(
"homeassistant.components.hunterdouglas_powerview.scene.PvScene.activate"
@ -28,7 +113,7 @@ async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None:
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": "scene.alexanderhd_one"},
{"entity_id": f"scene.powerview_generation_{api_version}_open_study"},
blocking=True,
)
await hass.async_block_till_done()