diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py index 6827448f4dbd..ff4bc2ac2daf 100644 --- a/homeassistant/components/tag/trigger.py +++ b/homeassistant/components/tag/trigger.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -31,6 +31,18 @@ async def async_attach_trigger( device_ids: set[str] | None = ( set(config[DEVICE_ID]) if DEVICE_ID in config else None ) + if device_ids is not None: + device_registry = dr.async_get(hass) + # A pre-migration composite device id no longer refers to a registered device; + # a tag scanned event carries the id of one of the devices it was split into. + # Expand it to those split device ids so the trigger keeps matching. + for device_id in list(device_ids): + split_devices = device_registry.async_get_devices_for_composite_device_id( + device_id + ) + if split_devices: + device_ids.discard(device_id) + device_ids.update(split_device.id for split_device in split_devices) job = HassJob(action) diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py index 91f1bfba95b1..1e26427d0f8b 100644 --- a/tests/components/tag/test_trigger.py +++ b/tests/components/tag/test_trigger.py @@ -2,6 +2,7 @@ from typing import Any +import attr import pytest from homeassistant.components import automation @@ -9,8 +10,11 @@ from homeassistant.components.tag import async_scan_tag from homeassistant.components.tag.const import DEVICE_ID, DOMAIN, TAG_ID from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + @pytest.fixture def tag_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): @@ -151,3 +155,87 @@ async def test_multiple_tags_and_devices_trigger( assert service_calls[1].data["message"] == "service called" assert service_calls[2].data["message"] == "service called" assert service_calls[3].data["message"] == "service called" + + +COMPOSITE_ID = "composite00000000000000000000ab" + + +@pytest.fixture +def split_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> tuple[dr.DeviceEntry, dr.DeviceEntry]: + """Create two devices which are splits of a pre-migration composite device.""" + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + identifiers={("itg1", "1")}, + name="Split device 1", + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("itg2", "1")}, + name="Split device 2", + ) + device_registry._devices[device_1.id] = attr.evolve( + device_1, composite_device_id=COMPOSITE_ID + ) + device_registry._devices[device_2.id] = attr.evolve( + device_2, composite_device_id=COMPOSITE_ID + ) + return device_registry._devices[device_1.id], device_registry._devices[device_2.id] + + +async def test_composite_device_trigger( + hass: HomeAssistant, + tag_setup, + service_calls: list[ServiceCall], + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], +) -> None: + """Test a tag trigger configured with a pre-migration composite device id. + + The composite device id no longer refers to a registered device; it is expanded to + the ids of the devices it was split into, so a tag scanned by either split device + fires the trigger. + """ + device_1, device_2 = split_devices + assert await tag_setup() + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": DOMAIN, + TAG_ID: "abc123", + DEVICE_ID: COMPOSITE_ID, + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + await hass.async_block_till_done() + + # A scan by the composite id or an unrelated device should not fire + await async_scan_tag(hass, tag_id="abc123", device_id=COMPOSITE_ID) + await async_scan_tag(hass, tag_id="abc123", device_id="other_device") + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # A scan by either split device should fire + await async_scan_tag(hass, tag_id="abc123", device_id=device_1.id) + await hass.async_block_till_done() + await async_scan_tag(hass, tag_id="abc123", device_id=device_2.id) + await hass.async_block_till_done() + + assert len(service_calls) == 2 + assert service_calls[0].data["message"] == "service called" + assert service_calls[1].data["message"] == "service called"