From 1b8d9f7cc4d423d6610b0abce22dd027c114acf6 Mon Sep 17 00:00:00 2001 From: On Freund Date: Sat, 22 Aug 2020 07:49:09 +0300 Subject: [PATCH] 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 --- CODEOWNERS | 1 + homeassistant/components/risco/__init__.py | 89 +++++++ .../components/risco/alarm_control_panel.py | 162 ++++++++++++ homeassistant/components/risco/config_flow.py | 58 +++++ homeassistant/components/risco/const.py | 5 + homeassistant/components/risco/manifest.json | 12 + homeassistant/components/risco/strings.json | 21 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/risco/__init__.py | 1 + .../risco/test_alarm_control_panel.py | 237 ++++++++++++++++++ tests/components/risco/test_config_flow.py | 113 +++++++++ 13 files changed, 706 insertions(+) create mode 100644 homeassistant/components/risco/__init__.py create mode 100644 homeassistant/components/risco/alarm_control_panel.py create mode 100644 homeassistant/components/risco/config_flow.py create mode 100644 homeassistant/components/risco/const.py create mode 100644 homeassistant/components/risco/manifest.json create mode 100644 homeassistant/components/risco/strings.json create mode 100644 tests/components/risco/__init__.py create mode 100644 tests/components/risco/test_alarm_control_panel.py create mode 100644 tests/components/risco/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 6009cd55745..7ce722b8617 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py new file mode 100644 index 00000000000..d620e2c1c68 --- /dev/null +++ b/homeassistant/components/risco/__init__.py @@ -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 diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py new file mode 100644 index 00000000000..a92b3cc186a --- /dev/null +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -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() diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py new file mode 100644 index 00000000000..75807a63406 --- /dev/null +++ b/homeassistant/components/risco/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py new file mode 100644 index 00000000000..7fa85227fe1 --- /dev/null +++ b/homeassistant/components/risco/const.py @@ -0,0 +1,5 @@ +"""Constants for the Risco integration.""" + +DOMAIN = "risco" + +DATA_COORDINATOR = "risco" diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json new file mode 100644 index 00000000000..9dd6fd95680 --- /dev/null +++ b/homeassistant/components/risco/manifest.json @@ -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" + ] +} \ No newline at end of file diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json new file mode 100644 index 00000000000..a2ce75a9d74 --- /dev/null +++ b/homeassistant/components/risco/strings.json @@ -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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8a9343cf58b..e1ab7647446 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -144,6 +144,7 @@ FLOWS = [ "rachio", "rainmachine", "ring", + "risco", "roku", "roomba", "roon", diff --git a/requirements_all.txt b/requirements_all.txt index ad248b7eefb..47aa702fcd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e7701148ab..3f7d1f9259e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/risco/__init__.py b/tests/components/risco/__init__.py new file mode 100644 index 00000000000..1a84a8d2399 --- /dev/null +++ b/tests/components/risco/__init__.py @@ -0,0 +1 @@ +"""Tests for the Risco integration.""" diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py new file mode 100644 index 00000000000..574e422eda1 --- /dev/null +++ b/tests/components/risco/test_alarm_control_panel.py @@ -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 + ) diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py new file mode 100644 index 00000000000..8886930100a --- /dev/null +++ b/tests/components/risco/test_config_flow.py @@ -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()