From d3e77e00e14267ee5869cd198fbbdfc2344f51cd Mon Sep 17 00:00:00 2001 From: sillyfrog <816454+sillyfrog@users.noreply.github.com> Date: Thu, 22 Jul 2021 22:40:33 +1000 Subject: [PATCH] Add Automate Pulse Hub v2 support (#39501) Co-authored-by: Franck Nijhof Co-authored-by: Sillyfrog --- .coveragerc | 6 + CODEOWNERS | 1 + homeassistant/components/automate/__init__.py | 36 +++++ homeassistant/components/automate/base.py | 93 +++++++++++ .../components/automate/config_flow.py | 37 +++++ homeassistant/components/automate/const.py | 6 + homeassistant/components/automate/cover.py | 147 ++++++++++++++++++ homeassistant/components/automate/helpers.py | 46 ++++++ homeassistant/components/automate/hub.py | 89 +++++++++++ .../components/automate/manifest.json | 13 ++ .../components/automate/strings.json | 19 +++ .../components/automate/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/automate/__init__.py | 1 + tests/components/automate/test_config_flow.py | 69 ++++++++ 17 files changed, 589 insertions(+) create mode 100644 homeassistant/components/automate/__init__.py create mode 100644 homeassistant/components/automate/base.py create mode 100644 homeassistant/components/automate/config_flow.py create mode 100644 homeassistant/components/automate/const.py create mode 100644 homeassistant/components/automate/cover.py create mode 100644 homeassistant/components/automate/helpers.py create mode 100644 homeassistant/components/automate/hub.py create mode 100644 homeassistant/components/automate/manifest.json create mode 100644 homeassistant/components/automate/strings.json create mode 100644 homeassistant/components/automate/translations/en.json create mode 100644 tests/components/automate/__init__.py create mode 100644 tests/components/automate/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2693080986b..4be3d2e5f01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -75,6 +75,12 @@ omit = homeassistant/components/asuswrt/router.py homeassistant/components/aten_pe/* homeassistant/components/atome/* + homeassistant/components/automate/__init__.py + homeassistant/components/automate/base.py + homeassistant/components/automate/const.py + homeassistant/components/automate/cover.py + homeassistant/components/automate/helpers.py + homeassistant/components/automate/hub.py homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py homeassistant/components/aurora/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 249a00a796e..0e92885d247 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ homeassistant/components/august/* @bdraco homeassistant/components/aurora/* @djtimca homeassistant/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core +homeassistant/components/automate/* @sillyfrog homeassistant/components/automation/* @home-assistant/core homeassistant/components/avea/* @pattyland homeassistant/components/awair/* @ahayworth @danielsjf diff --git a/homeassistant/components/automate/__init__.py b/homeassistant/components/automate/__init__.py new file mode 100644 index 00000000000..c4f34d96a05 --- /dev/null +++ b/homeassistant/components/automate/__init__.py @@ -0,0 +1,36 @@ +"""The Automate Pulse Hub v2 integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .hub import PulseHub + +PLATFORMS = ["cover"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Automate Pulse Hub v2 from a config entry.""" + hub = PulseHub(hass, entry) + + if not await hub.async_setup(): + return False + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = hub + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hub = hass.data[DOMAIN][entry.entry_id] + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + if not await hub.async_reset(): + return False + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/automate/base.py b/homeassistant/components/automate/base.py new file mode 100644 index 00000000000..de37933e54d --- /dev/null +++ b/homeassistant/components/automate/base.py @@ -0,0 +1,93 @@ +"""Base class for Automate Roller Blinds.""" +import logging + +import aiopulse2 + +from homeassistant.core import callback +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg + +from .const import AUTOMATE_ENTITY_REMOVE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomateBase(entity.Entity): + """Base representation of an Automate roller.""" + + def __init__(self, roller: aiopulse2.Roller) -> None: + """Initialize the roller.""" + self.roller = roller + + @property + def available(self) -> bool: + """Return True if roller and hub is available.""" + return self.roller.online and self.roller.hub.connected + + async def async_remove_and_unregister(self): + """Unregister from entity and device registry and call entity remove function.""" + _LOGGER.info("Removing %s %s", self.__class__.__name__, self.unique_id) + + ent_registry = await get_ent_reg(self.hass) + if self.entity_id in ent_registry.entities: + ent_registry.async_remove(self.entity_id) + + dev_registry = await get_dev_reg(self.hass) + device = dev_registry.async_get_device( + identifiers={(DOMAIN, self.unique_id)}, connections=set() + ) + if device is not None: + dev_registry.async_update_device( + device.id, remove_config_entry_id=self.registry_entry.config_entry_id + ) + + await self.async_remove() + + async def async_added_to_hass(self): + """Entity has been added to hass.""" + self.roller.callback_subscribe(self.notify_update) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + AUTOMATE_ENTITY_REMOVE.format(self.roller.id), + self.async_remove_and_unregister, + ) + ) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + self.roller.callback_unsubscribe(self.notify_update) + + @callback + def notify_update(self, roller: aiopulse2.Roller): + """Write updated device state information.""" + _LOGGER.debug( + "Device update notification received: %s (%r)", roller.id, roller.name + ) + self.async_write_ha_state() + + @property + def should_poll(self): + """Report that Automate entities do not need polling.""" + return False + + @property + def unique_id(self): + """Return the unique ID of this roller.""" + return self.roller.id + + @property + def name(self): + """Return the name of roller.""" + return self.roller.name + + @property + def device_info(self): + """Return the device info.""" + attrs = { + "identifiers": {(DOMAIN, self.roller.id)}, + } + return attrs diff --git a/homeassistant/components/automate/config_flow.py b/homeassistant/components/automate/config_flow.py new file mode 100644 index 00000000000..45d3a5b9349 --- /dev/null +++ b/homeassistant/components/automate/config_flow.py @@ -0,0 +1,37 @@ +"""Config flow for Automate Pulse Hub v2 integration.""" +import logging + +import aiopulse2 +import voluptuous as vol + +from homeassistant import config_entries + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Automate Pulse Hub v2.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step once we have info from the user.""" + if user_input is not None: + try: + hub = aiopulse2.Hub(user_input["host"]) + await hub.test() + title = hub.name + except Exception: # pylint: disable=broad-except + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) + + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) diff --git a/homeassistant/components/automate/const.py b/homeassistant/components/automate/const.py new file mode 100644 index 00000000000..0c1dc1bd2e5 --- /dev/null +++ b/homeassistant/components/automate/const.py @@ -0,0 +1,6 @@ +"""Constants for the Automate Pulse Hub v2 integration.""" + +DOMAIN = "automate" + +AUTOMATE_HUB_UPDATE = "automate_hub_update_{}" +AUTOMATE_ENTITY_REMOVE = "automate_entity_remove_{}" diff --git a/homeassistant/components/automate/cover.py b/homeassistant/components/automate/cover.py new file mode 100644 index 00000000000..86dcda10adf --- /dev/null +++ b/homeassistant/components/automate/cover.py @@ -0,0 +1,147 @@ +"""Support for Automate Roller Blinds.""" +import aiopulse2 + +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_SHADE, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .base import AutomateBase +from .const import AUTOMATE_HUB_UPDATE, DOMAIN +from .helpers import async_add_automate_entities + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Automate Rollers from a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + current = set() + + @callback + def async_add_automate_covers(): + async_add_automate_entities( + hass, AutomateCover, config_entry, current, async_add_entities + ) + + hub.cleanup_callbacks.append( + async_dispatcher_connect( + hass, + AUTOMATE_HUB_UPDATE.format(config_entry.entry_id), + async_add_automate_covers, + ) + ) + + +class AutomateCover(AutomateBase, CoverEntity): + """Representation of a Automate cover device.""" + + @property + def current_cover_position(self): + """Return the current position of the roller blind. + + None is unknown, 0 is closed, 100 is fully open. + """ + position = None + if self.roller.closed_percent is not None: + position = 100 - self.roller.closed_percent + return position + + @property + def current_cover_tilt_position(self): + """Return the current tilt of the roller blind. + + None is unknown, 0 is closed, 100 is fully open. + """ + return None + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = 0 + if self.current_cover_position is not None: + supported_features |= ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) + + return supported_features + + @property + def device_info(self): + """Return the device info.""" + attrs = super().device_info + attrs["manufacturer"] = "Automate" + attrs["model"] = self.roller.devicetype + attrs["sw_version"] = self.roller.version + attrs["via_device"] = (DOMAIN, self.roller.hub.id) + attrs["name"] = self.name + return attrs + + @property + def device_class(self): + """Class of the cover, a shade.""" + return DEVICE_CLASS_SHADE + + @property + def is_opening(self): + """Is cover opening/moving up.""" + return self.roller.action == aiopulse2.MovingAction.up + + @property + def is_closing(self): + """Is cover closing/moving down.""" + return self.roller.action == aiopulse2.MovingAction.down + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self.roller.closed_percent == 100 + + async def async_close_cover(self, **kwargs): + """Close the roller.""" + await self.roller.move_down() + + async def async_open_cover(self, **kwargs): + """Open the roller.""" + await self.roller.move_up() + + async def async_stop_cover(self, **kwargs): + """Stop the roller.""" + await self.roller.move_stop() + + async def async_set_cover_position(self, **kwargs): + """Move the roller shutter to a specific position.""" + await self.roller.move_to(100 - kwargs[ATTR_POSITION]) + + async def async_close_cover_tilt(self, **kwargs): + """Close the roller.""" + await self.roller.move_down() + + async def async_open_cover_tilt(self, **kwargs): + """Open the roller.""" + await self.roller.move_up() + + async def async_stop_cover_tilt(self, **kwargs): + """Stop the roller.""" + await self.roller.move_stop() + + async def async_set_cover_tilt(self, **kwargs): + """Tilt the roller shutter to a specific position.""" + await self.roller.move_to(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/automate/helpers.py b/homeassistant/components/automate/helpers.py new file mode 100644 index 00000000000..92130eeb79b --- /dev/null +++ b/homeassistant/components/automate/helpers.py @@ -0,0 +1,46 @@ +"""Helper functions for Automate Pulse.""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_add_automate_entities( + hass, entity_class, config_entry, current, async_add_entities +): + """Add any new entities.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + _LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) + + api = hub.api.rollers + + new_items = [] + for unique_id, roller in api.items(): + if unique_id not in current: + _LOGGER.debug("New %s %s", entity_class.__name__, unique_id) + new_item = entity_class(roller) + current.add(unique_id) + new_items.append(new_item) + + async_add_entities(new_items) + + +async def update_devices(hass, config_entry, api): + """Tell hass that device info has been updated.""" + dev_registry = await get_dev_reg(hass) + + for api_item in api.values(): + # Update Device name + device = dev_registry.async_get_device( + identifiers={(DOMAIN, api_item.id)}, connections=set() + ) + if device is not None: + dev_registry.async_update_device( + device.id, + name=api_item.name, + ) diff --git a/homeassistant/components/automate/hub.py b/homeassistant/components/automate/hub.py new file mode 100644 index 00000000000..78e1b5873fa --- /dev/null +++ b/homeassistant/components/automate/hub.py @@ -0,0 +1,89 @@ +"""Code to handle a Pulse Hub.""" +from __future__ import annotations + +import asyncio +import logging + +import aiopulse2 + +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import AUTOMATE_ENTITY_REMOVE, AUTOMATE_HUB_UPDATE +from .helpers import update_devices + +_LOGGER = logging.getLogger(__name__) + + +class PulseHub: + """Manages a single Pulse Hub.""" + + def __init__(self, hass, config_entry): + """Initialize the system.""" + self.config_entry = config_entry + self.hass = hass + self.api: aiopulse2.Hub | None = None + self.tasks = [] + self.current_rollers = {} + self.cleanup_callbacks = [] + + @property + def title(self): + """Return the title of the hub shown in the integrations list.""" + return f"{self.api.name} ({self.api.host})" + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data["host"] + + async def async_setup(self): + """Set up a hub based on host parameter.""" + host = self.host + + hub = aiopulse2.Hub(host, propagate_callbacks=True) + + self.api = hub + + hub.callback_subscribe(self.async_notify_update) + self.tasks.append(asyncio.create_task(hub.run())) + + _LOGGER.debug("Hub setup complete") + return True + + async def async_reset(self): + """Reset this hub to default state.""" + for cleanup_callback in self.cleanup_callbacks: + cleanup_callback() + + # If not setup + if self.api is None: + return False + + self.api.callback_unsubscribe(self.async_notify_update) + await self.api.stop() + del self.api + self.api = None + + # Wait for any running tasks to complete + await asyncio.wait(self.tasks) + + return True + + async def async_notify_update(self, hub=None): + """Evaluate entities when hub reports that update has occurred.""" + _LOGGER.debug("Hub {self.title} updated") + + await update_devices(self.hass, self.config_entry, self.api.rollers) + self.hass.config_entries.async_update_entry(self.config_entry, title=self.title) + + async_dispatcher_send( + self.hass, AUTOMATE_HUB_UPDATE.format(self.config_entry.entry_id) + ) + + for unique_id in list(self.current_rollers): + if unique_id not in self.api.rollers: + _LOGGER.debug("Notifying remove of %s", unique_id) + self.current_rollers.pop(unique_id) + async_dispatcher_send( + self.hass, AUTOMATE_ENTITY_REMOVE.format(unique_id) + ) diff --git a/homeassistant/components/automate/manifest.json b/homeassistant/components/automate/manifest.json new file mode 100644 index 00000000000..071aaf1589f --- /dev/null +++ b/homeassistant/components/automate/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "automate", + "name": "Automate Pulse Hub v2", + "config_flow": true, + "iot_class": "local_push", + "documentation": "https://www.home-assistant.io/integrations/automate", + "requirements": [ + "aiopulse2==0.6.0" + ], + "codeowners": [ + "@sillyfrog" + ] +} \ No newline at end of file diff --git a/homeassistant/components/automate/strings.json b/homeassistant/components/automate/strings.json new file mode 100644 index 00000000000..8a8131f0f67 --- /dev/null +++ b/homeassistant/components/automate/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/en.json b/homeassistant/components/automate/translations/en.json new file mode 100644 index 00000000000..2ad35962b25 --- /dev/null +++ b/homeassistant/components/automate/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b88d5639783..0e7b6c52cc2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -28,6 +28,7 @@ FLOWS = [ "atag", "august", "aurora", + "automate", "awair", "axis", "azure_devops", diff --git a/requirements_all.txt b/requirements_all.txt index d904a1187b3..a43f754b202 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,6 +220,9 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==1.1.0 +# homeassistant.components.automate +aiopulse2==0.6.0 + # homeassistant.components.acmeda aiopulse==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2baf16de687..5732833e079 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -142,6 +142,9 @@ aiomusiccast==0.8.0 # homeassistant.components.notion aionotion==1.1.0 +# homeassistant.components.automate +aiopulse2==0.6.0 + # homeassistant.components.acmeda aiopulse==0.4.2 diff --git a/tests/components/automate/__init__.py b/tests/components/automate/__init__.py new file mode 100644 index 00000000000..6a87ba942e3 --- /dev/null +++ b/tests/components/automate/__init__.py @@ -0,0 +1 @@ +"""Tests for the Automate Pulse Hub v2 integration.""" diff --git a/tests/components/automate/test_config_flow.py b/tests/components/automate/test_config_flow.py new file mode 100644 index 00000000000..fea2fa995cd --- /dev/null +++ b/tests/components/automate/test_config_flow.py @@ -0,0 +1,69 @@ +"""Test the Automate Pulse Hub v2 config flow.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries, setup +from homeassistant.components.automate.const import DOMAIN + + +def mock_hub(testfunc=None): + """Mock aiopulse2.Hub.""" + Hub = Mock() + Hub.name = "Name of the device" + + async def hub_test(): + if testfunc: + testfunc() + + Hub.test = hub_test + + return Hub + + +async def test_form(hass): + """Test we get the 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"] is None + + with patch("aiopulse2.Hub", return_value=mock_hub()), patch( + "homeassistant.components.automate.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "1.1.1.1", + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +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} + ) + + def raise_error(): + raise ConnectionRefusedError + + with patch("aiopulse2.Hub", return_value=mock_hub(raise_error)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}