From e2438a4b149aa5ca343aa187ac80f122732ae8b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Oct 2026 20:05:11 +0100 Subject: [PATCH] Improve handling of entity_id change in rflink (#183856) --- homeassistant/components/rflink/entity.py | 44 ++++++++------ homeassistant/components/rflink/sensor.py | 9 +-- tests/components/rflink/test_sensor.py | 51 ++++++++++++++++ tests/components/rflink/test_switch.py | 71 +++++++++++++++++++++++ 4 files changed, 151 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index 4799cbc65f99..52e239772147 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -1,6 +1,7 @@ """Support for Rflink devices.""" import asyncio +from collections import defaultdict import logging from typing import override @@ -109,6 +110,23 @@ class RflinkDevice(Entity): self._attr_available = availability self.async_write_ha_state() + @callback + def _async_register_lookup( + self, lookup: defaultdict[str, list[str]], event_id: str + ) -> None: + """Route events for event_id to this entity until it is removed.""" + entity_id = self.entity_id + lookup[event_id].append(entity_id) + + @callback + def _async_unregister() -> None: + entity_ids = lookup[event_id] + entity_ids.remove(entity_id) + if not entity_ids: + del lookup[event_id] + + self.async_on_remove(_async_unregister) + @override async def async_added_to_hass(self) -> None: """Register update callback.""" @@ -124,34 +142,24 @@ class RflinkDevice(Entity): ].remove(tmp_entity) # Register id and aliases - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][self._device_id].append( - self.entity_id - ) + lookup = self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND] + group_lookup = self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND] + self._async_register_lookup(lookup, self._device_id) if self._group: - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][ - self._device_id - ].append(self.entity_id) + self._async_register_lookup(group_lookup, self._device_id) # aliases respond to both normal and group commands (allon/alloff) if self._aliases: for _id in self._aliases: - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) + self._async_register_lookup(lookup, _id) + self._async_register_lookup(group_lookup, _id) # group_aliases only respond to group commands (allon/alloff) if self._group_aliases: for _id in self._group_aliases: - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) + self._async_register_lookup(group_lookup, _id) # nogroup_aliases only respond to normal commands if self._nogroup_aliases: for _id in self._nogroup_aliases: - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) + self._async_register_lookup(lookup, _id) self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_AVAILABILITY, self._availability_callback diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 527748450186..3030ac804a07 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -389,14 +389,11 @@ class RflinkSensor(RflinkDevice, SensorEntity): ].remove(tmp_entity) # Register id and aliases - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_SENSOR][self._device_id].append( - self.entity_id - ) + lookup = self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_SENSOR] + self._async_register_lookup(lookup, self._device_id) if self._aliases: for _id in self._aliases: - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_SENSOR][_id].append( - self.entity_id - ) + self._async_register_lookup(lookup, _id) self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_AVAILABILITY, self._availability_callback diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 4a624347b14c..7159e5a1f09b 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .test_init import mock_rflink @@ -701,3 +702,53 @@ async def test_empty_discovery_info( assert not hass.states.get(f"{DOMAIN}.protocol_0_0") assert not hass.states.get("light.protocol_0_0") assert "device_id not known and automatic add disabled" in caplog.text + + +async def test_removed_sensor_unregisters_lookups( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + entity_registry: er.EntityRegistry, +) -> None: + """Test a removed sensor is dropped from the event lookups.""" + config = { + "rflink": { + "port": "/dev/ttyABC0", + DOMAIN: { + "automatic_add": False, + "devices": { + "test_02": { + "name": "test_02", + "sensor_type": "humidity", + "aliases": ["test_alias_02_0"], + }, + # Shares the alias of the removed sensor + "test_03": { + "name": "test_03", + "sensor_type": "humidity", + "aliases": ["test_alias_02_0"], + }, + }, + }, + }, + } + event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + lookup = hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_SENSOR] + assert lookup["test_02"] == ["sensor.test_02"] + + entity_registry.async_remove("sensor.test_02") + await hass.async_block_till_done() + assert hass.states.get("sensor.test_02") is None + + assert lookup == { + "test_03": ["sensor.test_03"], + "test_alias_02_0": ["sensor.test_03"], + } + + for event_id in ("test_02", "test_alias_02_0"): + event_callback( + {"id": event_id, "sensor": "humidity", "value": 65, "unit": PERCENTAGE} + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_02") is None + assert hass.states.get("sensor.test_03").state == "65" diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py index 33278444d281..e738879c24ca 100644 --- a/tests/components/rflink/test_switch.py +++ b/tests/components/rflink/test_switch.py @@ -5,8 +5,15 @@ control of Rflink switch devices. """ +from typing import Any + import pytest +from homeassistant.components.rflink import ( + DATA_ENTITY_GROUP_LOOKUP, + DATA_ENTITY_LOOKUP, + EVENT_KEY_COMMAND, +) from homeassistant.components.rflink.entity import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, @@ -16,6 +23,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import CoreState, HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er from .test_init import mock_rflink @@ -599,3 +607,66 @@ async def test_restore_state( assert state assert state.state == STATE_OFF assert state.attributes["assumed_state"] + + +@pytest.mark.parametrize( + "device_config", + [ + pytest.param({}, id="device_id"), + pytest.param({"group": False}, id="nogroup_device_id"), + pytest.param({"aliases": ["test_alias_0_0"]}, id="alias"), + pytest.param({"group_aliases": ["test_group_0_0"]}, id="group_alias"), + pytest.param({"nogroup_aliases": ["test_nogroup_0_0"]}, id="nogroup_alias"), + ], +) +async def test_removed_switch_unregisters_lookups( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + entity_registry: er.EntityRegistry, + device_config: dict[str, Any], +) -> None: + """Test a removed switch is dropped from the event lookups.""" + config = { + "rflink": { + "port": "/dev/ttyABC0", + DOMAIN: { + "devices": { + "protocol_0_0": {"name": "test", **device_config}, + # Shares every alias id the removed switch may use + "protocol_0_1": { + "name": "other", + "aliases": ["test_alias_0_0"], + "group_aliases": ["test_group_0_0"], + "nogroup_aliases": ["test_nogroup_0_0"], + }, + }, + }, + }, + } + event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + lookup = hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND] + group_lookup = hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND] + assert "switch.test" in lookup["protocol_0_0"] + + entity_registry.async_remove("switch.test") + await hass.async_block_till_done() + assert hass.states.get("switch.test") is None + + assert lookup == { + "protocol_0_1": ["switch.other"], + "test_alias_0_0": ["switch.other"], + "test_nogroup_0_0": ["switch.other"], + } + assert group_lookup == { + "protocol_0_1": ["switch.other"], + "test_alias_0_0": ["switch.other"], + "test_group_0_0": ["switch.other"], + } + + for event_id in ("protocol_0_0", "test_alias_0_0", "test_nogroup_0_0"): + event_callback({"id": event_id, "command": "on"}) + event_callback({"id": "test_group_0_0", "command": "allon"}) + await hass.async_block_till_done() + + assert hass.states.get("switch.test") is None + assert hass.states.get("switch.other").state == STATE_ON