From 21399818afa2ca4879589e34b517e95e4b41ffc2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 18 Sep 2025 20:43:38 +0200 Subject: [PATCH] Remove stale devices for Comelit SimpleHome (#151519) --- .../components/comelit/coordinator.py | 61 ++++++- .../components/comelit/quality_scale.yaml | 4 +- tests/components/comelit/const.py | 40 ++--- .../comelit/test_alarm_control_panel.py | 14 +- tests/components/comelit/test_coordinator.py | 160 ++++++++++++++++++ 5 files changed, 243 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a5a90c07568..8818e296e03 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,7 +2,7 @@ from abc import abstractmethod from datetime import timedelta -from typing import TypeVar +from typing import Any, TypeVar from aiocomelit.api import ( AlarmDataObject, @@ -13,7 +13,16 @@ from aiocomelit.api import ( ComelitVedoAreaObject, ComelitVedoZoneObject, ) -from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.const import ( + BRIDGE, + CLIMATE, + COVER, + IRRIGATION, + LIGHT, + OTHER, + SCENARIO, + VEDO, +) from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiohttp import ClientSession @@ -111,6 +120,32 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): async def _async_update_system_data(self) -> T: """Class method for updating data.""" + async def _async_remove_stale_devices( + self, + previous_list: dict[int, Any], + current_list: dict[int, Any], + dev_type: str, + ) -> None: + """Remove stale devices.""" + device_registry = dr.async_get(self.hass) + + for i in previous_list: + if i not in current_list: + _LOGGER.debug( + "Detected change in %s devices: index %s removed", + dev_type, + i, + ) + identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}" + device = device_registry.async_get_device( + identifiers={(DOMAIN, identifier)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + class ComelitSerialBridge( ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]] @@ -137,7 +172,15 @@ class ComelitSerialBridge( self, ) -> dict[str, dict[int, ComelitSerialBridgeObject]]: """Specific method for updating data.""" - return await self.api.get_all_devices() + data = await self.api.get_all_devices() + + if self.data: + for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO): + await self._async_remove_stale_devices( + self.data[dev_type], data[dev_type], dev_type + ) + + return data class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): @@ -163,4 +206,14 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): self, ) -> AlarmDataObject: """Specific method for updating data.""" - return await self.api.get_all_areas_and_zones() + data = await self.api.get_all_areas_and_zones() + + if self.data: + for obj_type in ("alarm_areas", "alarm_zones"): + await self._async_remove_stale_devices( + self.data[obj_type], + data[obj_type], + "area" if obj_type == "alarm_areas" else "zone", + ) + + return data diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml index 4fbbd79d60d..3d512e71351 100644 --- a/homeassistant/components/comelit/quality_scale.yaml +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -72,9 +72,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: - status: todo - comment: missing implementation + stale-devices: done # Platinum async-dependency: done diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 0cbdaf56bbe..3a253e4b596 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -28,6 +28,18 @@ VEDO_PIN = 5678 FAKE_PIN = 0000 +LIGHT0 = ComelitSerialBridgeObject( + index=0, + name="Light0", + status=0, + human_status="off", + type="light", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, +) BRIDGE_DEVICE_QUERY = { CLIMATE: { 0: ComelitSerialBridgeObject( @@ -62,18 +74,7 @@ BRIDGE_DEVICE_QUERY = { ) }, LIGHT: { - 0: ComelitSerialBridgeObject( - index=0, - name="Light0", - status=0, - human_status="off", - type="light", - val=0, - protected=0, - zone="Bathroom", - power=0.0, - power_unit=WATT, - ) + 0: LIGHT0, }, OTHER: { 0: ComelitSerialBridgeObject( @@ -93,6 +94,13 @@ BRIDGE_DEVICE_QUERY = { SCENARIO: {}, } +ZONE0 = ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, +) VEDO_DEVICE_QUERY = AlarmDataObject( alarm_areas={ 0: ComelitVedoAreaObject( @@ -112,12 +120,6 @@ VEDO_DEVICE_QUERY = AlarmDataObject( ) }, alarm_zones={ - 0: ComelitVedoZoneObject( - index=0, - name="Zone0", - status_api="0x000", - status=0, - human_status=AlarmZoneState.REST, - ) + 0: ZONE0, }, ) diff --git a/tests/components/comelit/test_alarm_control_panel.py b/tests/components/comelit/test_alarm_control_panel.py index d3feac6ad3b..345c8c4df56 100644 --- a/tests/components/comelit/test_alarm_control_panel.py +++ b/tests/components/comelit/test_alarm_control_panel.py @@ -2,8 +2,8 @@ from unittest.mock import AsyncMock -from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.const import AlarmAreaState, AlarmZoneState +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject +from aiocomelit.const import AlarmAreaState from freezegun.api import FrozenDateTimeFactory import pytest @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration -from .const import VEDO_PIN +from .const import VEDO_PIN, ZONE0 from tests.common import MockConfigEntry, async_fire_time_changed @@ -74,13 +74,7 @@ async def test_entity_availability( ) }, alarm_zones={ - 0: ComelitVedoZoneObject( - index=0, - name="Zone0", - status_api="0x000", - status=0, - human_status=AlarmZoneState.REST, - ) + 0: ZONE0, }, ) diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py index 49e3164e875..d38e8bc7810 100644 --- a/tests/components/comelit/test_coordinator.py +++ b/tests/components/comelit/test_coordinator.py @@ -2,6 +2,23 @@ from unittest.mock import AsyncMock +from aiocomelit.api import ( + AlarmDataObject, + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) +from aiocomelit.const import ( + CLIMATE, + COVER, + IRRIGATION, + LIGHT, + OTHER, + SCENARIO, + WATT, + AlarmAreaState, + AlarmZoneState, +) from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,6 +28,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration +from .const import LIGHT0, ZONE0 from tests.common import MockConfigEntry, async_fire_time_changed @@ -47,3 +65,145 @@ async def test_coordinator_data_update_fails( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +async def test_coordinator_stale_device_serial_bridge( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test coordinator data update removes stale Serial Brdige devices.""" + + entity_id_0 = "light.light0" + entity_id_1 = "light.light1" + + mock_serial_bridge.get_all_devices.return_value = { + CLIMATE: {}, + COVER: {}, + LIGHT: { + 0: LIGHT0, + 1: ComelitSerialBridgeObject( + index=1, + name="Light1", + status=0, + human_status="off", + type="light", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + }, + OTHER: {}, + IRRIGATION: {}, + SCENARIO: {}, + } + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(entity_id_0)) + assert state.state == STATE_OFF + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_OFF + + mock_serial_bridge.get_all_devices.return_value = { + CLIMATE: {}, + COVER: {}, + LIGHT: {0: LIGHT0}, + OTHER: {}, + IRRIGATION: {}, + SCENARIO: {}, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_0)) + assert state.state == STATE_OFF + + # Light1 is removed + assert not hass.states.get(entity_id_1) + + +async def test_coordinator_stale_device_vedo( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test coordinator data update removes stale VEDO devices.""" + + entity_id_0 = "sensor.zone0" + entity_id_1 = "sensor.zone1" + + mock_vedo.get_all_areas_and_zones.return_value = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=0, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.DISARMED, + ) + }, + alarm_zones={ + 0: ZONE0, + 1: ComelitVedoZoneObject( + index=1, + name="Zone1", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ), + }, + ) + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(entity_id_0)) + assert state.state == AlarmZoneState.REST.value + assert (state := hass.states.get(entity_id_1)) + assert state.state == AlarmZoneState.REST.value + + mock_vedo.get_all_areas_and_zones.return_value = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=0, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.DISARMED, + ) + }, + alarm_zones={ + 0: ZONE0, + }, + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_0)) + assert state.state == AlarmZoneState.REST.value + + # Zone1 is removed + assert not hass.states.get(entity_id_1)