diff --git a/.coveragerc b/.coveragerc index 2aabc0d80281d1b3a4ec9349cc836af2e982ef10..cf9fa59397da42f517872911c8c2f5090c29f903 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index f60da8494d79865303b93fb73c7ddc2f8c0b2d9f..51ce87e702f15cab18cab237c69e89f24376b901 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 14ede545576898dad49a1a1eaf39a95bdc6bd83c..44ebf25a4f4c19bb788fb88d167228818ec2e972 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -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 diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..52a70b85d2e0dc58ec50a1391922fd95f9d4289c --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -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.""" diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py new file mode 100644 index 0000000000000000000000000000000000000000..9979cfb186c82eb07fe2b1162be8291e7c2f1c86 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -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) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py new file mode 100644 index 0000000000000000000000000000000000000000..45fd798238f58eb94d8cf5e708649f3e5f4f952f --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -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() diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..03d20e027b8bd4ed2b1144e77265b72bd972acd1 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -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, + } diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index 68fc6118a34e84d3e72137639da11d38e64ae44d..b68ec02d3f689578e9fb0452176d16af53603ae1 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -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"] + } +} \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index b73ce8fd7d5ee99c231d519e6966f0d1ff6dfba4..0e98ce0448d14831faccccb85cec2f664744ee12 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -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.""" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Home Assistant scene entries.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: config[HUB_ADDRESS]}, + ) + ) - hub_address = config.get(HUB_ADDRESS) - websession = async_get_clientsession(hass) - pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up powerview scene entries.""" - _scenes = await Scenes(pv_request).get_resources() - _rooms = await Rooms(pv_request).get_resources() + 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] - 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): diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..61bc0735613dd7095ca21223ac23577694100831 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/translations/en.json b/homeassistant/components/hunterdouglas_powerview/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..b2e9c1f207e65e8493e3d667eb86f2440039eacc --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/translations/en.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 636dab4de2709e2cd58f98957b26f6d5c524df72..6e259c2bf84afbb0cd798a1277a389732034cf96 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ FLOWS = [ "homematicip_cloud", "huawei_lte", "hue", + "hunterdouglas_powerview", "iaqualink", "icloud", "ifttt", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fa0c5ad593aa34975b91000bc6de7c07f19b872b..ddf9b2cdb6756c30cd3165294c5384da31d3a05e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -49,6 +49,7 @@ HOMEKIT = { "Healty Home Coach": "netatmo", "LIFX": "lifx", "Netatmo Relay": "netatmo", + "PowerView": "hunterdouglas_powerview", "Presence": "netatmo", "Rachio": "rachio", "TRADFRI": "tradfri", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f02da029de75926f0769fefcb6dc0f9c1ed5de46..47362e55aee8f8b9a01b7782595b7db7fcc53a07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/hunterdouglas_powerview/__init__.py b/tests/components/hunterdouglas_powerview/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..034d845b1109f5cf7aa8dc4085f956a0e3f4b7ff --- /dev/null +++ b/tests/components/hunterdouglas_powerview/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hunter Douglas PowerView integration.""" diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..67b2c2dc954b9192e7f98aab1436d3f185b27485 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -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"} diff --git a/tests/fixtures/hunterdouglas_powerview/userdata.json b/tests/fixtures/hunterdouglas_powerview/userdata.json new file mode 100644 index 0000000000000000000000000000000000000000..ca5eea73f7b7864650acaa92647e263c58160c2c --- /dev/null +++ b/tests/fixtures/hunterdouglas_powerview/userdata.json @@ -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 + } +}