From 30618aae942caa6792f0f8346c46f7da08df1208 Mon Sep 17 00:00:00 2001 From: Ludovico de Nittis Date: Sun, 11 Apr 2021 22:35:04 +0200 Subject: [PATCH] Reintroduce iAlarm integration (#43525) The previous iAlarm integration has been removed because it used webscraping #43010. Since then, the pyialarm library has been updated to use the iAlarm API instead. With this commit I reintroduce the iAlarm integration, leveraging the new HA config flow. Signed-off-by: Ludovico de Nittis --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/ialarm/__init__.py | 89 +++++++++++++++ .../components/ialarm/alarm_control_panel.py | 64 +++++++++++ .../components/ialarm/config_flow.py | 63 +++++++++++ homeassistant/components/ialarm/const.py | 22 ++++ homeassistant/components/ialarm/manifest.json | 12 ++ homeassistant/components/ialarm/strings.json | 20 ++++ .../components/ialarm/translations/en.json | 20 ++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ialarm/__init__.py | 1 + tests/components/ialarm/test_config_flow.py | 104 ++++++++++++++++++ tests/components/ialarm/test_init.py | 85 ++++++++++++++ 15 files changed, 489 insertions(+) create mode 100644 homeassistant/components/ialarm/__init__.py create mode 100644 homeassistant/components/ialarm/alarm_control_panel.py create mode 100644 homeassistant/components/ialarm/config_flow.py create mode 100644 homeassistant/components/ialarm/const.py create mode 100644 homeassistant/components/ialarm/manifest.json create mode 100644 homeassistant/components/ialarm/strings.json create mode 100644 homeassistant/components/ialarm/translations/en.json create mode 100644 tests/components/ialarm/__init__.py create mode 100644 tests/components/ialarm/test_config_flow.py create mode 100644 tests/components/ialarm/test_init.py diff --git a/.coveragerc b/.coveragerc index 2a5e6ecc502..3d126dfd23b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -431,6 +431,7 @@ omit = homeassistant/components/hvv_departures/sensor.py homeassistant/components/hvv_departures/__init__.py homeassistant/components/hydrawise/* + homeassistant/components/ialarm/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 5f2fd6588a6..860ee9f0665 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ homeassistant/components/hunterdouglas_powerview/* @bdraco homeassistant/components/hvv_departures/* @vigonotion homeassistant/components/hydrawise/* @ptcryan homeassistant/components/hyperion/* @dermotduffy +homeassistant/components/ialarm/* @RyuzakiKK homeassistant/components/iammeter/* @lewei50 homeassistant/components/iaqualink/* @flz homeassistant/components/icloud/* @Quentame @nzapponi diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py new file mode 100644 index 00000000000..03d07a15394 --- /dev/null +++ b/homeassistant/components/ialarm/__init__.py @@ -0,0 +1,89 @@ +"""iAlarm integration.""" +import asyncio +import logging + +from async_timeout import timeout +from pyialarm import IAlarm + +from homeassistant.components.alarm_control_panel import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_COORDINATOR, DOMAIN, IALARM_TO_HASS + +PLATFORM = "alarm_control_panel" +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up iAlarm config.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + ialarm = IAlarm(host, port) + + try: + async with timeout(10): + mac = await hass.async_add_executor_job(ialarm.get_mac) + except (asyncio.TimeoutError, ConnectionError) as ex: + raise ConfigEntryNotReady from ex + + coordinator = IAlarmDataUpdateCoordinator(hass, ialarm, mac) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + } + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, PLATFORM) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload iAlarm config.""" + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, PLATFORM) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class IAlarmDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching iAlarm data.""" + + def __init__(self, hass, ialarm, mac): + """Initialize global iAlarm data updater.""" + self.ialarm = ialarm + self.state = None + self.host = ialarm.host + self.mac = mac + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + def _update_data(self) -> None: + """Fetch data from iAlarm via sync functions.""" + status = self.ialarm.get_status() + _LOGGER.debug("iAlarm status: %s", status) + + self.state = IALARM_TO_HASS.get(status) + + async def _async_update_data(self) -> None: + """Fetch data from iAlarm.""" + try: + async with timeout(10): + await self.hass.async_add_executor_job(self._update_data) + except ConnectionError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py new file mode 100644 index 00000000000..a33162b7afd --- /dev/null +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -0,0 +1,64 @@ +"""Interfaces with iAlarm control panels.""" +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_HOME, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + """Set up a iAlarm alarm control panel based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + async_add_entities([IAlarmPanel(coordinator)], False) + + +class IAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): + """Representation of an iAlarm device.""" + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Antifurto365 - Meian", + } + + @property + def unique_id(self): + """Return a unique id.""" + return self.coordinator.mac + + @property + def name(self): + """Return the name.""" + return "iAlarm" + + @property + def state(self): + """Return the state of the device.""" + return self.coordinator.state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self.coordinator.ialarm.disarm() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self.coordinator.ialarm.arm_stay() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self.coordinator.ialarm.arm_away() diff --git a/homeassistant/components/ialarm/config_flow.py b/homeassistant/components/ialarm/config_flow.py new file mode 100644 index 00000000000..64eab90719b --- /dev/null +++ b/homeassistant/components/ialarm/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Antifurto365 iAlarm integration.""" +import logging + +from pyialarm import IAlarm +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PORT + +# pylint: disable=unused-import +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + + +async def _get_device_mac(hass: core.HomeAssistant, host, port): + ialarm = IAlarm(host, port) + return await hass.async_add_executor_job(ialarm.get_mac) + + +class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Antifurto365 iAlarm.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + mac = None + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + try: + # If we are able to get the MAC address, we are able to establish + # a connection to the device. + mac = await _get_device_mac(self.hass, host, port) + except ConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) diff --git a/homeassistant/components/ialarm/const.py b/homeassistant/components/ialarm/const.py new file mode 100644 index 00000000000..c6eaf0ec979 --- /dev/null +++ b/homeassistant/components/ialarm/const.py @@ -0,0 +1,22 @@ +"""Constants for the iAlarm integration.""" +from pyialarm import IAlarm + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) + +DATA_COORDINATOR = "ialarm" + +DEFAULT_PORT = 18034 + +DOMAIN = "ialarm" + +IALARM_TO_HASS = { + IAlarm.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + IAlarm.ARMED_STAY: STATE_ALARM_ARMED_HOME, + IAlarm.DISARMED: STATE_ALARM_DISARMED, + IAlarm.TRIGGERED: STATE_ALARM_TRIGGERED, +} diff --git a/homeassistant/components/ialarm/manifest.json b/homeassistant/components/ialarm/manifest.json new file mode 100644 index 00000000000..1e4c0383922 --- /dev/null +++ b/homeassistant/components/ialarm/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ialarm", + "name": "Antifurto365 iAlarm", + "documentation": "https://www.home-assistant.io/integrations/ialarm", + "requirements": [ + "pyialarm==1.5" + ], + "codeowners": [ + "@RyuzakiKK" + ], + "config_flow": true +} diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json new file mode 100644 index 00000000000..5976a95ea5d --- /dev/null +++ b/homeassistant/components/ialarm/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/ialarm/translations/en.json b/homeassistant/components/ialarm/translations/en.json new file mode 100644 index 00000000000..2ea7a7ab669 --- /dev/null +++ b/homeassistant/components/ialarm/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + }, + "title": "Antifurto365 iAlarm" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 808f18c319d..25429296d8e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -109,6 +109,7 @@ FLOWS = [ "hunterdouglas_powerview", "hvv_departures", "hyperion", + "ialarm", "iaqualink", "icloud", "ifttt", diff --git a/requirements_all.txt b/requirements_all.txt index fe68fc71bd7..998fcec8796 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1448,6 +1448,9 @@ pyhomematic==0.1.72 # homeassistant.components.homeworks pyhomeworks==0.0.6 +# homeassistant.components.ialarm +pyialarm==1.5 + # homeassistant.components.icloud pyicloud==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17885071b1e..480435be441 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,6 +783,9 @@ pyhiveapi==0.4.1 # homeassistant.components.homematic pyhomematic==0.1.72 +# homeassistant.components.ialarm +pyialarm==1.5 + # homeassistant.components.icloud pyicloud==0.10.2 diff --git a/tests/components/ialarm/__init__.py b/tests/components/ialarm/__init__.py new file mode 100644 index 00000000000..51cccfad023 --- /dev/null +++ b/tests/components/ialarm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Antifurto365 iAlarm integration.""" diff --git a/tests/components/ialarm/test_config_flow.py b/tests/components/ialarm/test_config_flow.py new file mode 100644 index 00000000000..54da9a18b1a --- /dev/null +++ b/tests/components/ialarm/test_config_flow.py @@ -0,0 +1,104 @@ +"""Test the Antifurto365 iAlarm config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.ialarm.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +TEST_DATA = {CONF_HOST: "1.1.1.1", CONF_PORT: 18034} + +TEST_MAC = "00:00:54:12:34:56" + + +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"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.ialarm.config_flow.IAlarm.get_status", + return_value=1, + ), patch( + "homeassistant.components.ialarm.config_flow.IAlarm.get_mac", + return_value=TEST_MAC, + ), patch( + "homeassistant.components.ialarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_DATA["host"] + assert result2["data"] == TEST_DATA + 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} + ) + + with patch( + "homeassistant.components.ialarm.config_flow.IAlarm.get_mac", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +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.ialarm.config_flow.IAlarm.get_mac", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_already_exists(hass): + """Test that a flow with an existing host aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_MAC, + data=TEST_DATA, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ialarm.config_flow.IAlarm.get_mac", + return_value=TEST_MAC, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/ialarm/test_init.py b/tests/components/ialarm/test_init.py new file mode 100644 index 00000000000..2f1936aff81 --- /dev/null +++ b/tests/components/ialarm/test_init.py @@ -0,0 +1,85 @@ +"""Test the Antifurto365 iAlarm init.""" +from unittest.mock import Mock, patch +from uuid import uuid4 + +import pytest + +from homeassistant.components.ialarm.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="ialarm_api") +def ialarm_api_fixture(): + """Set up IAlarm API fixture.""" + with patch("homeassistant.components.ialarm.IAlarm") as mock_ialarm_api: + yield mock_ialarm_api + + +@pytest.fixture(name="mock_config_entry") +def mock_config_fixture(): + """Return a fake config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.10.20", CONF_PORT: 18034}, + entry_id=str(uuid4()), + ) + + +async def test_setup_entry(hass, ialarm_api, mock_config_entry): + """Test setup entry.""" + ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") + + mock_config_entry.add_to_hass(hass) + await async_setup_component( + hass, + DOMAIN, + { + "ialarm": { + CONF_HOST: "192.168.10.20", + CONF_PORT: 18034, + }, + }, + ) + await hass.async_block_till_done() + ialarm_api.return_value.get_mac.assert_called_once() + assert mock_config_entry.state == ENTRY_STATE_LOADED + + +async def test_setup_not_ready(hass, ialarm_api, mock_config_entry): + """Test setup failed because we can't connect to the alarm system.""" + ialarm_api.return_value.get_mac = Mock(side_effect=ConnectionError) + + mock_config_entry.add_to_hass(hass) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert mock_config_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_entry(hass, ialarm_api, mock_config_entry): + """Test being able to unload an entry.""" + ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") + + mock_config_entry.add_to_hass(hass) + await async_setup_component( + hass, + DOMAIN, + { + "ialarm": { + CONF_HOST: "192.168.10.20", + CONF_PORT: 18034, + }, + }, + ) + await hass.async_block_till_done() + + assert mock_config_entry.state == ENTRY_STATE_LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state == ENTRY_STATE_NOT_LOADED