diff --git a/custom_components/econnect_alarm/__init__.py b/custom_components/econnect_alarm/__init__.py index 975bbf0..ea6b8bc 100644 --- a/custom_components/econnect_alarm/__init__.py +++ b/custom_components/econnect_alarm/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # and asks for the first update, hence why in `async_setup_entry` there is no need # to call `coordinator.async_refresh()`. client = ElmoClient(BASE_URL, entry.data[CONF_DOMAIN]) - device = AlarmDevice(connection=client) + device = AlarmDevice(client, entry.options) await hass.async_add_executor_job(device.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) # Execute device update in a thread pool diff --git a/custom_components/econnect_alarm/alarm_control_panel.py b/custom_components/econnect_alarm/alarm_control_panel.py index dd7278c..7b542ef 100644 --- a/custom_components/econnect_alarm/alarm_control_panel.py +++ b/custom_components/econnect_alarm/alarm_control_panel.py @@ -11,19 +11,19 @@ SUPPORT_ALARM_ARM_NIGHT, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_AREAS_ARM_HOME, - CONF_AREAS_ARM_NIGHT, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, -) +from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE from .decorators import set_device_state -from .helpers import parse_areas_config _LOGGER = logging.getLogger(__name__) @@ -33,9 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_d device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE] coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] unique_id = entry.entry_id - # Optional arming areas - areas_home = parse_areas_config(entry.options.get(CONF_AREAS_ARM_HOME)) - areas_night = parse_areas_config(entry.options.get(CONF_AREAS_ARM_NIGHT)) async_add_devices( [ EconnectAlarm( @@ -43,8 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_d device, coordinator, unique_id, - areas_home=areas_home, - areas_night=areas_night, ) ] ) @@ -53,14 +48,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_d class EconnectAlarm(CoordinatorEntity, AlarmControlPanelEntity): """E-connect alarm entity.""" - def __init__(self, name, device, coordinator, unique_id, areas_home=None, areas_night=None): + def __init__(self, name, device, coordinator, unique_id): """Construct.""" super().__init__(coordinator) self._name = name self._device = device self._unique_id = unique_id - self._areas_home = areas_home - self._areas_night = areas_night @property def unique_id(self): @@ -92,30 +85,30 @@ def supported_features(self): """Return the list of supported features.""" return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - @set_device_state(STATE_ALARM_DISARMING) + @set_device_state(STATE_ALARM_DISARMED, STATE_ALARM_DISARMING) async def async_alarm_disarm(self, code=None): """Send disarm command.""" await self.hass.async_add_executor_job(self._device.disarm, code) - @set_device_state(STATE_ALARM_ARMING) + @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) - @set_device_state(STATE_ALARM_ARMING) + @set_device_state(STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING) async def async_alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._areas_home: + if not self._device._sectors_home: _LOGGER.warning("Triggering ARM HOME without configuration. Use integration Options to configure it.") return - await self.hass.async_add_executor_job(self._device.arm, code, self._areas_home) + await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_home) - @set_device_state(STATE_ALARM_ARMING) + @set_device_state(STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING) async def async_alarm_arm_night(self, code=None): """Send arm night command.""" - if not self._areas_night: + if not self._device._sectors_night: _LOGGER.warning("Triggering ARM NIGHT without configuration. Use integration Options to configure it.") return - await self.hass.async_add_executor_job(self._device.arm, code, self._areas_night) + await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_night) diff --git a/custom_components/econnect_alarm/binary_sensor.py b/custom_components/econnect_alarm/binary_sensor.py index 2e68710..7941255 100644 --- a/custom_components/econnect_alarm/binary_sensor.py +++ b/custom_components/econnect_alarm/binary_sensor.py @@ -1,8 +1,5 @@ """Module for e-connect binary sensors (sectors and inputs).""" -import logging - from elmo import query -from elmo.devices import AlarmDevice from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,9 +9,9 @@ DataUpdateCoordinator, ) -from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE +from custom_components.econnect_alarm.devices import AlarmDevice -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE async def async_setup_entry( diff --git a/custom_components/econnect_alarm/config_flow.py b/custom_components/econnect_alarm/config_flow.py index 72bc22a..087ae07 100644 --- a/custom_components/econnect_alarm/config_flow.py +++ b/custom_components/econnect_alarm/config_flow.py @@ -10,7 +10,7 @@ from .const import CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_DOMAIN, DOMAIN from .exceptions import InvalidAreas -from .helpers import validate_areas, validate_credentials +from .helpers import parse_areas_config, validate_credentials _LOGGER = logging.getLogger(__name__) @@ -93,7 +93,8 @@ async def async_step_init(self, user_input=None): errors = {} if user_input is not None: try: - await validate_areas(self.hass, user_input) + parse_areas_config(user_input.get(CONF_AREAS_ARM_HOME), raises=True) + parse_areas_config(user_input.get(CONF_AREAS_ARM_NIGHT), raises=True) except InvalidAreas: errors["base"] = "invalid_areas" except Exception as err: # pylint: disable=broad-except diff --git a/custom_components/econnect_alarm/decorators.py b/custom_components/econnect_alarm/decorators.py index bd68d27..89c8714 100644 --- a/custom_components/econnect_alarm/decorators.py +++ b/custom_components/econnect_alarm/decorators.py @@ -7,7 +7,7 @@ _LOGGER = logging.getLogger(__name__) -def set_device_state(new_state): +def set_device_state(new_state, loader_state): """Set a new Alarm device state, or revert to a previous state in case of error. This decorator is used to convert a library exception in a log warning, while @@ -20,10 +20,13 @@ def decorator(func): async def func_wrapper(*args, **kwargs): self = args[0] previous_state = self._device.state - self._device.state = new_state + self._device.state = loader_state self.async_write_ha_state() try: - return await func(*args, **kwargs) + result = await func(*args, **kwargs) + self._device.state = new_state + self.async_write_ha_state() + return result except LockError: _LOGGER.warning( "Impossible to obtain the lock. Be sure you inserted the code, or that nobody is using the panel." diff --git a/custom_components/econnect_alarm/devices.py b/custom_components/econnect_alarm/devices.py index 26a7fcc..6d08092 100644 --- a/custom_components/econnect_alarm/devices.py +++ b/custom_components/econnect_alarm/devices.py @@ -5,11 +5,16 @@ from elmo.utils import _filter_data from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_UNAVAILABLE, ) from requests.exceptions import HTTPError +from .const import CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT +from .helpers import parse_areas_config + _LOGGER = logging.getLogger(__name__) @@ -28,14 +33,21 @@ class AlarmDevice: print(device.state) """ - def __init__(self, connection): + def __init__(self, connection, config=None): # Configuration and internals self._connection = connection + self._sectors_home = [] + self._sectors_night = [] self._lastIds = { q.SECTORS: 0, q.INPUTS: 0, } + # Load user configuration + if config is not None: + self._sectors_home = parse_areas_config(config.get(CONF_AREAS_ARM_HOME)) + self._sectors_night = parse_areas_config(config.get(CONF_AREAS_ARM_NIGHT)) + # Alarm state self.state = STATE_UNAVAILABLE self.sectors_armed = {} @@ -78,6 +90,33 @@ def has_updates(self): _LOGGER.error(f"Device | Error parsing the poll response: {err}") raise err + def get_state(self): + """Determine the alarm state based on the armed sectors. + + This method evaluates the armed sectors and maps them to predefined + alarm states: home, night, or away. If no sectors are armed, it returns + a disarmed state. For accurate comparisons, the method sorts the sectors + internally, ensuring robustness against potentially unsorted input. + + Returns: + str: One of the predefined HA alarm states. + """ + if not self.sectors_armed: + return STATE_ALARM_DISARMED + + # Note: `element` is the sector ID you use to arm/disarm the sector. + sectors = [sectors["element"] for sectors in self.sectors_armed.values()] + # Sort lists here for robustness, ensuring accurate comparisons + # regardless of whether the input lists were pre-sorted or not. + sectors_armed_sorted = sorted(sectors) + if sectors_armed_sorted == sorted(self._sectors_home): + return STATE_ALARM_ARMED_HOME + + if sectors_armed_sorted == sorted(self._sectors_night): + return STATE_ALARM_ARMED_NIGHT + + return STATE_ALARM_ARMED_AWAY + def update(self): """Updates the internal state of the device based on the latest data. @@ -116,8 +155,8 @@ def update(self): self._lastIds[q.SECTORS] = sectors.get("last_id", 0) self._lastIds[q.INPUTS] = inputs.get("last_id", 0) - # Update the internal state machine - self.state = STATE_ALARM_ARMED_AWAY if self.sectors_armed else STATE_ALARM_DISARMED + # Update the internal state machine (mapping state) + self.state = self.get_state() def arm(self, code, sectors=None): try: diff --git a/custom_components/econnect_alarm/helpers.py b/custom_components/econnect_alarm/helpers.py index 0663fca..b3882ea 100644 --- a/custom_components/econnect_alarm/helpers.py +++ b/custom_components/econnect_alarm/helpers.py @@ -3,35 +3,46 @@ from homeassistant import core from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import BASE_URL, CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_DOMAIN +from .const import BASE_URL, CONF_DOMAIN from .exceptions import InvalidAreas -def parse_areas_config(config, raises=False): - """Parse area config that is represented as a comma separated value. +def parse_areas_config(config: str, raises: bool = False): + """Parses a comma-separated string of area configurations into a list of integers. - Usage: - parse_areas_config("3,4") # Returns [3, 4] + Takes a string containing comma-separated area IDs and converts it to a list of integers. + In case of any parsing errors, either raises a custom `InvalidAreas` exception or returns an empty list + based on the `raises` flag. Args: - config: The string that is stored in the configuration registry. - raises: If set `True`, raises exceptions if they happen. - Raises: - ValueError: If given config is not a list of integers. - AttributeError: If given config is `None` object. + config (str): A comma-separated string of area IDs, e.g., "3,4". + raises (bool, optional): Determines the error handling behavior. If `True`, the function + raises the `InvalidAreas` exception upon encountering a parsing error. + If `False`, it suppresses the error and returns an empty list. + Defaults to `False`. + Returns: - A Python list with integers representing areas ID, such as `[3, 4]`, - or `None` if invalid. + list[int]: A list of integers representing area IDs. If parsing fails and `raises` is `False`, + returns an empty list. + + Raises: + InvalidAreas: If there's a parsing error and the `raises` flag is set to `True`. + + Examples: + >>> parse_areas_config("3,4") + [3, 4] + >>> parse_areas_config("3,a") + [] """ try: return [int(x) for x in config.split(",")] - except (ValueError, AttributeError) as err: + except (ValueError, AttributeError): if raises: - raise err - return None + raise InvalidAreas + return [] -async def validate_credentials(hass: core.HomeAssistant, data): +async def validate_credentials(hass: core.HomeAssistant, config: dict): """Validate if user input includes valid credentials to connect. Initialize the client with an API endpoint and a vendor and authenticate @@ -49,30 +60,6 @@ async def validate_credentials(hass: core.HomeAssistant, data): e-connect backend. """ # Check Credentials - client = ElmoClient(BASE_URL, domain=data.get(CONF_DOMAIN)) - await hass.async_add_executor_job(client.auth, data[CONF_USERNAME], data[CONF_PASSWORD]) + client = ElmoClient(BASE_URL, domain=config.get(CONF_DOMAIN)) + await hass.async_add_executor_job(client.auth, config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) return True - - -async def validate_areas(hass: core.HomeAssistant, data): - """Validate if user input is a valid list of areas. - - Args: - hass: HomeAssistant instance. - data: data that needs validation (configured areas). - Raises: - InvalidAreas: if the given list of areas is not parsable in a - Python list. - Returns: - `True` if given `data` includes properly formatted areas. - """ - - try: - # Check if areas are parsable - if data.get(CONF_AREAS_ARM_HOME): - parse_areas_config(data[CONF_AREAS_ARM_HOME], raises=True) - if data.get(CONF_AREAS_ARM_NIGHT): - parse_areas_config(data[CONF_AREAS_ARM_NIGHT], raises=True) - return True - except (ValueError, AttributeError): - raise InvalidAreas diff --git a/tests/conftest.py b/tests/conftest.py index 77956a3..b6cec0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ +import logging + import pytest import responses from elmo.api.client import ElmoClient +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from custom_components.econnect_alarm.alarm_control_panel import EconnectAlarm +from custom_components.econnect_alarm.devices import AlarmDevice from .fixtures import responses as r @@ -13,6 +19,30 @@ async def hass(hass): yield hass +@pytest.fixture(scope="function") +def alarm_entity(hass, client): + """Fixture to provide a test instance of the EconnectAlarm entity. + + This sets up an AlarmDevice and its corresponding DataUpdateCoordinator, + then initializes the EconnectAlarm entity with a test name and ID. It also + assigns the Home Assistant instance and a mock entity ID to the created entity. + + Args: + hass: Mock Home Assistant instance. + client: Mock client for the AlarmDevice. + + Yields: + EconnectAlarm: Initialized test instance of the EconnectAlarm entity. + """ + device = AlarmDevice(client) + coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_alarm") + entity = EconnectAlarm(name="Test Alarm", device=device, coordinator=coordinator, unique_id="test_id") + # Set up the fixture + entity.hass = hass + entity.entity_id = "econnect_alarm.test_id" + yield entity + + @pytest.fixture(scope="function") def client(): """Creates an instance of `ElmoClient` which emulates the behavior of a real client for diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..d47fbff --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,56 @@ +import pytest +from elmo.api.exceptions import CodeError, LockError + +from custom_components.econnect_alarm.decorators import set_device_state + + +@pytest.mark.asyncio +async def test_set_device_state_successful(alarm_entity): + """Should update the device state to the new state.""" + + @set_device_state("new_state", "loader_state") + async def test_func(self): + pass + + # Test + await test_func(alarm_entity) + assert alarm_entity._device.state == "new_state" + + +@pytest.mark.asyncio +async def test_set_device_state_lock_error(alarm_entity): + """Should revert the device state to the previous state.""" + + @set_device_state("new_state", "loader_state") + async def test_func(self): + raise LockError() + + # Test + await test_func(alarm_entity) + assert alarm_entity._device.state == "unavailable" + + +@pytest.mark.asyncio +async def test_set_device_state_code_error(alarm_entity): + """Should revert the device state to the previous state.""" + + @set_device_state("new_state", "loader_state") + async def test_func(self): + raise CodeError() + + # Test + await test_func(alarm_entity) + assert alarm_entity._device.state == "unavailable" + + +@pytest.mark.asyncio +async def test_set_device_state_loader_state(alarm_entity): + """Should use the loader_state until the function is completed.""" + + @set_device_state("new_state", "loader_state") + async def test_func(self): + # Test (what runs here is before the function is completed) + assert self._device.state == "loader_state" + + # Run test + await test_func(alarm_entity) diff --git a/tests/test_devices.py b/tests/test_devices.py index 4388133..11cf889 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,18 +1,50 @@ import pytest from elmo import query as q from elmo.api.exceptions import CodeError, CredentialError, LockError, ParseError +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_UNAVAILABLE, +) from requests.exceptions import HTTPError +from custom_components.econnect_alarm.const import ( + CONF_AREAS_ARM_HOME, + CONF_AREAS_ARM_NIGHT, +) from custom_components.econnect_alarm.devices import AlarmDevice def test_device_constructor(client): """Should initialize defaults attributes to run properly.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) # Test assert device._connection == client assert device._lastIds == {q.SECTORS: 0, q.INPUTS: 0} - assert device.state == "unavailable" + assert device._sectors_home == [] + assert device._sectors_night == [] + assert device.state == STATE_UNAVAILABLE + assert device.sectors_armed == {} + assert device.sectors_disarmed == {} + assert device.inputs_alerted == {} + assert device.inputs_wait == {} + + +def test_device_constructor_with_config(client): + """Should initialize defaults attributes to run properly.""" + config = { + CONF_AREAS_ARM_HOME: "3, 4", + CONF_AREAS_ARM_NIGHT: "1, 2, 3", + } + device = AlarmDevice(client, config=config) + # Test + assert device._connection == client + assert device._lastIds == {q.SECTORS: 0, q.INPUTS: 0} + assert device._sectors_home == [3, 4] + assert device._sectors_night == [1, 2, 3] + assert device.state == STATE_UNAVAILABLE assert device.sectors_armed == {} assert device.sectors_disarmed == {} assert device.inputs_alerted == {} @@ -21,7 +53,7 @@ def test_device_constructor(client): def test_device_connect(client, mocker): """Should call authentication endpoints and update internal state.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "auth") # Test device.connect("username", "password") @@ -32,7 +64,7 @@ def test_device_connect(client, mocker): def test_device_connect_error(client, mocker): """Should handle (log) authentication errors (not 2xx).""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "auth") device._connection.auth.side_effect = HTTPError("Unable to communicate with e-Connect") # Test @@ -43,7 +75,7 @@ def test_device_connect_error(client, mocker): def test_device_connect_credential_error(client, mocker): """Should handle (log) credential errors (401/403).""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "auth") device._connection.auth.side_effect = CredentialError("Incorrect username and/or password") # Test @@ -54,7 +86,7 @@ def test_device_connect_credential_error(client, mocker): def test_device_has_updates(client, mocker): """Should call the client polling system passing the internal state.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) device.connect("username", "password") device._lastIds[q.SECTORS] = 20 device._lastIds[q.INPUTS] = 20 @@ -72,7 +104,7 @@ def bad_poll(ids): ids[q.SECTORS] = 0 ids[q.INPUTS] = 0 - device = AlarmDevice(connection=client) + device = AlarmDevice(client) device._lastIds = {q.SECTORS: 4, q.INPUTS: 42} mocker.patch.object(device._connection, "poll") device._connection.poll.side_effect = bad_poll @@ -84,7 +116,7 @@ def bad_poll(ids): def test_device_has_updates_errors(client, mocker): """Should handle (log) polling errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "poll") device._connection.poll.side_effect = HTTPError("Unable to communicate with e-Connect") # Test @@ -96,7 +128,7 @@ def test_device_has_updates_errors(client, mocker): def test_device_has_updates_parse_errors(client, mocker): """Should handle (log) polling errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "poll") device._connection.poll.side_effect = ParseError("Error parsing the poll response") # Test @@ -108,7 +140,7 @@ def test_device_has_updates_parse_errors(client, mocker): def test_device_update_success(client, mocker): """Should check store the e-connect System status in the device object.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "query") sectors_armed = { 0: {"id": 1, "index": 0, "element": 1, "excluded": False, "status": True, "name": "S1 Living Room"}, @@ -140,7 +172,7 @@ def test_device_update_success(client, mocker): def test_device_update_http_error(client, mocker): """Tests if device's update method raises HTTPError when querying.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "query", side_effect=HTTPError("HTTP Error")) with pytest.raises(HTTPError): device.update() @@ -148,7 +180,7 @@ def test_device_update_http_error(client, mocker): def test_device_update_parse_error(client, mocker): """Tests if update method raises ParseError when querying.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "query", side_effect=ParseError("Parse Error")) with pytest.raises(ParseError): device.update() @@ -156,7 +188,7 @@ def test_device_update_parse_error(client, mocker): def test_device_update_state_machine_armed(client, mocker): """Should check if the state machine is properly updated after calling update().""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "query") device._connection.query.side_effect = [ { @@ -183,7 +215,7 @@ def test_device_update_state_machine_armed(client, mocker): def test_device_update_state_machine_disarmed(client, mocker): """Should check if the state machine is properly updated after calling update().""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "query") device._connection.query.side_effect = [ { @@ -211,7 +243,7 @@ def test_device_update_state_machine_disarmed(client, mocker): @pytest.mark.xfail def test_device_update_query_not_valid(client, mocker): """Should not crash if an exception is raised.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.patch.object(device._connection, "query") device._connection.query.side_effect = Exception("Unexpected") # Test @@ -220,7 +252,7 @@ def test_device_update_query_not_valid(client, mocker): def test_device_arm_success(client, mocker): """Should arm the e-connect system using the underlying client.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "arm") # Test @@ -234,7 +266,7 @@ def test_device_arm_success(client, mocker): def test_device_arm_error(client, mocker): """Should handle (log) connection errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "arm") device._connection.lock.side_effect = HTTPError("Unable to communicate with e-Connect") @@ -248,7 +280,7 @@ def test_device_arm_error(client, mocker): def test_device_arm_lock_error(client, mocker): """Should handle (log) locking errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "arm") device._connection.lock.side_effect = LockError("Unable to acquire the lock") @@ -262,7 +294,7 @@ def test_device_arm_lock_error(client, mocker): def test_device_arm_code_error(client, mocker): """Should handle (log) code errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "arm") device._connection.lock.side_effect = CodeError("Code is incorrect") @@ -276,7 +308,7 @@ def test_device_arm_code_error(client, mocker): def test_device_disarm_success(client, mocker): """Should disarm the e-connect system using the underlying client.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "disarm") # Test @@ -291,7 +323,7 @@ def test_device_disarm_success(client, mocker): def test_device_disarm_error(client, mocker): """Should handle (log) connection errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "disarm") device._connection.lock.side_effect = HTTPError("Unable to communicate with e-Connect") @@ -305,7 +337,7 @@ def test_device_disarm_error(client, mocker): def test_device_disarm_lock_error(client, mocker): """Should handle (log) locking errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "disarm") device._connection.lock.side_effect = LockError("Unable to acquire the lock") @@ -319,7 +351,7 @@ def test_device_disarm_lock_error(client, mocker): def test_device_disarm_code_error(client, mocker): """Should handle (log) code errors.""" - device = AlarmDevice(connection=client) + device = AlarmDevice(client) mocker.spy(device._connection, "lock") mocker.spy(device._connection, "disarm") device._connection.lock.side_effect = CodeError("Code is incorrect") @@ -329,3 +361,94 @@ def test_device_disarm_code_error(client, mocker): device.disarm("1234", sectors=[4]) assert device._connection.lock.call_count == 1 assert device._connection.disarm.call_count == 0 + + +def test_get_state_no_sectors_armed(client): + """Test when no sectors are armed.""" + device = AlarmDevice(client) + device._sectors_home = [] + device._sectors_night = [] + device.sectors_armed = {} + # Test + assert device.get_state() == STATE_ALARM_DISARMED + + +def test_get_state_armed_home(client): + """Test when sectors are armed for home.""" + device = AlarmDevice(client) + device._sectors_home = [1, 2, 3] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 1, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 2, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 3, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + # Test + assert device.get_state() == STATE_ALARM_ARMED_HOME + + +def test_get_state_armed_home_out_of_order(client): + """Test when sectors are armed for home (out of order).""" + device = AlarmDevice(client) + device._sectors_home = [2, 1, 3] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 3, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 1, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 2, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + # Test + assert device.get_state() == STATE_ALARM_ARMED_HOME + + +def test_get_state_armed_night(client): + """Test when sectors are armed for night.""" + device = AlarmDevice(client) + device._sectors_night = [4, 5, 6] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 4, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 5, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 6, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + # Test (out of order keys to test sorting) + assert device.get_state() == STATE_ALARM_ARMED_NIGHT + + +def test_get_state_armed_night_out_of_order(client): + """Test when sectors are armed for night (out of order).""" + device = AlarmDevice(client) + device._sectors_night = [5, 6, 4] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 6, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 4, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 5, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + # Test + assert device.get_state() == STATE_ALARM_ARMED_NIGHT + + +def test_get_state_armed_away(client): + """Test when sectors are armed but don't match home or night.""" + device = AlarmDevice(client) + device._sectors_home = [1, 2, 3] + device._sectors_night = [4, 5, 6] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 1, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 2, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 4, "excluded": False, "status": True, "name": "S3 Outdoor"}, + } + # Test + assert device.get_state() == STATE_ALARM_ARMED_AWAY + + +def test_get_state_armed_mixed(client): + """Test when some sectors from home and night are armed.""" + device = AlarmDevice(client) + device._sectors_home = [1, 2, 3] + device._sectors_night = [4, 5, 6] + device.sectors_armed = { + 0: {"id": 1, "index": 0, "element": 1, "excluded": False, "status": True, "name": "S1 Living Room"}, + 1: {"id": 2, "index": 1, "element": 2, "excluded": False, "status": True, "name": "S2 Bedroom"}, + 2: {"id": 3, "index": 2, "element": 3, "excluded": False, "status": True, "name": "S3 Outdoor"}, + 3: {"id": 4, "index": 3, "element": 5, "excluded": False, "status": True, "name": "S5 Perimeter"}, + } + # Test + assert device.get_state() == STATE_ALARM_ARMED_AWAY diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..736fb39 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,33 @@ +import pytest + +from custom_components.econnect_alarm.exceptions import InvalidAreas +from custom_components.econnect_alarm.helpers import parse_areas_config + + +def test_parse_areas_config_valid_input(): + assert parse_areas_config("3,4") == [3, 4] + assert parse_areas_config("1,2,3,4,5") == [1, 2, 3, 4, 5] + assert parse_areas_config("10") == [10] + assert parse_areas_config("") == [] + + +def test_parse_areas_config_invalid_input(): + assert parse_areas_config("3,a") == [] + assert parse_areas_config("3.4") == [] + assert parse_areas_config("3,") == [] + + +def test_parse_areas_config_raises_value_error(): + with pytest.raises(InvalidAreas): + parse_areas_config("3,a", raises=True) + with pytest.raises(InvalidAreas): + parse_areas_config("3.4", raises=True) + + +def test_parse_areas_config_raises_attribute_error(): + with pytest.raises(InvalidAreas): + parse_areas_config(None, raises=True) + + +def test_parse_areas_config_whitespace(): + assert parse_areas_config(" 3 , 4 ") == [3, 4]