291 files
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
@@ -9,7 +10,8 @@ from homeassistant.helpers import area_registry as ar
|
||||
|
||||
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
|
||||
from ....reference_extraction import (
|
||||
extract_targets_from_config,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -42,16 +44,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown area IDs referenced by ``entity``."""
|
||||
area_ids = set(entity.referenced_areas)
|
||||
area_ids = core_references(entity, "areas")
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
area_ids.update(extract_targets_from_config(raw_config).area_ids)
|
||||
# Templates that look up an area that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="areas")
|
||||
area_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
area_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).area_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
area_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_area_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -9,7 +9,9 @@ from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
|
||||
from ....reference_extraction import (
|
||||
core_references,
|
||||
extract_targets_from_config,
|
||||
names_given_to_templates,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....template_extraction import extract_device_ids_from_config
|
||||
@@ -74,7 +76,7 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown device IDs referenced by ``entity``."""
|
||||
device_ids = set(entity.referenced_devices)
|
||||
device_ids = core_references(entity, "devices")
|
||||
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
@@ -92,14 +94,18 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
entity.raw_config.get("triggers")
|
||||
)
|
||||
)
|
||||
# Devices referenced via device_entities() in templates.
|
||||
device_ids.update(extract_device_ids_from_config(entity.raw_config))
|
||||
# Devices referenced through the device functions in templates.
|
||||
# The variables and fields it gives its templates hide a call.
|
||||
shadowed = names_given_to_templates(entity.raw_config)
|
||||
device_ids.update(
|
||||
extract_device_ids_from_config(entity.raw_config, shadowed)
|
||||
)
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
device_ids -= only_in_disabled_steps(
|
||||
entity.raw_config,
|
||||
lambda found: (
|
||||
extract_targets_from_config(found).device_ids
|
||||
| extract_device_ids_from_config(found)
|
||||
| extract_device_ids_from_config(found, shadowed)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+99
-68
@@ -11,13 +11,19 @@ from homeassistant.helpers import entity_registry as er
|
||||
from ....action_extraction import (
|
||||
async_extract_entities_from_action_config,
|
||||
async_extract_entities_from_value,
|
||||
async_unknown_entities_named,
|
||||
)
|
||||
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
|
||||
from ....reference_extraction import without_disabled_steps
|
||||
from ....reference_extraction import (
|
||||
VALUE_KEYS,
|
||||
core_references,
|
||||
event_payload_keys_to_leave_alone,
|
||||
names_given_to_templates,
|
||||
threshold_entities,
|
||||
without_never_rendered,
|
||||
)
|
||||
from ....template_extraction import (
|
||||
KNOWN_DOMAINS,
|
||||
async_extract_entities_from_config,
|
||||
async_filter_known_entity_ids_with_templates,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
|
||||
@@ -43,13 +49,21 @@ async def extract_template_entities_from_automation_entity(
|
||||
else:
|
||||
return set()
|
||||
|
||||
return await async_extract_entities_from_config(hass, config, known_services)
|
||||
# A description, a name or a field example is shown, never rendered: an
|
||||
# entity in an example template there is not one the automation uses.
|
||||
return await async_extract_entities_from_config(
|
||||
hass,
|
||||
without_never_rendered(config),
|
||||
known_services,
|
||||
names_given_to_templates(config),
|
||||
)
|
||||
|
||||
|
||||
async def extract_entities_from_automation_config(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str] | None = None,
|
||||
shadowed: frozenset[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from automation configuration.
|
||||
|
||||
@@ -57,6 +71,10 @@ async def extract_entities_from_automation_config(
|
||||
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`.
|
||||
|
||||
The names the automation gives its templates, like a variable called
|
||||
`states`, are taken from all of it here, before walking its parts,
|
||||
unless ``shadowed`` already holds them.
|
||||
"""
|
||||
entities = set()
|
||||
|
||||
@@ -65,13 +83,15 @@ async def extract_entities_from_automation_config(
|
||||
|
||||
if known_services is None:
|
||||
known_services = async_get_all_services(hass)
|
||||
if shadowed is None:
|
||||
shadowed = names_given_to_templates(config)
|
||||
|
||||
# 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
|
||||
hass, config[key], known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
@@ -80,7 +100,7 @@ async def extract_entities_from_automation_config(
|
||||
if key in config:
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(
|
||||
hass, config[key], known_services
|
||||
hass, config[key], known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
@@ -89,7 +109,10 @@ async def extract_entities_from_automation_config(
|
||||
if key in config:
|
||||
entities.update(
|
||||
await async_extract_entities_from_action_config(
|
||||
hass, config[key], known_services=known_services
|
||||
hass,
|
||||
config[key],
|
||||
known_services=known_services,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -100,31 +123,36 @@ async def _entities_from_reference_fields(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str],
|
||||
*,
|
||||
in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> 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.
|
||||
one, and it is read exactly like the others. So is the entity a trigger
|
||||
or condition takes as its threshold, unless the dictionary sits below a
|
||||
key that holds values: event data that only looks like one is still event
|
||||
data.
|
||||
"""
|
||||
entities = set()
|
||||
entities = set() if in_values else threshold_entities(config)
|
||||
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
|
||||
hass, config[key], known_services=known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
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,
|
||||
*,
|
||||
_in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from trigger configuration."""
|
||||
entities = set()
|
||||
@@ -138,16 +166,26 @@ async def extract_entities_from_trigger_config(
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, item, known_services)
|
||||
await extract_entities_from_trigger_config(
|
||||
hass,
|
||||
item,
|
||||
known_services,
|
||||
_in_values=_in_values,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
entities.update(
|
||||
await _entities_from_reference_fields(
|
||||
hass, config, known_services, in_values=_in_values, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
payload_keys = _payload_keys_to_leave_alone(config)
|
||||
payload_keys = event_payload_keys_to_leave_alone(config)
|
||||
|
||||
# Extract from nested configs
|
||||
for key, value in config.items():
|
||||
@@ -156,42 +194,18 @@ async def extract_entities_from_trigger_config(
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, value, known_services)
|
||||
await extract_entities_from_trigger_config(
|
||||
hass,
|
||||
value,
|
||||
known_services,
|
||||
_in_values=_in_values or key in VALUE_KEYS,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -224,6 +238,9 @@ async def extract_entities_from_condition_config(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any] | list,
|
||||
known_services: set[str] | None = None,
|
||||
*,
|
||||
_in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from condition configuration."""
|
||||
entities = set()
|
||||
@@ -237,21 +254,35 @@ async def extract_entities_from_condition_config(
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(hass, item, known_services)
|
||||
await extract_entities_from_condition_config(
|
||||
hass,
|
||||
item,
|
||||
known_services,
|
||||
_in_values=_in_values,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
entities.update(
|
||||
await _entities_from_reference_fields(
|
||||
hass, config, known_services, in_values=_in_values, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
# Extract from nested configs
|
||||
for value in config.values():
|
||||
for key, value in config.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(
|
||||
hass, value, known_services
|
||||
hass,
|
||||
value,
|
||||
known_services,
|
||||
_in_values=_in_values or key in VALUE_KEYS,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -292,24 +323,30 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
)
|
||||
self._known_services = async_get_all_services(self.hass)
|
||||
|
||||
async def _async_named_in(self, config: dict[str, Any]) -> set[str]:
|
||||
async def _async_named_in(
|
||||
self, config: dict[str, Any], shadowed: frozenset[str]
|
||||
) -> 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.
|
||||
exactly what only those name. ``shadowed`` comes from the whole
|
||||
configuration, as `config` can be one without its disabled steps.
|
||||
"""
|
||||
named = await extract_entities_from_automation_config(
|
||||
self.hass, config, self._known_services
|
||||
self.hass, config, self._known_services, shadowed
|
||||
)
|
||||
named |= await async_extract_entities_from_config(
|
||||
self.hass, config, self._known_services
|
||||
self.hass,
|
||||
without_never_rendered(config),
|
||||
self._known_services,
|
||||
shadowed,
|
||||
)
|
||||
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)
|
||||
all_entities = core_references(entity, "entities")
|
||||
|
||||
# Also extract entities directly from raw configuration if available
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
@@ -330,18 +367,12 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
)
|
||||
)
|
||||
|
||||
# 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(
|
||||
raw_config = getattr(entity, "raw_config", None)
|
||||
return await async_unknown_entities_named(
|
||||
self.hass,
|
||||
entity_ids=all_entities,
|
||||
all_entities,
|
||||
raw_config,
|
||||
named_in=self._async_named_in,
|
||||
known_entity_ids=self._known_entity_ids,
|
||||
known_services=self._known_services,
|
||||
)
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.helpers import floor_registry as fr
|
||||
from homeassistant.helpers import area_registry as ar, floor_registry as fr
|
||||
|
||||
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
|
||||
from ....reference_extraction import (
|
||||
extract_targets_from_config,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -23,7 +25,10 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
domain = automation.DOMAIN
|
||||
repair = "automation_unknown_floor_references"
|
||||
# Templates look floors up through areas as well, so a renamed area
|
||||
# can change what they find.
|
||||
inspect_events = {
|
||||
ar.EVENT_AREA_REGISTRY_UPDATED,
|
||||
fr.EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
inspect_on_reload = True
|
||||
@@ -41,16 +46,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown floor IDs referenced by ``entity``."""
|
||||
floor_ids = set(entity.referenced_floors)
|
||||
floor_ids = core_references(entity, "floors")
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
floor_ids.update(extract_targets_from_config(raw_config).floor_ids)
|
||||
# Templates that look up a floor that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="floors")
|
||||
floor_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
floor_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).floor_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
floor_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_floor_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
@@ -9,7 +10,8 @@ from homeassistant.helpers import label_registry as lr
|
||||
|
||||
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
|
||||
from ....reference_extraction import (
|
||||
extract_targets_from_config,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -41,16 +43,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown label IDs referenced by ``entity``."""
|
||||
label_ids = set(entity.referenced_labels)
|
||||
label_ids = core_references(entity, "labels")
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
label_ids.update(extract_targets_from_config(raw_config).label_ids)
|
||||
# Templates that look up a label that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="labels")
|
||||
label_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
label_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).label_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
label_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_label_ids(
|
||||
self.hass,
|
||||
|
||||
Reference in New Issue
Block a user