Add Automate Pulse Hub v2 support (#39501)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Sillyfrog <sillyfrog@users.noreply.github.com>
This commit is contained in:
sillyfrog 2021-07-22 22:40:33 +10:00 committed by GitHub
parent f009b1442f
commit d3e77e00e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 589 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ FLOWS = [
"atag",
"august",
"aurora",
"automate",
"awair",
"axis",
"azure_devops",

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Automate Pulse Hub v2 integration."""

View File

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