"""Spook - Your homie.""" from __future__ import annotations from typing import TYPE_CHECKING, Any from homeassistant.components import automation from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.helpers import entity_registry as er from ....action_extraction import ( async_extract_entities_from_action_config, async_extract_entities_from_value, ) from ....entity_filtering import async_get_all_entity_ids, async_get_all_services from ....reference_extraction import without_disabled_steps from ....template_extraction import ( KNOWN_DOMAINS, async_extract_entities_from_config, async_filter_known_entity_ids_with_templates, ) from . import AbstractSpookAutomationReferencesRepair if TYPE_CHECKING: from homeassistant.core import HomeAssistant async def extract_template_entities_from_automation_entity( hass: HomeAssistant, entity: Any, known_services: set[str] | None = None, ) -> set[str]: """Extract entities from automation configuration using Template analysis. This function finds template strings in automation configuration and creates Template objects to extract entity references using Template.async_render_to_info(). This provides more comprehensive entity detection than regex-based parsing alone. """ # Get the automation configuration config = None if hasattr(entity, "raw_config") and entity.raw_config: config = entity.raw_config else: return set() return await async_extract_entities_from_config(hass, config, known_services) async def extract_entities_from_automation_config( hass: HomeAssistant, config: dict[str, Any], known_services: set[str] | None = None, ) -> set[str]: """Extract entity IDs from automation configuration. ``known_services`` is built once per inspection and handed down, because building it flattens every service Home Assistant has and the walk below passes a lot of template strings. See `action_extraction.async_extract_entities_from_action_config`. """ entities = set() if not isinstance(config, dict): return entities if known_services is None: known_services = async_get_all_services(hass) # Extract entities from trigger config for key in ("trigger", "triggers"): if key in config: entities.update( await extract_entities_from_trigger_config( hass, config[key], known_services ) ) # Extract entities from condition config for key in ("condition", "conditions"): if key in config: entities.update( await extract_entities_from_condition_config( hass, config[key], known_services ) ) # Extract entities from action config for key in ("action", "actions"): if key in config: entities.update( await async_extract_entities_from_action_config( hass, config[key], known_services=known_services ) ) return entities async def _entities_from_reference_fields( hass: HomeAssistant, config: dict[str, Any], known_services: set[str], ) -> set[str]: """Extract entities from the config keys that name a reference. ``zone`` is in here because a zone trigger and a zone condition both name one, and it is read exactly like the others. """ entities = set() for key in ("entity_id", "device_id", "zone"): if key in config: entities.update( await async_extract_entities_from_value( hass, config[key], known_services=known_services ) ) return entities # Where an event trigger keeps what it matches on, rather than what it needs. _EVENT_PAYLOAD_KEYS = frozenset({"event_data", "event_data_template"}) async def extract_entities_from_trigger_config( hass: HomeAssistant, config: dict[str, Any] | list, known_services: set[str] | None = None, ) -> set[str]: """Extract entity IDs from trigger configuration.""" entities = set() if not config: return entities if known_services is None: known_services = async_get_all_services(hass) if isinstance(config, list): for item in config: entities.update( await extract_entities_from_trigger_config(hass, item, known_services) ) return entities if not isinstance(config, dict): return entities entities.update(await _entities_from_reference_fields(hass, config, known_services)) payload_keys = _payload_keys_to_leave_alone(config) # Extract from nested configs for key, value in config.items(): if key in payload_keys: continue if isinstance(value, (dict, list)): entities.update( await extract_entities_from_trigger_config(hass, value, known_services) ) return entities def _payload_keys_to_leave_alone(config: dict[str, Any]) -> frozenset[str]: """Return the event payload keys that hold data rather than references. An `entity_id` inside `event_data` is a reference when the event comes from an integration that means it that way, `timer.finished` being the usual one. On somebody's own event it is whatever the sender put there, and reporting that as a missing entity is a repair about an automation that works perfectly well. Told apart by the event type: one named after a domain comes from that integration, anything else is somebody's own. """ event_types = config.get("event_type") if event_types is None: return frozenset() if isinstance(event_types, str): event_types = [event_types] for event_type in event_types: if not isinstance(event_type, str): continue domain, dot, _ = event_type.partition(".") if dot and domain in KNOWN_DOMAINS: return frozenset() return _EVENT_PAYLOAD_KEYS def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> set[str]: """Extract event types from trigger configuration.""" event_types = set() if not config: return event_types if isinstance(config, list): for item in config: event_types.update(extract_event_types_from_trigger_config(item)) return event_types if not isinstance(config, dict): return event_types value = config.get("event_type") if isinstance(value, str): event_types.add(value) elif isinstance(value, list): event_types.update(item for item in value if isinstance(item, str)) for value in config.values(): if isinstance(value, (dict, list)): event_types.update(extract_event_types_from_trigger_config(value)) return event_types async def extract_entities_from_condition_config( hass: HomeAssistant, config: dict[str, Any] | list, known_services: set[str] | None = None, ) -> set[str]: """Extract entity IDs from condition configuration.""" entities = set() if not config: return entities if known_services is None: known_services = async_get_all_services(hass) if isinstance(config, list): for item in config: entities.update( await extract_entities_from_condition_config(hass, item, known_services) ) return entities if not isinstance(config, dict): return entities entities.update(await _entities_from_reference_fields(hass, config, known_services)) # Extract from nested configs for value in config.values(): if isinstance(value, (dict, list)): entities.update( await extract_entities_from_condition_config( hass, value, known_services ) ) return entities class SpookRepair(AbstractSpookAutomationReferencesRepair): """Spook repair tries to find unknown referenced entity in automations.""" domain = automation.DOMAIN repair = "automation_unknown_entity_references" inspect_events = { EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED, } inspect_config_entry_changed = True inspect_on_reload = True inspect_on_entity_added_or_removed = True unavailable_entity_class = automation.UnavailableAutomationEntity entity_label = "automation" reference_label = "entities" references_are_entities = True edit_url_pattern = "/config/automation/edit/{unique_id}" _known_entity_ids: set[str] _known_services: set[str] async def _async_setup_inspection(self) -> None: """Cache what every automation in this cycle needs looked up. The service set is in here for the same reason as the entity ids: building it flattens every service Home Assistant has, and it is the same answer for every automation in one pass. """ self._known_entity_ids = async_get_all_entity_ids( self.hass, include_all_none=True ) self._known_services = async_get_all_services(self.hass) async def _async_named_in(self, config: dict[str, Any]) -> set[str]: """Return the entities a configuration names, the way this repair reads it. The same reading the report is built from, structure and templates alike, so comparing it with and without the disabled steps leaves out exactly what only those name. """ named = await extract_entities_from_automation_config( self.hass, config, self._known_services ) named |= await async_extract_entities_from_config( self.hass, config, self._known_services ) return named async def _async_compute_unknown_references(self, entity: Any) -> set[str]: """Return unknown entity IDs referenced by ``entity`` (incl. templates).""" all_entities = set(entity.referenced_entities) # Also extract entities directly from raw configuration if available if hasattr(entity, "raw_config") and entity.raw_config: all_entities.update( await extract_entities_from_automation_config( self.hass, entity.raw_config, self._known_services ) ) for key in ("trigger", "triggers"): all_entities.difference_update( extract_event_types_from_trigger_config(entity.raw_config.get(key)) ) # Extract entities from Template objects within the automation entity all_entities.update( await extract_template_entities_from_automation_entity( self.hass, entity, self._known_services ) ) # Home Assistant's own list includes disabled steps, triggers and # conditions too. Something parked that way does nothing, so what only # it names is left out of the report: whatever this repair finds in # the configuration, and no longer finds once those are pruned. if isinstance(raw_config := getattr(entity, "raw_config", None), dict): named = await self._async_named_in(raw_config) still_named = await self._async_named_in(without_disabled_steps(raw_config)) all_entities -= named - still_named return await async_filter_known_entity_ids_with_templates( self.hass, entity_ids=all_entities, known_entity_ids=self._known_entity_ids, known_services=self._known_services, )