From ac63da992c2bded62cc81b5e28f6cfed360dfbd3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Aug 2026 08:00:34 +0200 Subject: [PATCH] Log warning when event triggers filter on composite device (#179490) --- .../homeassistant/triggers/event.py | 69 ++++++++++- .../homeassistant/triggers/test_event.py | 112 +++++++++++++++++- 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 7f45f19862b5..010f20a0c5a4 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,16 +1,29 @@ """Offer event listening automation rules.""" from collections.abc import ItemsView, Mapping +import logging from typing import Any import voluptuous as vol -from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM, EVENT_STATE_REPORTED +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_EVENT_DATA, + CONF_PLATFORM, + EVENT_STATE_REPORTED, +) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + template, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType +from homeassistant.util import yaml as yaml_util + +_LOGGER = logging.getLogger(__name__) CONF_EVENT_TYPE = "event_type" CONF_EVENT_CONTEXT = "context" @@ -39,6 +52,58 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( ) +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config. + + Warn if the trigger filters event_data.device_id on a pre-migration composite device + id - a device that was split into one device per config entry. + A templated device id is a Template (not a plain string) and is left alone. + """ + validated_config: ConfigType = TRIGGER_SCHEMA(config) + if ( + CONF_EVENT_DATA in validated_config + and isinstance( + device_id := validated_config[CONF_EVENT_DATA].get(CONF_DEVICE_ID), str + ) + and ( + split_devices := dr.async_get( + hass + ).async_get_devices_for_composite_device_id(device_id) + ) + ): + _log_composite_device_id_warning(hass, config, device_id, split_devices) + return validated_config + + +@callback +def _log_composite_device_id_warning( + hass: HomeAssistant, + config: ConfigType, + device_id: str, + split_devices: list[dr.DeviceEntry], +) -> None: + """Warn that an event trigger filters on a split (pre-migration) device id.""" + + device_summaries: list[str] = [] + for device in split_devices: + entry = hass.config_entries.async_get_entry(device.config_entry_id) + domain = entry.domain if entry else "unknown" + name = device.name_by_user or device.name or device.id + device_summaries.append(f"{name} ({device.id}) from the {domain} integration") + + _LOGGER.warning( + "Event trigger filters on device '%s', which was split into one device per " + "integration and no longer exists, so the trigger can no longer fire. Update the " + "automation, script or template entity to filter on one of these devices instead: " + "%s.\nThe affected trigger is configured as:\n%s", + device_id, + ", ".join(device_summaries), + yaml_util.dump(config), + ) + + def _schema_value(value: Any) -> Any: if isinstance(value, list): return vol.In(value) diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 5536db1eb5e9..316d2eba42d8 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -1,13 +1,17 @@ """The tests for the Event automation.""" +import logging + +import attr import pytest from homeassistant.components import automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr, script, trigger from homeassistant.setup import async_setup_component -from tests.common import mock_component +from tests.common import MockConfigEntry, mock_component @pytest.fixture @@ -629,3 +633,109 @@ async def test_templated_state_reported_event( "Got error 'Can't listen to state_reported in event trigger' " "when setting up triggers for automation 0" in caplog.text ) + + +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] + + +_EVENT_TRIGGER = { + "platform": "event", + "event_type": "my_event", + "event_data": {"device_id": COMPOSITE_ID}, +} + + +def _expected_composite_warning( + device_1: dr.DeviceEntry, device_2: dr.DeviceEntry +) -> str: + """Return the exact warning the event validator logs for a composite device id.""" + return ( + f"Event trigger filters on device '{COMPOSITE_ID}', which was split into one " + "device per integration and no longer exists, so the trigger can no longer fire. " + "Update the automation, script or template entity to filter on one of these " + "devices instead: " + f"Split device 1 ({device_1.id}) from the itg1 integration, " + f"Split device 2 ({device_2.id}) from the itg2 integration.\n" + "The affected trigger is configured as:\n" + "platform: event\n" + "event_type: my_event\n" + "event_data:\n" + f" device_id: {COMPOSITE_ID}\n" + ) + + +async def test_composite_device_id_logs_warning( + hass: HomeAssistant, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a composite event_data.device_id filter logs the full warning.""" + with caplog.at_level(logging.WARNING): + await trigger.async_validate_trigger_config(hass, [_EVENT_TRIGGER]) + assert caplog.messages == [_expected_composite_warning(*split_devices)] + + +async def test_live_device_id_no_warning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a live event_data.device_id filter does not warn.""" + entry = MockConfigEntry(domain="itg") + entry.add_to_hass(hass) + live_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("itg", "1")} + ) + with caplog.at_level(logging.WARNING): + await trigger.async_validate_trigger_config( + hass, + [ + { + "platform": "event", + "event_type": "my_event", + "event_data": {"device_id": live_device.id}, + } + ], + ) + assert caplog.messages == [] + + +async def test_wait_for_trigger_composite_device_id_logs_warning( + hass: HomeAssistant, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a composite device_id in a wait_for_trigger event trigger warns too.""" + with caplog.at_level(logging.WARNING): + await script.async_validate_actions_config( + hass, [{"wait_for_trigger": [_EVENT_TRIGGER]}] + ) + assert caplog.messages == [_expected_composite_warning(*split_devices)]