From 802a225e1106da77d8087b40ff3dcad35ed89b4b Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:09:48 +0100 Subject: [PATCH] Clean alarm control panel platform for Satel Integra (#156357) --- .../satel_integra/alarm_control_panel.py | 86 +++++---- .../snapshots/test_alarm_control_panel.ambr | 84 +++++++++ .../satel_integra/test_alarm_control_panel.py | 166 ++++++++++++++++++ 3 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr create mode 100644 tests/components/satel_integra/test_alarm_control_panel.py diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 510193b5de7..ffb46d4dbe8 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections import OrderedDict import logging -from satel_integra.satel_integra import AlarmState +from satel_integra.satel_integra import AlarmState, AsyncSatel from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -16,17 +15,31 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ARM_HOME_MODE, CONF_PARTITION_NUMBER, + DOMAIN, SIGNAL_PANEL_MESSAGE, SUBENTRY_TYPE_PARTITION, SatelConfigEntry, ) +ALARM_STATE_MAP = { + AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED, + AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED, + AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING, + AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME, + AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME, + AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME, + AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY, + AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING, + AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING, +} + _LOGGER = logging.getLogger(__name__) @@ -45,9 +58,9 @@ async def async_setup_entry( ) for subentry in partition_subentries: - partition_num = subentry.data[CONF_PARTITION_NUMBER] - zone_name = subentry.data[CONF_NAME] - arm_home_mode = subentry.data[CONF_ARM_HOME_MODE] + partition_num: int = subentry.data[CONF_PARTITION_NUMBER] + zone_name: str = subentry.data[CONF_NAME] + arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE] async_add_entities( [ @@ -73,20 +86,31 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_has_entity_name = True + _attr_name = None + def __init__( - self, controller, name, arm_home_mode, partition_id, config_entry_id + self, + controller: AsyncSatel, + device_name: str, + arm_home_mode: int, + partition_id: int, + config_entry_id: str, ) -> None: """Initialize the alarm panel.""" - self._attr_name = name self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}" self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller + self._attr_device_info = DeviceInfo( + name=device_name, identifiers={(DOMAIN, self._attr_unique_id)} + ) + async def async_added_to_hass(self) -> None: """Update alarm status and register callbacks for future updates.""" - _LOGGER.debug("Starts listening for panel messages") - self._update_alarm_status() + self._attr_alarm_state = self._read_alarm_state() + self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status @@ -94,55 +118,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): ) @callback - def _update_alarm_status(self): + def _update_alarm_status(self) -> None: """Handle alarm status update.""" state = self._read_alarm_state() - _LOGGER.debug("Got status update, current status: %s", state) + if state != self._attr_alarm_state: self._attr_alarm_state = state self.async_write_ha_state() - else: - _LOGGER.debug("Ignoring alarm status message, same state") - def _read_alarm_state(self): + def _read_alarm_state(self) -> AlarmControlPanelState | None: """Read current status of the alarm and translate it into HA status.""" - # Default - disarmed: - hass_alarm_status = AlarmControlPanelState.DISARMED - if not self._satel.connected: + _LOGGER.debug("Alarm panel not connected") return None - state_map = OrderedDict( - [ - (AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED), - (AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED), - (AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING), - (AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME), - (AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME), - (AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME), - (AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY), - ( - AlarmState.EXIT_COUNTDOWN_OVER_10, - AlarmControlPanelState.PENDING, - ), - ( - AlarmState.EXIT_COUNTDOWN_UNDER_10, - AlarmControlPanelState.PENDING, - ), - ] - ) - _LOGGER.debug("State map of Satel: %s", self._satel.partition_states) - - for satel_state, ha_state in state_map.items(): + for satel_state, ha_state in ALARM_STATE_MAP.items(): if ( satel_state in self._satel.partition_states and self._partition_id in self._satel.partition_states[satel_state] ): - hass_alarm_status = ha_state - break + return ha_state - return hass_alarm_status + return AlarmControlPanelState.DISARMED async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -154,8 +152,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): self._attr_alarm_state == AlarmControlPanelState.TRIGGERED ) - _LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state) - await self._satel.disarm(code, [self._partition_id]) if clear_alarm_necessary: @@ -165,14 +161,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - _LOGGER.debug("Arming away") if code: await self._satel.arm(code, [self._partition_id]) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - _LOGGER.debug("Arming home") if code: await self._satel.arm(code, [self._partition_id], self._arm_home_mode) diff --git a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..33d0214976c --- /dev/null +++ b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,84 @@ +# serializer version: 1 +# name: test_alarm_control_panel[alarm_control_panel.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'satel_integra', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234567890_alarm_panel_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[alarm_control_panel.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': , + 'friendly_name': 'Home', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'satel_integra', + '1234567890_alarm_panel_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Home', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/satel_integra/test_alarm_control_panel.py b/tests/components/satel_integra/test_alarm_control_panel.py new file mode 100644 index 00000000000..36c3ff55787 --- /dev/null +++ b/tests/components/satel_integra/test_alarm_control_panel.py @@ -0,0 +1,166 @@ +"""Test Satel Integra alarm panel.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from satel_integra.satel_integra import AlarmState +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_DOMAIN, + AlarmControlPanelState, +) +from homeassistant.components.satel_integra.const import DOMAIN +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import MOCK_CODE, MOCK_ENTRY_ID, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def alarm_control_panel_only() -> AsyncGenerator[None]: + """Enable only the alarm panel platform.""" + with patch( + "homeassistant.components.satel_integra.PLATFORMS", + [Platform.ALARM_CONTROL_PANEL], + ): + yield + + +@pytest.mark.usefixtures("mock_satel") +async def test_alarm_control_panel( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry_with_subentries: MockConfigEntry, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test alarm control panel correctly being set up.""" + await setup_integration(hass, mock_config_entry_with_subentries) + + await snapshot_platform(hass, entity_registry, snapshot, MOCK_ENTRY_ID) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "1234567890_alarm_panel_1")} + ) + + assert device_entry == snapshot(name="device") + + +async def test_alarm_control_panel_initial_state_on( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test alarm control panel has a correct initial state after initialization.""" + mock_satel.partition_states = {AlarmState.ARMED_MODE0: [1]} + + await setup_integration(hass, mock_config_entry_with_subentries) + + assert ( + hass.states.get("alarm_control_panel.home").state + == AlarmControlPanelState.ARMED_AWAY + ) + + +@pytest.mark.parametrize( + ("source_state", "resulting_state"), + [ + (AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED), + (AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED), + (AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING), + (AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME), + (AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME), + (AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME), + (AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY), + (AlarmState.EXIT_COUNTDOWN_OVER_10, AlarmControlPanelState.ARMING), + (AlarmState.EXIT_COUNTDOWN_UNDER_10, AlarmControlPanelState.ARMING), + ], +) +async def test_alarm_status_callback( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, + source_state: AlarmState, + resulting_state: AlarmControlPanelState, +) -> None: + """Test alarm control panel correctly changes state after a callback from the panel.""" + await setup_integration(hass, mock_config_entry_with_subentries) + + assert ( + hass.states.get("alarm_control_panel.home").state + == AlarmControlPanelState.DISARMED + ) + + monitor_status_call = mock_satel.monitor_status.call_args_list[0][0] + alarm_panel_update_method = monitor_status_call[0] + + mock_satel.partition_states = {source_state: [1]} + + alarm_panel_update_method() + assert hass.states.get("alarm_control_panel.home").state == resulting_state + + +async def test_alarm_control_panel_arming( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test alarm control panel correctly sending arming commands to the panel.""" + await setup_integration(hass, mock_config_entry_with_subentries) + + # Test Arm home + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: "alarm_control_panel.home", ATTR_CODE: MOCK_CODE}, + blocking=True, + ) + + mock_satel.arm.assert_awaited_once_with(MOCK_CODE, [1], 1) + + mock_satel.arm.reset_mock() + + # Test Arm away + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: "alarm_control_panel.home", ATTR_CODE: MOCK_CODE}, + blocking=True, + ) + + mock_satel.arm.assert_awaited_once_with(MOCK_CODE, [1]) + + +async def test_alarm_control_panel_disarming( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test alarm panel correctly disarms and dismisses alarm.""" + mock_satel.partition_states = {AlarmState.TRIGGERED: [1]} + + await setup_integration(hass, mock_config_entry_with_subentries) + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.home", ATTR_CODE: MOCK_CODE}, + blocking=True, + ) + + mock_satel.disarm.assert_awaited_once_with(MOCK_CODE, [1]) + + mock_satel.clear_alarm.assert_awaited_once_with(MOCK_CODE, [1])