Add Risco integration (#36930)

* Risco integration

* Fix lint errors

* Raise ConfigEntryNotReady if can't connect

* Gracefully handle shutdown

* pass session to pyrisco

* minor change to init

* Fix retries

* Add exception log

* Remove retries

* Address code review comments

* Remove log
This commit is contained in:
On Freund 2020-08-22 07:49:09 +03:00 committed by GitHub
parent 83b9c6188d
commit 1b8d9f7cc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 706 additions and 0 deletions

View file

@ -346,6 +346,7 @@ homeassistant/components/random/* @fabaff
homeassistant/components/repetier/* @MTrab
homeassistant/components/rfxtrx/* @danielhiversen @elupus
homeassistant/components/ring/* @balloob
homeassistant/components/risco/* @OnFreund
homeassistant/components/rmvtransport/* @cgtobi
homeassistant/components/roku/* @ctalkington
homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn

View file

@ -0,0 +1,89 @@
"""The Risco integration."""
import asyncio
from datetime import timedelta
import logging
from pyrisco import CannotConnectError, OperationError, RiscoAPI, UnauthorizedError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DATA_COORDINATOR, DOMAIN
PLATFORMS = ["alarm_control_panel"]
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Risco component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Risco from a config entry."""
data = entry.data
risco = RiscoAPI(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN])
try:
await risco.login(async_get_clientsession(hass))
except CannotConnectError as error:
raise ConfigEntryNotReady() from error
except UnauthorizedError:
_LOGGER.exception("Failed to login to Risco cloud")
return False
coordinator = RiscoDataUpdateCoordinator(hass, risco)
await coordinator.async_refresh()
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
}
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
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
class RiscoDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching risco data."""
def __init__(self, hass, risco):
"""Initialize global risco data updater."""
self.risco = risco
interval = timedelta(seconds=30)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=interval,
)
async def _async_update_data(self):
"""Fetch data from risco."""
try:
return await self.risco.get_state()
except (CannotConnectError, UnauthorizedError, OperationError) as error:
raise UpdateFailed from error

View file

@ -0,0 +1,162 @@
"""Support for Risco alarms."""
import logging
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity
from homeassistant.components.alarm_control_panel.const import (
SUPPORT_ALARM_ARM_AWAY,
SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
SUPPORT_ALARM_ARM_HOME,
SUPPORT_ALARM_ARM_NIGHT,
)
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMING,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_UNKNOWN,
)
from .const import DATA_COORDINATOR, DOMAIN
_LOGGER = logging.getLogger(__name__)
SUPPORTED_STATES = [
STATE_ALARM_DISARMED,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_TRIGGERED,
]
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Risco alarm control panel."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities = [
RiscoAlarm(hass, coordinator, partition_id)
for partition_id in coordinator.data.partitions.keys()
]
async_add_entities(entities, False)
class RiscoAlarm(AlarmControlPanelEntity):
"""Representation of a Risco partition."""
def __init__(self, hass, coordinator, partition_id):
"""Init the partition."""
self._hass = hass
self._coordinator = coordinator
self._partition_id = partition_id
self._partition = self._coordinator.data.partitions[self._partition_id]
@property
def should_poll(self):
"""No need to poll. Coordinator notifies entity of updates."""
return False
@property
def available(self):
"""Return if entity is available."""
return self._coordinator.last_update_success
def _refresh_from_coordinator(self):
self._partition = self._coordinator.data.partitions[self._partition_id]
self.async_write_ha_state()
async def async_added_to_hass(self):
"""When entity is added to hass."""
self.async_on_remove(
self._coordinator.async_add_listener(self._refresh_from_coordinator)
)
@property
def _risco(self):
"""Return the Risco API object."""
return self._coordinator.risco
@property
def device_info(self):
"""Return device info for this device."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.name,
"manufacturer": "Risco",
}
@property
def name(self):
"""Return the name of the partition."""
return f"Risco {self._risco.site_name} Partition {self._partition_id}"
@property
def unique_id(self):
"""Return a unique id for that partition."""
return f"{self._risco.site_uuid}_{self._partition_id}"
@property
def state(self):
"""Return the state of the device."""
if self._partition.triggered:
return STATE_ALARM_TRIGGERED
if self._partition.arming:
return STATE_ALARM_ARMING
if self._partition.armed:
return STATE_ALARM_ARMED_AWAY
if self._partition.partially_armed:
return STATE_ALARM_ARMED_HOME
if self._partition.disarmed:
return STATE_ALARM_DISARMED
return STATE_UNKNOWN
@property
def supported_features(self):
"""Return the list of supported features."""
return (
SUPPORT_ALARM_ARM_HOME
| SUPPORT_ALARM_ARM_AWAY
| SUPPORT_ALARM_ARM_NIGHT
| SUPPORT_ALARM_ARM_CUSTOM_BYPASS
)
@property
def code_arm_required(self):
"""Whether the code is required for arm actions."""
return False
async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
await self._call_alarm_method("disarm")
async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
await self._call_alarm_method("partial_arm")
async def async_alarm_arm_night(self, code=None):
"""Send arm night command."""
await self._call_alarm_method("partial_arm")
async def async_alarm_arm_custom_bypass(self, code=None):
"""Send arm custom bypass command."""
await self._call_alarm_method("partial_arm")
async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
await self._call_alarm_method("arm")
async def _call_alarm_method(self, method, code=None):
alarm = await getattr(self._risco, method)(self._partition_id)
self._partition = alarm.partitions[self._partition_id]
self.async_write_ha_state()
async def async_update(self):
"""Update the entity.
Only used by the generic entity update service.
"""
await self._coordinator.async_request_refresh()

View file

@ -0,0 +1,58 @@
"""Config flow for Risco integration."""
import logging
from pyrisco import CannotConnectError, RiscoAPI, UnauthorizedError
import voluptuous as vol
from homeassistant import config_entries, core
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str, CONF_PIN: str})
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.
"""
risco = RiscoAPI(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN])
try:
await risco.login(async_get_clientsession(hass))
finally:
await risco.close()
return {"title": risco.site_name}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Risco."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnectError:
errors["base"] = "cannot_connect"
except UnauthorizedError:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)

View file

@ -0,0 +1,5 @@
"""Constants for the Risco integration."""
DOMAIN = "risco"
DATA_COORDINATOR = "risco"

View file

@ -0,0 +1,12 @@
{
"domain": "risco",
"name": "Risco",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/risco",
"requirements": [
"pyrisco==0.2.1"
],
"codeowners": [
"@OnFreund"
]
}

View file

@ -0,0 +1,21 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"pin": "Pin code"
}
}
},
"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

@ -144,6 +144,7 @@ FLOWS = [
"rachio",
"rainmachine",
"ring",
"risco",
"roku",
"roomba",
"roon",

View file

@ -1592,6 +1592,9 @@ pyrecswitch==1.0.2
# homeassistant.components.repetier
pyrepetier==3.0.5
# homeassistant.components.risco
pyrisco==0.2.1
# homeassistant.components.sabnzbd
pysabnzbd==1.1.0

View file

@ -754,6 +754,9 @@ pyps4-2ndscreen==1.1.1
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
# homeassistant.components.risco
pyrisco==0.2.1
# homeassistant.components.acer_projector
# homeassistant.components.zha
pyserial==3.4

View file

@ -0,0 +1 @@
"""Tests for the Risco integration."""

View file

@ -0,0 +1,237 @@
"""Tests for the Risco alarm control panel device."""
import pytest
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.components.risco import CannotConnectError, UnauthorizedError
from homeassistant.components.risco.const import DOMAIN
from homeassistant.const import (
CONF_PASSWORD,
CONF_PIN,
CONF_USERNAME,
SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_CUSTOM_BYPASS,
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_ARM_NIGHT,
SERVICE_ALARM_DISARM,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMING,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_UNKNOWN,
)
from homeassistant.helpers.entity_component import async_update_entity
from tests.async_mock import AsyncMock, MagicMock, PropertyMock, patch
from tests.common import MockConfigEntry
TEST_CONFIG = {
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_PIN: "1234",
}
TEST_SITE_UUID = "test-site-uuid"
TEST_SITE_NAME = "test-site-name"
FIRST_ENTITY_ID = "alarm_control_panel.risco_test_site_name_partition_0"
SECOND_ENTITY_ID = "alarm_control_panel.risco_test_site_name_partition_1"
def _partition_mock():
return MagicMock(
triggered=False,
arming=False,
armed=False,
disarmed=False,
partially_armed=False,
)
@pytest.fixture
def two_part_alarm():
"""Fixture to mock alarm with two partitions."""
partition_mocks = {0: _partition_mock(), 1: _partition_mock()}
alarm_mock = MagicMock()
with patch.object(
partition_mocks[0], "id", new_callable=PropertyMock(return_value=0)
), patch.object(
partition_mocks[1], "id", new_callable=PropertyMock(return_value=1)
), patch.object(
alarm_mock,
"partitions",
new_callable=PropertyMock(return_value=partition_mocks),
), patch(
"homeassistant.components.risco.RiscoAPI.get_state",
AsyncMock(return_value=alarm_mock),
):
yield alarm_mock
async def _setup_risco(hass, alarm=MagicMock()):
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
with patch(
"homeassistant.components.risco.RiscoAPI.login", return_value=True,
), patch(
"homeassistant.components.risco.RiscoAPI.site_uuid",
new_callable=PropertyMock(return_value=TEST_SITE_UUID),
), patch(
"homeassistant.components.risco.RiscoAPI.site_name",
new_callable=PropertyMock(return_value=TEST_SITE_NAME),
), patch(
"homeassistant.components.risco.RiscoAPI.close", AsyncMock()
):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
async def test_cannot_connect(hass):
"""Test connection error."""
with patch(
"homeassistant.components.risco.RiscoAPI.login", side_effect=CannotConnectError,
):
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
registry = await hass.helpers.entity_registry.async_get_registry()
assert not registry.async_is_registered(FIRST_ENTITY_ID)
assert not registry.async_is_registered(SECOND_ENTITY_ID)
async def test_unauthorized(hass):
"""Test unauthorized error."""
with patch(
"homeassistant.components.risco.RiscoAPI.login", side_effect=UnauthorizedError,
):
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
registry = await hass.helpers.entity_registry.async_get_registry()
assert not registry.async_is_registered(FIRST_ENTITY_ID)
assert not registry.async_is_registered(SECOND_ENTITY_ID)
async def test_setup(hass, two_part_alarm):
"""Test entity setup."""
registry = await hass.helpers.entity_registry.async_get_registry()
assert not registry.async_is_registered(FIRST_ENTITY_ID)
assert not registry.async_is_registered(SECOND_ENTITY_ID)
await _setup_risco(hass, two_part_alarm)
assert registry.async_is_registered(FIRST_ENTITY_ID)
assert registry.async_is_registered(SECOND_ENTITY_ID)
registry = await hass.helpers.device_registry.async_get_registry()
device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_0")}, {})
assert device is not None
assert device.manufacturer == "Risco"
device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_1")}, {})
assert device is not None
assert device.manufacturer == "Risco"
async def _check_state(hass, alarm, property, state, entity_id, partition_id):
with patch.object(alarm.partitions[partition_id], property, return_value=True):
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == state
async def test_states(hass, two_part_alarm):
"""Test the various alarm states."""
await _setup_risco(hass, two_part_alarm)
assert hass.states.get(FIRST_ENTITY_ID).state == STATE_UNKNOWN
await _check_state(
hass, two_part_alarm, "triggered", STATE_ALARM_TRIGGERED, FIRST_ENTITY_ID, 0
)
await _check_state(
hass, two_part_alarm, "triggered", STATE_ALARM_TRIGGERED, SECOND_ENTITY_ID, 1
)
await _check_state(
hass, two_part_alarm, "arming", STATE_ALARM_ARMING, FIRST_ENTITY_ID, 0
)
await _check_state(
hass, two_part_alarm, "arming", STATE_ALARM_ARMING, SECOND_ENTITY_ID, 1
)
await _check_state(
hass, two_part_alarm, "armed", STATE_ALARM_ARMED_AWAY, FIRST_ENTITY_ID, 0
)
await _check_state(
hass, two_part_alarm, "armed", STATE_ALARM_ARMED_AWAY, SECOND_ENTITY_ID, 1
)
await _check_state(
hass,
two_part_alarm,
"partially_armed",
STATE_ALARM_ARMED_HOME,
FIRST_ENTITY_ID,
0,
)
await _check_state(
hass,
two_part_alarm,
"partially_armed",
STATE_ALARM_ARMED_HOME,
SECOND_ENTITY_ID,
1,
)
await _check_state(
hass, two_part_alarm, "disarmed", STATE_ALARM_DISARMED, FIRST_ENTITY_ID, 0
)
await _check_state(
hass, two_part_alarm, "disarmed", STATE_ALARM_DISARMED, SECOND_ENTITY_ID, 1
)
async def _test_servie_call(hass, service, method, entity_id, partition_id):
with patch(
"homeassistant.components.risco.RiscoAPI." + method, AsyncMock()
) as set_mock:
await _call_alarm_service(hass, service, entity_id)
set_mock.assert_awaited_once_with(partition_id)
async def _call_alarm_service(hass, service, entity_id):
data = {"entity_id": entity_id}
await hass.services.async_call(
ALARM_DOMAIN, service, service_data=data, blocking=True
)
async def test_sets(hass, two_part_alarm):
"""Test settings the various modes."""
await _setup_risco(hass, two_part_alarm)
await _test_servie_call(hass, SERVICE_ALARM_DISARM, "disarm", FIRST_ENTITY_ID, 0)
await _test_servie_call(hass, SERVICE_ALARM_DISARM, "disarm", SECOND_ENTITY_ID, 1)
await _test_servie_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_ENTITY_ID, 0)
await _test_servie_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_ENTITY_ID, 1)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_ENTITY_ID, 0
)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_ENTITY_ID, 1
)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_NIGHT, "partial_arm", FIRST_ENTITY_ID, 0
)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_NIGHT, "partial_arm", SECOND_ENTITY_ID, 1
)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", FIRST_ENTITY_ID, 0
)
await _test_servie_call(
hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", SECOND_ENTITY_ID, 1
)

View file

@ -0,0 +1,113 @@
"""Test the Risco config flow."""
from homeassistant import config_entries, setup
from homeassistant.components.risco.config_flow import (
CannotConnectError,
UnauthorizedError,
)
from homeassistant.components.risco.const import DOMAIN
from tests.async_mock import AsyncMock, PropertyMock, patch
TEST_SITE_NAME = "test-site-name"
TEST_DATA = {
"username": "test-username",
"password": "test-password",
"pin": "1234",
}
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"] == {}
with patch(
"homeassistant.components.risco.config_flow.RiscoAPI.login", return_value=True,
), patch(
"homeassistant.components.risco.config_flow.RiscoAPI.site_name",
new_callable=PropertyMock(return_value=TEST_SITE_NAME),
), patch(
"homeassistant.components.risco.config_flow.RiscoAPI.close", AsyncMock()
) as mock_close, patch(
"homeassistant.components.risco.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.risco.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_DATA
)
assert result2["type"] == "create_entry"
assert result2["title"] == TEST_SITE_NAME
assert result2["data"] == TEST_DATA
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
mock_close.assert_awaited_once()
async def test_form_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.risco.config_flow.RiscoAPI.login",
side_effect=UnauthorizedError,
), patch(
"homeassistant.components.risco.config_flow.RiscoAPI.close", AsyncMock()
) as mock_close:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_DATA
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
mock_close.assert_awaited_once()
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}
)
with patch(
"homeassistant.components.risco.config_flow.RiscoAPI.login",
side_effect=CannotConnectError,
), patch(
"homeassistant.components.risco.config_flow.RiscoAPI.close", AsyncMock()
) as mock_close:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_DATA
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
mock_close.assert_awaited_once()
async def test_form_exception(hass):
"""Test we handle unknown exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.risco.config_flow.RiscoAPI.login",
side_effect=Exception,
), patch(
"homeassistant.components.risco.config_flow.RiscoAPI.close", AsyncMock()
) as mock_close:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_DATA
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
mock_close.assert_awaited_once()