From 6c4e308a3ff9f0351f1bc53cd6dc9b5381cea434 Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Wed, 8 Nov 2023 12:54:03 +0100 Subject: [PATCH] feat: add AWAY state configuration (#107) --- .../econnect_metronet/alarm_control_panel.py | 2 +- .../econnect_metronet/config_flow.py | 8 +++++- custom_components/econnect_metronet/const.py | 1 + .../econnect_metronet/devices.py | 8 +++++- .../econnect_metronet/strings.json | 11 ++++---- .../econnect_metronet/translations/en.json | 3 ++- .../econnect_metronet/translations/it.json | 3 ++- tests/conftest.py | 11 +++----- tests/test_alarm_panel.py | 22 ++++++++++++++++ tests/test_decorators.py | 24 ++++++++++-------- tests/test_devices.py | 25 +++++++++++++++++++ tests/test_options_flow.py | 15 ++++++++++- 12 files changed, 103 insertions(+), 30 deletions(-) diff --git a/custom_components/econnect_metronet/alarm_control_panel.py b/custom_components/econnect_metronet/alarm_control_panel.py index 152f3f7..203ead7 100644 --- a/custom_components/econnect_metronet/alarm_control_panel.py +++ b/custom_components/econnect_metronet/alarm_control_panel.py @@ -100,7 +100,7 @@ async def async_alarm_disarm(self, code=None): @set_device_state(STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING) async def async_alarm_arm_away(self, code=None): """Send arm away command.""" - await self.hass.async_add_executor_job(self._device.arm, code) + await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_away) @set_device_state(STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING) async def async_alarm_arm_home(self, code=None): diff --git a/custom_components/econnect_metronet/config_flow.py b/custom_components/econnect_metronet/config_flow.py index 282acee..bfce460 100644 --- a/custom_components/econnect_metronet/config_flow.py +++ b/custom_components/econnect_metronet/config_flow.py @@ -11,6 +11,7 @@ from requests.exceptions import ConnectionError, HTTPError from .const import ( + CONF_AREAS_ARM_AWAY, CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_AREAS_ARM_VACATION, @@ -104,7 +105,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Reconfigure integration options. Available options are: - * Areas armed in Arm Away state + * Areas armed in Arm Away state. If not set all sectors are armed. + * Areas armed in Arm Home state * Areas armed in Arm Night state * Areas armed in Arm Vacation state """ @@ -131,6 +133,10 @@ async def async_step_init(self, user_input=None): step_id="init", data_schema=vol.Schema( { + vol.Optional( + CONF_AREAS_ARM_AWAY, + default=self.config_entry.options.get(CONF_AREAS_ARM_AWAY, []), + ): select(sectors), vol.Optional( CONF_AREAS_ARM_HOME, default=self.config_entry.options.get(CONF_AREAS_ARM_HOME, []), diff --git a/custom_components/econnect_metronet/const.py b/custom_components/econnect_metronet/const.py index a7a04b1..15505c3 100644 --- a/custom_components/econnect_metronet/const.py +++ b/custom_components/econnect_metronet/const.py @@ -8,6 +8,7 @@ CONF_DOMAIN = "domain" CONF_SYSTEM_URL = "system_base_url" CONF_SYSTEM_NAME = "system_name" +CONF_AREAS_ARM_AWAY = "areas_arm_away" CONF_AREAS_ARM_HOME = "areas_arm_home" CONF_AREAS_ARM_NIGHT = "areas_arm_night" CONF_AREAS_ARM_VACATION = "areas_arm_vacation" diff --git a/custom_components/econnect_metronet/devices.py b/custom_components/econnect_metronet/devices.py index 5ca4467..33d20b2 100644 --- a/custom_components/econnect_metronet/devices.py +++ b/custom_components/econnect_metronet/devices.py @@ -13,7 +13,12 @@ ) from requests.exceptions import HTTPError -from .const import CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_AREAS_ARM_VACATION +from .const import ( + CONF_AREAS_ARM_AWAY, + CONF_AREAS_ARM_HOME, + CONF_AREAS_ARM_NIGHT, + CONF_AREAS_ARM_VACATION, +) _LOGGER = logging.getLogger(__name__) @@ -45,6 +50,7 @@ def __init__(self, connection, config=None): # Load user configuration config = config or {} + self._sectors_away = config.get(CONF_AREAS_ARM_AWAY) or [] self._sectors_home = config.get(CONF_AREAS_ARM_HOME) or [] self._sectors_night = config.get(CONF_AREAS_ARM_NIGHT) or [] self._sectors_vacation = config.get(CONF_AREAS_ARM_VACATION) or [] diff --git a/custom_components/econnect_metronet/strings.json b/custom_components/econnect_metronet/strings.json index 5a1adbd..e439fc6 100644 --- a/custom_components/econnect_metronet/strings.json +++ b/custom_components/econnect_metronet/strings.json @@ -30,13 +30,14 @@ "step": { "init": { "data": { - "areas_arm_home": "Armed areas while at home (e.g 3,4 - optional)", - "areas_arm_night": "Armed areas at night (e.g. 3,4 - optional)", - "areas_arm_vacation": "Armed areas when you are on vacation (e.g. 3,4 - optional)", + "areas_arm_away": "Armed areas while away (unset to arm all areas)", + "areas_arm_home": "Armed areas while at home (optional)", + "areas_arm_night": "Armed areas at night (optional)", + "areas_arm_vacation": "Armed areas when you are on vacation (optional)", "scan_interval": "Scan interval (e.g. 120 - optional)" }, - "description": "Define sectors you want to arm in different modes.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", - "title": "Configure your Elmo/IESS system" + "description": "Define sectors you want to arm in different modes. If AWAY section is unset, all sectors are armed.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", + "title": "Configure your e-Connect/Metronet system" } } }, diff --git a/custom_components/econnect_metronet/translations/en.json b/custom_components/econnect_metronet/translations/en.json index 5573c4f..2464560 100644 --- a/custom_components/econnect_metronet/translations/en.json +++ b/custom_components/econnect_metronet/translations/en.json @@ -30,12 +30,13 @@ "step": { "init": { "data": { + "areas_arm_away": "Armed areas while away (unset to arm all areas)", "areas_arm_home": "Armed areas while at home (optional)", "areas_arm_night": "Armed areas at night (optional)", "areas_arm_vacation": "Armed areas when you are on vacation (optional)", "scan_interval": "Scan interval (e.g. 120 - optional)" }, - "description": "Define sectors you want to arm in different modes.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", + "description": "Define sectors you want to arm in different modes. If AWAY section is unset, all sectors are armed.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", "title": "Configure your e-Connect/Metronet system" } } diff --git a/custom_components/econnect_metronet/translations/it.json b/custom_components/econnect_metronet/translations/it.json index 8379cb7..6308327 100644 --- a/custom_components/econnect_metronet/translations/it.json +++ b/custom_components/econnect_metronet/translations/it.json @@ -30,12 +30,13 @@ "step": { "init": { "data": { + "areas_arm_away": "Settori armati mentre sei fuori casa (attiva tutti i settori se non configurato)", "areas_arm_home": "Settori armati mentre sei a casa (opzionale)", "areas_arm_night": "Settori armati di notte (opzionale)", "areas_arm_vacation": "Settori armati quando sei in vacanza (opzionale)", "scan_interval": "Intervallo di scansione (es. 120 - opzionale)" }, - "description": "Scegli, tra quelli proposti, i settori che desideri armare nelle diverse modalità.\n\nImposta il valore 'Intervallo di scansione' solo se desideri ridurre l'utilizzo dei dati, nel caso in cui il sistema sia connesso tramite una rete mobile (SIM). Lascialo vuoto per aggiornamenti in tempo reale, oppure imposta un valore in secondi (es. 120 per un aggiornamento ogni 2 minuti)", + "description": "Scegli, tra quelli proposti, i settori che desideri armare nelle diverse modalità. Se l'opzione FUORI CASA non venisse configurata, tutti i settori saranno attivati in allarme.\n\nImposta il valore 'Intervallo di scansione' solo se desideri ridurre l'utilizzo dei dati, nel caso in cui il sistema sia connesso tramite una rete mobile (SIM). Lascialo vuoto per aggiornamenti in tempo reale, oppure imposta un valore in secondi (es. 120 per un aggiornamento ogni 2 minuti)", "title": "Configura il tuo sistema e-Connect/Metronet" } } diff --git a/tests/conftest.py b/tests/conftest.py index ce1b277..b26a2c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,6 @@ -import logging - import pytest import responses from elmo.api.client import ElmoClient -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from custom_components.econnect_metronet import async_setup from custom_components.econnect_metronet.alarm_control_panel import EconnectAlarm @@ -53,7 +50,7 @@ def alarm_device(client): @pytest.fixture(scope="function") -def alarm_entity(hass, client, config_entry): +def panel(hass, config_entry, alarm_device, coordinator): """Fixture to provide a test instance of the EconnectAlarm entity. This sets up an AlarmDevice and its corresponding DataUpdateCoordinator, @@ -67,11 +64,9 @@ def alarm_entity(hass, client, config_entry): Yields: EconnectAlarm: Initialized test instance of the EconnectAlarm entity. """ - device = AlarmDevice(client) - coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") - entity = EconnectAlarm(unique_id="test_id", config=config_entry, device=device, coordinator=coordinator) + entity = EconnectAlarm(unique_id="test_id", config=config_entry, device=alarm_device, coordinator=coordinator) entity.hass = hass - yield entity + return entity @pytest.fixture(scope="function") diff --git a/tests/test_alarm_panel.py b/tests/test_alarm_panel.py index 27aed74..97488c3 100644 --- a/tests/test_alarm_panel.py +++ b/tests/test_alarm_panel.py @@ -1,5 +1,6 @@ import logging +import pytest from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from custom_components.econnect_metronet.alarm_control_panel import EconnectAlarm @@ -38,3 +39,24 @@ def test_alarm_panel_entity_id_with_system_name(client, hass, config_entry): coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") entity = EconnectAlarm("test_id", config_entry, device, coordinator) assert entity.entity_id == "econnect_metronet.econnect_metronet_home" + + +@pytest.mark.asyncio +async def test_alarm_panel_arm_away(mocker, panel): + # Ensure an empty AWAY config arms all sectors + arm = mocker.patch.object(panel._device._connection, "arm", autopsec=True) + # Test + await panel.async_alarm_arm_away(code=42) + assert arm.call_count == 1 + assert arm.call_args.kwargs["sectors"] == [] + + +@pytest.mark.asyncio +async def test_alarm_panel_arm_away_with_options(mocker, panel): + # Ensure an empty AWAY config arms all sectors + arm = mocker.patch.object(panel._device._connection, "arm", autopsec=True) + panel._device._sectors_away = [1, 2] + # Test + await panel.async_alarm_arm_away(code=42) + assert arm.call_count == 1 + assert arm.call_args.kwargs["sectors"] == [1, 2] diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 9848ce5..604e4c7 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -5,7 +5,7 @@ @pytest.mark.asyncio -async def test_set_device_state_successful(alarm_entity): +async def test_set_device_state_successful(panel): """Should update the device state to the new state.""" @set_device_state("new_state", "loader_state") @@ -13,38 +13,40 @@ async def test_func(self): pass # Test - await test_func(alarm_entity) - assert alarm_entity._device.state == "new_state" + await test_func(panel) + assert panel._device.state == "new_state" @pytest.mark.asyncio -async def test_set_device_state_lock_error(alarm_entity): +async def test_set_device_state_lock_error(panel): """Should revert the device state to the previous state.""" @set_device_state("new_state", "loader_state") async def test_func(self): raise LockError() + panel._device.state = "old_state" # Test - await test_func(alarm_entity) - assert alarm_entity._device.state == "unavailable" + await test_func(panel) + assert panel._device.state == "old_state" @pytest.mark.asyncio -async def test_set_device_state_code_error(alarm_entity): +async def test_set_device_state_code_error(panel): """Should revert the device state to the previous state.""" @set_device_state("new_state", "loader_state") async def test_func(self): raise CodeError() + panel._device.state = "old_state" # Test - await test_func(alarm_entity) - assert alarm_entity._device.state == "unavailable" + await test_func(panel) + assert panel._device.state == "old_state" @pytest.mark.asyncio -async def test_set_device_state_loader_state(alarm_entity): +async def test_set_device_state_loader_state(panel): """Should use the loader_state until the function is completed.""" @set_device_state("new_state", "loader_state") @@ -53,4 +55,4 @@ async def test_func(self): assert self._device.state == "loader_state" # Run test - await test_func(alarm_entity) + await test_func(panel) diff --git a/tests/test_devices.py b/tests/test_devices.py index a392b63..5f0662d 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -13,6 +13,7 @@ from requests.models import Response from custom_components.econnect_metronet.const import ( + CONF_AREAS_ARM_AWAY, CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_AREAS_ARM_VACATION, @@ -27,6 +28,7 @@ def test_device_constructor(client): assert device._connection == client assert device._inventory == {} assert device._last_ids == {10: 0, 9: 0, 11: 0} + assert device._sectors_away == [] assert device._sectors_home == [] assert device._sectors_night == [] assert device._sectors_vacation == [] @@ -36,6 +38,7 @@ def test_device_constructor(client): def test_device_constructor_with_config(client): """Should initialize defaults attributes to run properly.""" config = { + CONF_AREAS_ARM_AWAY: [1, 2, 3, 4, 5], CONF_AREAS_ARM_HOME: [3, 4], CONF_AREAS_ARM_NIGHT: [1, 2, 3], CONF_AREAS_ARM_VACATION: [5, 3], @@ -45,6 +48,7 @@ def test_device_constructor_with_config(client): assert device._connection == client assert device._inventory == {} assert device._last_ids == {10: 0, 9: 0, 11: 0} + assert device._sectors_away == [1, 2, 3, 4, 5] assert device._sectors_home == [3, 4] assert device._sectors_night == [1, 2, 3] assert device._sectors_vacation == [5, 3] @@ -54,6 +58,7 @@ def test_device_constructor_with_config(client): def test_device_constructor_with_config_empty(client): """Should initialize defaults attributes to run properly.""" config = { + CONF_AREAS_ARM_AWAY: None, CONF_AREAS_ARM_HOME: None, CONF_AREAS_ARM_NIGHT: None, CONF_AREAS_ARM_VACATION: None, @@ -63,6 +68,7 @@ def test_device_constructor_with_config_empty(client): assert device._connection == client assert device._inventory == {} assert device._last_ids == {10: 0, 9: 0, 11: 0} + assert device._sectors_away == [] assert device._sectors_home == [] assert device._sectors_night == [] assert device._sectors_vacation == [] @@ -871,6 +877,7 @@ def test_get_state_armed_vacation_out_of_order(alarm_device): def test_get_state_armed_away(alarm_device): """Test when sectors are armed but don't match home or night.""" + alarm_device._sectors_away = [] alarm_device._sectors_home = [1, 2, 3] alarm_device._sectors_night = [4, 5, 6] alarm_device._sectors_vacation = [4, 2] @@ -887,6 +894,7 @@ def test_get_state_armed_away(alarm_device): def test_get_state_armed_mixed(alarm_device): """Test when some sectors from home and night are armed.""" + alarm_device._sectors_away = [] alarm_device._sectors_home = [1, 2, 3] alarm_device._sectors_night = [4, 5, 6] alarm_device._inventory = { @@ -899,3 +907,20 @@ def test_get_state_armed_mixed(alarm_device): } # Test assert alarm_device.get_state() == STATE_ALARM_ARMED_AWAY + + +def test_get_state_armed_away_with_config(alarm_device): + # Ensure arm AWAY is set when it matches the config value + alarm_device._sectors_away = [4] + alarm_device._sectors_home = [1, 2, 3] + alarm_device._sectors_night = [4, 5, 6] + alarm_device._sectors_vacation = [4, 2] + alarm_device._inventory = { + 9: { + 0: {"id": 1, "index": 0, "element": 1, "excluded": False, "status": False, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 2, "excluded": False, "status": False, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 4, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + } + # Test + assert alarm_device.get_state() == STATE_ALARM_ARMED_AWAY diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 99f246f..e5f3e25 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -25,11 +25,13 @@ async def test_form_fields(self, hass, config_entry): assert form["step_id"] == "init" assert form["errors"] == {} assert list(form["data_schema"].schema.keys()) == [ + "areas_arm_away", "areas_arm_home", "areas_arm_night", "areas_arm_vacation", "scan_interval", ] + assert isinstance(form["data_schema"].schema["areas_arm_away"], select) assert isinstance(form["data_schema"].schema["areas_arm_home"], select) assert isinstance(form["data_schema"].schema["areas_arm_night"], select) assert isinstance(form["data_schema"].schema["areas_arm_vacation"], select) @@ -49,7 +51,12 @@ async def test_form_submit_successful_empty(self, hass, config_entry): # Check HA config assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" - assert result["data"] == {"areas_arm_vacation": [], "areas_arm_home": [], "areas_arm_night": []} + assert result["data"] == { + "areas_arm_vacation": [], + "areas_arm_home": [], + "areas_arm_night": [], + "areas_arm_away": [], + } async def test_form_submit_invalid_type(self, hass, config_entry): # Ensure it fails if a user submits an option with an invalid type @@ -102,6 +109,7 @@ async def test_form_submit_successful_with_identifier(self, hass, config_entry): assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" assert result["data"] == { + "areas_arm_away": [], "areas_arm_home": [1], "areas_arm_night": [], "areas_arm_vacation": [], @@ -127,6 +135,7 @@ async def test_form_submit_successful_with_input(self, hass, config_entry): assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" assert result["data"] == { + "areas_arm_away": [], "areas_arm_home": [(1, "S1 Living Room")], "areas_arm_night": [], "areas_arm_vacation": [], @@ -142,6 +151,9 @@ async def test_form_submit_successful_with_multiple_inputs(self, hass, config_en result = await hass.config_entries.options.async_configure( form["flow_id"], user_input={ + "areas_arm_away": [ + (2, "S2 Bedroom"), + ], "areas_arm_home": [ (1, "S1 Living Room"), ], @@ -156,6 +168,7 @@ async def test_form_submit_successful_with_multiple_inputs(self, hass, config_en assert result["type"] == "create_entry" assert result["title"] == "e-Connect/Metronet Alarm" assert result["data"] == { + "areas_arm_away": [(2, "S2 Bedroom")], "areas_arm_home": [(1, "S1 Living Room")], "areas_arm_night": [(1, "S1 Living Room")], "areas_arm_vacation": [(1, "S1 Living Room"), (2, "S2 Bedroom")],