Config flow for hunterdouglas_powerview (#34795)

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2020-04-29 16:24:57 -05:00 committed by GitHub
parent 6ae7f31947
commit 6c18a2cae2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1140 additions and 60 deletions

View file

@ -311,7 +311,10 @@ omit =
homeassistant/components/huawei_lte/*
homeassistant/components/huawei_router/device_tracker.py
homeassistant/components/hue/light.py
homeassistant/components/hunterdouglas_powerview/__init__.py
homeassistant/components/hunterdouglas_powerview/scene.py
homeassistant/components/hunterdouglas_powerview/cover.py
homeassistant/components/hunterdouglas_powerview/entity.py
homeassistant/components/hydrawise/*
homeassistant/components/hyperion/light.py
homeassistant/components/ialarm/alarm_control_panel.py

View file

@ -174,6 +174,7 @@ homeassistant/components/http/* @home-assistant/core
homeassistant/components/huawei_lte/* @scop
homeassistant/components/huawei_router/* @abmantis
homeassistant/components/hue/* @balloob
homeassistant/components/hunterdouglas_powerview/* @bdraco
homeassistant/components/iammeter/* @lewei50
homeassistant/components/iaqualink/* @flz
homeassistant/components/icloud/* @Quentame

View file

@ -1 +1,194 @@
"""The hunterdouglas_powerview component."""
"""The Hunter Douglas PowerView integration."""
import asyncio
from datetime import timedelta
import logging
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.helpers.tools import base64_to_unicode
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades
from aiopvapi.userdata import UserData
import async_timeout
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
COORDINATOR,
DEVICE_FIRMWARE,
DEVICE_INFO,
DEVICE_MAC_ADDRESS,
DEVICE_MODEL,
DEVICE_NAME,
DEVICE_REVISION,
DEVICE_SERIAL_NUMBER,
DOMAIN,
FIRMWARE_IN_USERDATA,
HUB_EXCEPTIONS,
HUB_NAME,
MAC_ADDRESS_IN_USERDATA,
MAINPROCESSOR_IN_USERDATA_FIRMWARE,
MODEL_IN_MAINPROCESSOR,
PV_API,
PV_ROOM_DATA,
PV_SCENE_DATA,
PV_SHADE_DATA,
PV_SHADES,
REVISION_IN_MAINPROCESSOR,
ROOM_DATA,
SCENE_DATA,
SERIAL_NUMBER_IN_USERDATA,
SHADE_DATA,
USER_DATA,
)
DEVICE_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, extra=vol.ALLOW_EXTRA
)
def _has_all_unique_hosts(value):
"""Validate that each hub configured has a unique host."""
hosts = [device[CONF_HOST] for device in value]
schema = vol.Schema(vol.Unique())
schema(hosts)
return value
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_hosts)},
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = ["cover", "scene"]
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, hass_config: dict):
"""Set up the Hunter Douglas PowerView component."""
hass.data.setdefault(DOMAIN, {})
if DOMAIN not in hass_config:
return True
for conf in hass_config[DOMAIN]:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Hunter Douglas PowerView from a config entry."""
config = entry.data
hub_address = config.get(CONF_HOST)
websession = async_get_clientsession(hass)
pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession)
try:
async with async_timeout.timeout(10):
device_info = await async_get_device_info(pv_request)
except HUB_EXCEPTIONS:
_LOGGER.error("Connection error to PowerView hub: %s", hub_address)
raise ConfigEntryNotReady
if not device_info:
_LOGGER.error("Unable to initialize PowerView hub: %s", hub_address)
raise ConfigEntryNotReady
rooms = Rooms(pv_request)
room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA])
scenes = Scenes(pv_request)
scene_data = _async_map_data_by_id((await scenes.get_resources())[SCENE_DATA])
shades = Shades(pv_request)
shade_data = _async_map_data_by_id((await shades.get_resources())[SHADE_DATA])
async def async_update_data():
"""Fetch data from shade endpoint."""
async with async_timeout.timeout(10):
shade_entries = await shades.get_resources()
if not shade_entries:
raise UpdateFailed(f"Failed to fetch new shade data.")
return _async_map_data_by_id(shade_entries[SHADE_DATA])
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="powerview hub",
update_method=async_update_data,
update_interval=timedelta(seconds=60),
)
hass.data[DOMAIN][entry.entry_id] = {
PV_API: pv_request,
PV_ROOM_DATA: room_data,
PV_SCENE_DATA: scene_data,
PV_SHADES: shades,
PV_SHADE_DATA: shade_data,
COORDINATOR: coordinator,
DEVICE_INFO: device_info,
}
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_get_device_info(pv_request):
"""Determine device info."""
userdata = UserData(pv_request)
resources = await userdata.get_resources()
userdata_data = resources[USER_DATA]
main_processor_info = userdata_data[FIRMWARE_IN_USERDATA][
MAINPROCESSOR_IN_USERDATA_FIRMWARE
]
return {
DEVICE_NAME: base64_to_unicode(userdata_data[HUB_NAME]),
DEVICE_MAC_ADDRESS: userdata_data[MAC_ADDRESS_IN_USERDATA],
DEVICE_SERIAL_NUMBER: userdata_data[SERIAL_NUMBER_IN_USERDATA],
DEVICE_REVISION: main_processor_info[REVISION_IN_MAINPROCESSOR],
DEVICE_FIRMWARE: main_processor_info,
DEVICE_MODEL: main_processor_info[MODEL_IN_MAINPROCESSOR],
}
@callback
def _async_map_data_by_id(data):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,135 @@
"""Config flow for Hunter Douglas PowerView integration."""
import logging
from aiopvapi.helpers.aiorequest import AioRequest
import async_timeout
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import async_get_device_info
from .const import DEVICE_NAME, DEVICE_SERIAL_NUMBER, HUB_EXCEPTIONS
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
HAP_SUFFIX = "._hap._tcp.local."
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
hub_address = data[CONF_HOST]
websession = async_get_clientsession(hass)
pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession)
try:
async with async_timeout.timeout(10):
device_info = await async_get_device_info(pv_request)
except HUB_EXCEPTIONS:
raise CannotConnect
if not device_info:
raise CannotConnect
# Return info that you want to store in the config entry.
return {
"title": device_info[DEVICE_NAME],
"unique_id": device_info[DEVICE_SERIAL_NUMBER],
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hunter Douglas PowerView."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize the powerview config flow."""
self.powerview_config = {}
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
if self._host_already_configured(user_input[CONF_HOST]):
return self.async_abort(reason="already_configured")
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
await self.async_set_unique_id(info["unique_id"])
return self.async_create_entry(
title=info["title"], data={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input=None):
"""Handle the initial step."""
return await self.async_step_user(user_input)
async def async_step_homekit(self, homekit_info):
"""Handle HomeKit discovery."""
# If we already have the host configured do
# not open connections to it if we can avoid it.
if self._host_already_configured(homekit_info[CONF_HOST]):
return self.async_abort(reason="already_configured")
try:
info = await validate_input(self.hass, homekit_info)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
return self.async_abort(reason="unknown")
await self.async_set_unique_id(info["unique_id"], raise_on_progress=False)
self._abort_if_unique_id_configured({CONF_HOST: homekit_info["host"]})
name = homekit_info["name"]
if name.endswith(HAP_SUFFIX):
name = name[: -len(HAP_SUFFIX)]
self.powerview_config = {
CONF_HOST: homekit_info["host"],
CONF_NAME: name,
}
return await self.async_step_link()
async def async_step_link(self, user_input=None):
"""Attempt to link with Powerview."""
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]},
)
return self.async_show_form(
step_id="link", description_placeholders=self.powerview_config
)
def _host_already_configured(self, host):
"""See if we already have a hub with the host address configured."""
existing_hosts = {
entry.data[CONF_HOST] for entry in self._async_current_entries()
}
return host in existing_hosts
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

View file

@ -0,0 +1,65 @@
"""Support for Powerview scenes from a Powerview hub."""
import asyncio
from aiopvapi.helpers.aiorequest import PvApiConnectionError
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"
FIRMWARE_IN_USERDATA = "firmware"
MAINPROCESSOR_IN_USERDATA_FIRMWARE = "mainProcessor"
REVISION_IN_MAINPROCESSOR = "revision"
MODEL_IN_MAINPROCESSOR = "name"
HUB_NAME = "hubName"
FIRMWARE_IN_SHADE = "firmware"
FIRMWARE_REVISION = "revision"
FIRMWARE_SUB_REVISION = "subRevision"
FIRMWARE_BUILD = "build"
DEVICE_NAME = "device_name"
DEVICE_MAC_ADDRESS = "device_mac_address"
DEVICE_SERIAL_NUMBER = "device_serial_number"
DEVICE_REVISION = "device_revision"
DEVICE_INFO = "device_info"
DEVICE_MODEL = "device_model"
DEVICE_FIRMWARE = "device_firmware"
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_RESPONSE = "shade"
STATE_ATTRIBUTE_ROOM_NAME = "roomName"
PV_API = "pv_api"
PV_HUB = "pv_hub"
PV_SHADES = "pv_shades"
PV_SCENE_DATA = "pv_scene_data"
PV_SHADE_DATA = "pv_shade_data"
PV_ROOM_DATA = "pv_room_data"
COORDINATOR = "coordinator"
HUB_EXCEPTIONS = (asyncio.TimeoutError, PvApiConnectionError)

View file

@ -0,0 +1,306 @@
"""Support for hunter douglas shades."""
import asyncio
import logging
from aiopvapi.helpers.constants import ATTR_POSITION1, ATTR_POSITION_DATA
from aiopvapi.resources.shade import (
ATTR_POSKIND1,
ATTR_TYPE,
MAX_POSITION,
MIN_POSITION,
factory as PvShade,
)
import async_timeout
from homeassistant.components.cover import (
ATTR_POSITION,
DEVICE_CLASS_SHADE,
SUPPORT_CLOSE,
SUPPORT_OPEN,
SUPPORT_SET_POSITION,
SUPPORT_STOP,
CoverEntity,
)
from homeassistant.core import callback
from homeassistant.helpers.event import async_call_later
from .const import (
COORDINATOR,
DEVICE_INFO,
DEVICE_MODEL,
DEVICE_SERIAL_NUMBER,
DOMAIN,
FIRMWARE_BUILD,
FIRMWARE_IN_SHADE,
FIRMWARE_REVISION,
FIRMWARE_SUB_REVISION,
MANUFACTURER,
PV_API,
PV_ROOM_DATA,
PV_SHADE_DATA,
ROOM_ID_IN_SHADE,
ROOM_NAME_UNICODE,
SHADE_RESPONSE,
STATE_ATTRIBUTE_ROOM_NAME,
)
from .entity import HDEntity
_LOGGER = logging.getLogger(__name__)
# Estimated time it takes to complete a transition
# from one state to another
TRANSITION_COMPLETE_DURATION = 30
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the hunter douglas shades."""
pv_data = hass.data[DOMAIN][entry.entry_id]
room_data = pv_data[PV_ROOM_DATA]
shade_data = pv_data[PV_SHADE_DATA]
pv_request = pv_data[PV_API]
coordinator = pv_data[COORDINATOR]
device_info = pv_data[DEVICE_INFO]
entities = []
for raw_shade in 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 = PvShade(raw_shade, pv_request)
name_before_refresh = shade.name
try:
async with async_timeout.timeout(1):
await shade.refresh()
except asyncio.TimeoutError:
# Forced refresh is not required for setup
pass
entities.append(
PowerViewShade(
shade, name_before_refresh, room_data, coordinator, device_info
)
)
async_add_entities(entities)
def hd_position_to_hass(hd_position):
"""Convert hunter douglas position to hass position."""
return round((hd_position / MAX_POSITION) * 100)
def hass_position_to_hd(hass_positon):
"""Convert hass position to hunter douglas position."""
return int(hass_positon / 100 * MAX_POSITION)
class PowerViewShade(HDEntity, CoverEntity):
"""Representation of a powerview shade."""
def __init__(self, shade, name, room_data, coordinator, device_info):
"""Initialize the shade."""
room_id = shade.raw_data.get(ROOM_ID_IN_SHADE)
super().__init__(coordinator, device_info, shade.id)
self._shade = shade
self._device_info = device_info
self._is_opening = False
self._is_closing = False
self._room_name = None
self._last_action_timestamp = 0
self._scheduled_transition_update = None
self._name = name
self._room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "")
self._current_cover_position = MIN_POSITION
self._coordinator = coordinator
@property
def device_state_attributes(self):
"""Return the state attributes."""
return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name}
@property
def supported_features(self):
"""Flag supported features."""
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
if self._device_info[DEVICE_MODEL] != "1":
supported_features |= SUPPORT_STOP
return supported_features
@property
def is_closed(self):
"""Return if the cover is closed."""
return self._current_cover_position == MIN_POSITION
@property
def is_opening(self):
"""Return if the cover is opening."""
return self._is_opening
@property
def is_closing(self):
"""Return if the cover is closing."""
return self._is_closing
@property
def current_cover_position(self):
"""Return the current position of cover."""
return hd_position_to_hass(self._current_cover_position)
@property
def device_class(self):
"""Return device class."""
return DEVICE_CLASS_SHADE
@property
def name(self):
"""Return the name of the shade."""
return self._name
async def async_close_cover(self, **kwargs):
"""Close the cover."""
await self._async_move(0)
async def async_open_cover(self, **kwargs):
"""Open the cover."""
await self._async_move(100)
async def async_stop_cover(self, **kwargs):
"""Stop the cover."""
# Cancel any previous updates
self._async_cancel_scheduled_transition_update()
self._async_update_from_command(await self._shade.stop())
await self._async_force_refresh_state()
async def set_cover_position(self, **kwargs):
"""Move the shade to a specific position."""
if ATTR_POSITION not in kwargs:
return
await self._async_move(kwargs[ATTR_POSITION])
async def _async_move(self, target_hass_position):
"""Move the shade to a position."""
current_hass_position = hd_position_to_hass(self._current_cover_position)
steps_to_move = abs(current_hass_position - target_hass_position)
if not steps_to_move:
return
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(
await self._shade.move(
{
ATTR_POSITION1: hass_position_to_hd(target_hass_position),
ATTR_POSKIND1: 1,
}
)
)
self._is_opening = False
self._is_closing = False
if target_hass_position > current_hass_position:
self._is_opening = True
elif target_hass_position < current_hass_position:
self._is_closing = True
self.async_write_ha_state()
@callback
def _async_update_from_command(self, raw_data):
"""Update the shade state after a command."""
if not raw_data or SHADE_RESPONSE not in raw_data:
return
self._async_process_new_shade_data(raw_data[SHADE_RESPONSE])
@callback
def _async_process_new_shade_data(self, data):
"""Process new data from an update."""
self._shade.raw_data = data
self._async_update_current_cover_position()
@callback
def _async_update_current_cover_position(self):
"""Update the current cover position from the data."""
_LOGGER.debug("Raw data update: %s", self._shade.raw_data)
position_data = self._shade.raw_data[ATTR_POSITION_DATA]
if ATTR_POSITION1 in position_data:
self._current_cover_position = position_data[ATTR_POSITION1]
self._is_opening = False
self._is_closing = False
@callback
def _async_cancel_scheduled_transition_update(self):
"""Cancel any previous updates."""
if not self._scheduled_transition_update:
return
self._scheduled_transition_update()
self._scheduled_transition_update = None
@callback
def _async_schedule_update_for_transition(self, steps):
self.async_write_ha_state()
# Cancel any previous updates
self._async_cancel_scheduled_transition_update()
est_time_to_complete_transition = 1 + int(
TRANSITION_COMPLETE_DURATION * (steps / 100)
)
_LOGGER.debug(
"Estimated time to complete transition of %s steps for %s: %s",
steps,
self.name,
est_time_to_complete_transition,
)
# Schedule an update for when we expect the transition
# to be completed.
self._scheduled_transition_update = async_call_later(
self.hass,
est_time_to_complete_transition,
self._async_complete_schedule_update,
)
async def _async_complete_schedule_update(self, _):
"""Update status of the cover."""
_LOGGER.debug("Processing scheduled update for %s", self.name)
self._scheduled_transition_update = None
await self._async_force_refresh_state()
async def _async_force_refresh_state(self):
"""Refresh the cover state and force the device cache to be bypassed."""
await self._shade.refresh()
self._async_update_current_cover_position()
self.async_write_ha_state()
@property
def device_info(self):
"""Return the device_info of the device."""
firmware = self._shade.raw_data[FIRMWARE_IN_SHADE]
sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}"
model = self._shade.raw_data[ATTR_TYPE]
for shade in self._shade.shade_types:
if shade.shade_type == model:
model = shade.description
break
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.name,
"model": str(model),
"sw_version": sw_version,
"manufacturer": MANUFACTURER,
"via_device": (DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]),
}
async def async_added_to_hass(self):
"""When entity is added to hass."""
self._async_update_current_cover_position()
self.async_on_remove(
self._coordinator.async_add_listener(self._async_update_shade_from_group)
)
@callback
def _async_update_shade_from_group(self):
"""Update with new data from the coordinator."""
if self._scheduled_transition_update:
# If a transition in in progress
# the data will be wrong
return
self._async_process_new_shade_data(self._coordinator.data[self._shade.id])
self.async_write_ha_state()

View file

@ -0,0 +1,59 @@
"""The nexia integration base entity."""
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import Entity
from .const import (
DEVICE_FIRMWARE,
DEVICE_MAC_ADDRESS,
DEVICE_MODEL,
DEVICE_NAME,
DEVICE_SERIAL_NUMBER,
DOMAIN,
FIRMWARE_BUILD,
FIRMWARE_REVISION,
FIRMWARE_SUB_REVISION,
MANUFACTURER,
)
class HDEntity(Entity):
"""Base class for hunter douglas entities."""
def __init__(self, coordinator, device_info, unique_id):
"""Initialize the entity."""
super().__init__()
self._coordinator = coordinator
self._unique_id = unique_id
self._device_info = device_info
@property
def available(self):
"""Return True if entity is available."""
return self._coordinator.last_update_success
@property
def unique_id(self):
"""Return the unique id."""
return self._unique_id
@property
def should_poll(self):
"""Return False, updates are controlled via coordinator."""
return False
@property
def device_info(self):
"""Return the device_info of the device."""
firmware = self._device_info[DEVICE_FIRMWARE]
sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}"
return {
"identifiers": {(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER])},
"connections": {
(dr.CONNECTION_NETWORK_MAC, self._device_info[DEVICE_MAC_ADDRESS])
},
"name": self._device_info[DEVICE_NAME],
"model": self._device_info[DEVICE_MODEL],
"sw_version": sw_version,
"manufacturer": MANUFACTURER,
}

View file

@ -2,6 +2,12 @@
"domain": "hunterdouglas_powerview",
"name": "Hunter Douglas PowerView",
"documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview",
"requirements": ["aiopvapi==1.6.14"],
"codeowners": []
"requirements": [
"aiopvapi==1.6.14"
],
"codeowners": ["@bdraco"],
"config_flow": true,
"homekit": {
"models": ["PowerView"]
}
}

View file

@ -2,86 +2,73 @@
import logging
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.resources.scene import Scene as PvScene
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
import voluptuous as vol
from homeassistant.components.scene import DOMAIN, Scene
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.components.scene import Scene
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_PLATFORM
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from .const import (
COORDINATOR,
DEVICE_INFO,
DOMAIN,
HUB_ADDRESS,
PV_API,
PV_ROOM_DATA,
PV_SCENE_DATA,
ROOM_NAME_UNICODE,
STATE_ATTRIBUTE_ROOM_NAME,
)
from .entity import HDEntity
_LOGGER = logging.getLogger(__name__)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
HUB_ADDRESS = "address"
PLATFORM_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "hunterdouglas_powerview",
vol.Required(HUB_ADDRESS): cv.string,
}
{vol.Required(CONF_PLATFORM): DOMAIN, vol.Required(HUB_ADDRESS): cv.string}
)
SCENE_DATA = "sceneData"
ROOM_DATA = "roomData"
SCENE_NAME = "name"
ROOM_NAME = "name"
SCENE_ID = "id"
ROOM_ID = "id"
ROOM_ID_IN_SCENE = "roomId"
STATE_ATTRIBUTE_ROOM_NAME = "roomName"
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Import platform from yaml."""
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: config[HUB_ADDRESS]},
)
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up Home Assistant scene entries."""
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up powerview scene entries."""
hub_address = config.get(HUB_ADDRESS)
websession = async_get_clientsession(hass)
pv_data = hass.data[DOMAIN][entry.entry_id]
room_data = pv_data[PV_ROOM_DATA]
scene_data = pv_data[PV_SCENE_DATA]
pv_request = pv_data[PV_API]
coordinator = pv_data[COORDINATOR]
device_info = pv_data[DEVICE_INFO]
pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession)
_scenes = await Scenes(pv_request).get_resources()
_rooms = await Rooms(pv_request).get_resources()
if not _scenes or not _rooms:
_LOGGER.error("Unable to initialize PowerView hub: %s", hub_address)
return
pvscenes = (
PowerViewScene(hass, PvScene(_raw_scene, pv_request), _rooms)
for _raw_scene in _scenes[SCENE_DATA]
PowerViewScene(
PvScene(raw_scene, pv_request), room_data, coordinator, device_info
)
for scene_id, raw_scene in scene_data.items()
)
async_add_entities(pvscenes)
class PowerViewScene(Scene):
class PowerViewScene(HDEntity, Scene):
"""Representation of a Powerview scene."""
def __init__(self, hass, scene, room_data):
def __init__(self, scene, room_data, coordinator, device_info):
"""Initialize the scene."""
super().__init__(coordinator, device_info, scene.id)
self._scene = scene
self.hass = hass
self._room_name = None
self._sync_room_data(room_data)
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, str(self._scene.id), hass=hass
)
def _sync_room_data(self, room_data):
"""Sync room data."""
room = next(
(
room
for room in room_data[ROOM_DATA]
if room[ROOM_ID] == self._scene.room_id
),
{},
)
self._room_name = room.get(ROOM_NAME, "")
self._room_name = room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "")
@property
def name(self):

View file

@ -0,0 +1,24 @@
{
"title": "Hunter Douglas PowerView",
"config": {
"step": {
"user": {
"title": "Connect to the PowerView Hub",
"data": {
"host": "IP Address"
}
},
"link": {
"title": "Connect to the PowerView Hub",
"description": "Do you want to setup {name} ({host})?"
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
}
}

View file

@ -0,0 +1,25 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the PowerView Hub",
"data": {
"host": "IP Address"
}
},
"link": {
"title": "Connect to the PowerView Hub",
"description": "Do you want to setup {name} ({host})?"
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured",
"cannot_connect": "Failed to connect, please try again",
"unknown": "Unexpected error"
}
}
}

View file

@ -54,6 +54,7 @@ FLOWS = [
"homematicip_cloud",
"huawei_lte",
"hue",
"hunterdouglas_powerview",
"iaqualink",
"icloud",
"ifttt",

View file

@ -49,6 +49,7 @@ HOMEKIT = {
"Healty Home Coach": "netatmo",
"LIFX": "lifx",
"Netatmo Relay": "netatmo",
"PowerView": "hunterdouglas_powerview",
"Presence": "netatmo",
"Rachio": "rachio",
"TRADFRI": "tradfri",

View file

@ -85,6 +85,9 @@ aiohue==2.1.0
# homeassistant.components.notion
aionotion==1.1.0
# homeassistant.components.hunterdouglas_powerview
aiopvapi==1.6.14
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==1.0.2

View file

@ -0,0 +1 @@
"""Tests for the Hunter Douglas PowerView integration."""

View file

@ -0,0 +1,220 @@
"""Test the Logitech Harmony Hub config flow."""
import asyncio
import json
from asynctest import CoroutineMock, MagicMock, patch
from homeassistant import config_entries, setup
from homeassistant.components.hunterdouglas_powerview.const import DOMAIN
from tests.common import load_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:
type(mock_powerview_userdata).get_resources = CoroutineMock(
side_effect=get_resources
)
else:
type(mock_powerview_userdata).get_resources = CoroutineMock(
return_value=userdata
)
return mock_powerview_userdata
async def test_user_form(hass):
"""Test we get the user form."""
await setup.async_setup_component(hass, "persistent_notification", {})
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_userdata()
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
), patch(
"homeassistant.components.hunterdouglas_powerview.async_setup",
return_value=True,
) as mock_setup, 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"},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "AlexanderHD"
assert result2["data"] == {
"host": "1.2.3.4",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
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"
async def test_form_import(hass):
"""Test we get the form with import source."""
await setup.async_setup_component(hass, "persistent_notification", {})
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",
return_value=True,
) as mock_setup, patch(
"homeassistant.components.hunterdouglas_powerview.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={"host": "1.2.3.4"},
)
assert result["type"] == "create_entry"
assert result["title"] == "AlexanderHD"
assert result["data"] == {
"host": "1.2.3.4",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_homekit(hass):
"""Test we get the form with homekit source."""
await setup.async_setup_component(hass, "persistent_notification", {})
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": "homekit"},
data={
"host": "1.2.3.4",
"properties": {"id": "AA::BB::CC::DD::EE::FF"},
"name": "PowerViewHub._hap._tcp.local.",
},
)
assert result["type"] == "form"
assert result["step_id"] == "link"
assert result["errors"] is None
assert result["description_placeholders"] == {
"host": "1.2.3.4",
"name": "PowerViewHub",
}
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
), patch(
"homeassistant.components.hunterdouglas_powerview.async_setup",
return_value=True,
) as mock_setup, 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"], {})
assert result2["type"] == "create_entry"
assert result2["title"] == "PowerViewHub"
assert result2["data"] == {"host": "1.2.3.4"}
assert result2["result"].unique_id == "ABC123"
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
result3 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "homekit"},
data={
"host": "1.2.3.4",
"properties": {"id": "AA::BB::CC::DD::EE::FF"},
"name": "PowerViewHub._hap._tcp.local.",
},
)
assert result3["type"] == "abort"
async def test_form_cannot_connect(hass):
"""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=asyncio.TimeoutError
)
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_no_data(hass):
"""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,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
async def test_form_unknown_exception(hass):
"""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": {}})
with patch(
"homeassistant.components.hunterdouglas_powerview.UserData",
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}

View file

@ -0,0 +1,50 @@
{
"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
}
}