291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
+467 -20
View File
@@ -6,19 +6,21 @@ from abc import ABC, abstractmethod
import asyncio
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import timedelta
import hashlib
import importlib
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, final
from homeassistant.components import blueprint, lovelace as lovelace_const
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.energy.data import (
async_get_manager as async_get_energy_manager,
)
from homeassistant.components.homeassistant import SERVICE_HOMEASSISTANT_RESTART
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.config_entries import (
SIGNAL_CONFIG_ENTRY_CHANGED,
ConfigEntry,
@@ -39,13 +41,16 @@ from homeassistant.helpers import (
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_component import DATA_INSTANCES
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.recorder import get_instance
from homeassistant.util.async_ import create_eager_task
from .attribute_checking import async_unknown_attributes
from .attribute_value_checking import as_yaml, async_unknown_attribute_values
from .const import DOMAIN, LOGGER
from .dashboard_resources import is_yaml_managed, redundant_item_ids
from .dismissals import async_get_dismissals
from .ectoplasms.spook.triggers.state_changed import async_watched_entity_ids
from .energy_preferences import (
async_unknown_energy_entities,
energy_preferences_without,
@@ -54,6 +59,7 @@ from .entity_filtering import (
async_filter_known_entity_ids,
async_get_all_entity_ids,
async_name_helper_in_the_registry,
is_not_an_entity_id,
)
from .entity_suggestions import (
async_describe_warmed_unknown_entities,
@@ -65,14 +71,22 @@ from .helper_sources import (
async_unknown_helper_sources,
async_unknown_min_max_members,
)
from .reference_extraction import async_collect_mentioned_strings
from .reference_extraction import (
AttributeValue,
NamedReferences,
async_collect_mentioned_strings,
async_referencing,
extract_attribute_references_from_config,
extract_state_references_from_config,
)
from .registry_usage import async_area_in_use, async_floor_in_use, async_label_in_use
from .repair_documentation import repair_documentation_url
from .state_checking import async_unknown_states
from .statistics_sources import async_settled_orphaned_statistic_ids
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sized
from datetime import datetime, timedelta
from datetime import datetime
from types import ModuleType
from homeassistant.data_entry_flow import FlowResult
@@ -100,6 +114,12 @@ _FINGERPRINT_LENGTH = 8
# not prune it below this.
_MIN_MAX_MINIMUM_MEMBERS = 2
# How long the first round of inspections after a start is spread over. All
# of them looking at once, on an instance that has only just started and
# often still has integrations adding devices, is a solid block of work at
# the worst moment for it. #1898.
FIRST_INSPECTIONS_SPREAD_OVER = timedelta(minutes=2)
def _plural(items: Sized) -> str:
"""Return the plural suffix for a sized collection."""
@@ -111,6 +131,26 @@ def _bulleted(items: Iterable[str]) -> str:
return "\n".join(f"- `{item}`" for item in items)
def format_config_entry_references(
hass: HomeAssistant, entry_ids: Iterable[str]
) -> str:
"""Return the markdown list of config entries an action cannot use.
One that exists was reported for being of another integration than the
action asks for, so it says whose it is. That is usually the clue: the ID
was copied over from an action of that other integration.
"""
lines = []
for entry_id in entry_ids:
if (entry := hass.config_entries.async_get_entry(entry_id)) is None:
lines.append(f"- `{entry_id}`")
else:
lines.append(
f"- `{entry_id}` (belongs to {entry.title}, of `{entry.domain}`)"
)
return "\n".join(lines)
def _fingerprint(references: Iterable[str]) -> str:
r"""Return a short digest of what a finding is about.
@@ -318,6 +358,13 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
#: time alone (e.g. something going stale), not in response to an event.
inspect_interval: timedelta | None = None
#: Wait this long after activating before looking at all, and ignore
#: everything that would trigger a look until then. For repairs that ask
#: the recorder, which has its hands full right after a start. After a
#: start the repair manager hands every repair without one its own turn
#: instead, so they do not all look at the same moment.
first_inspection_delay: timedelta | None = None
#: Re-run the inspection when an entity appears or goes. Needed by repairs
#: about entity references, as plenty of entities never touch the entity
#: registry: one set by a script or a template without a unique ID arrives
@@ -334,11 +381,12 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
super().__init__(hass)
self._event_subs = set()
self.possible_issue_ids = set()
self._waiting_for_first_inspection = False
async def _async_inspect_with_cleanup(self) -> None:
"""Run an inspection and clean up issues that are no longer valid."""
# Don't inspect if we are stopping
if self.hass.is_stopping:
# Don't inspect if we are stopping, or should not look yet
if self.hass.is_stopping or self._waiting_for_first_inspection:
return
if not self.automatically_clean_up_issues:
@@ -396,8 +444,22 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
function=self._async_inspect_with_cleanup,
)
# Spook says: Bounce!
await self.inspect_debouncer.async_call()
if self.first_inspection_delay is None:
# Spook says: Bounce!
await self.inspect_debouncer.async_call()
else:
self._waiting_for_first_inspection = True
@callback
def _async_first_inspection(_: datetime) -> None:
self._waiting_for_first_inspection = False
self.inspect_debouncer.async_schedule_call()
self._event_subs.add(
async_call_later(
self.hass, self.first_inspection_delay, _async_first_inspection
)
)
async def _async_call_inspect_debouncer(_: Event) -> None:
# Trigger an inspection when an event is received from the event bus.
@@ -538,10 +600,26 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
def _format_references(self, references: list[str]) -> str:
"""Return the bulleted reference list for the issue message."""
if self.references_are_entities:
if not self.references_are_entities:
return "\n".join(f"- `{reference}`" for reference in references)
# Something that is no entity ID at all comes after the entities that
# do not exist, said to be what it is. No guess at what was meant:
# `cover.blind.position` may have meant the cover, its position or
# something else entirely, and a wrong suggestion is worse than none.
lines = [
# Warmed for the whole round in `async_inspect` already.
return async_describe_warmed_unknown_entities(self.hass, references)
return "\n".join(f"- `{reference}`" for reference in references)
async_describe_warmed_unknown_entities(
self.hass,
[entity for entity in references if not is_not_an_entity_id(entity)],
),
*(
f"- `{value}` (not an entity ID)"
for value in references
if is_not_an_entity_id(value)
),
]
return "\n".join(line for line in lines if line)
async def _async_setup_inspection(self) -> None:
"""Prepare per-inspection state (called once per inspection cycle).
@@ -633,7 +711,12 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
if self.references_are_entities and findings:
await async_warm_rename_suggestions(
self.hass,
{reference for _, references in findings for reference in references},
{
reference
for _, references in findings
for reference in references
if not is_not_an_entity_id(reference)
},
)
for entity, sorted_unknown in findings:
@@ -656,6 +739,310 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
)
def _finding_reference(entity_id: str, name: str | AttributeValue) -> str:
"""Return how an unknown attribute or state is written down in an issue.
A value of an attribute keeps its type: `2` and `"2"` are not the same
thing to an attribute. One compared as text, like Spook's own trigger
does, is kept apart too: it can have another best guess than the same
value compared the way core does, and both belong in the issue.
"""
if isinstance(name, AttributeValue):
reference = f"{entity_id}[{name.attribute}]:{json.dumps(name.value)}"
return f"{reference}:as-text" if name.as_text else reference
return f"{entity_id}:{name}"
@dataclass(frozen=True, slots=True)
class _UnknownName:
"""Something an entity never has, named in an automation or script."""
entity_id: str
name: str | AttributeValue
suggestion: str | None
class AbstractSpookUnknownEntityNamesRepair(
AbstractSpookEntityComponentUnknownReferencesRepair, ABC
):
"""Base for repairs about what an entity never has: an attribute, a state.
A trigger or condition waiting on one loads fine and never fires, and
says nothing about it. What an entity has is only known at runtime, and
the last place to look is the recorder, so this does not look right at
the start, when the recorder has its hands full. After that it looks
whenever one of its own entities is added or removed, which is how a
reload that changed something ends, on the usual events, and once a day
for what changes with time alone: history being written.
The whole round's references are worked out up front, so the recorder is
asked once per round rather than once per automation.
"""
first_inspection_delay = timedelta(minutes=10)
inspect_interval = timedelta(days=1)
#: How one finding reads in the issue, with ``{name}`` and ``{entity_id}``.
finding_line: str
_unknown_by_owner: dict[str, set[str]]
_unknown_by_reference: dict[str, _UnknownName]
_inspected_configs: dict[str, Any]
_named: dict[str, tuple[Any, NamedReferences]]
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the repair."""
super().__init__(hass)
self._named = {}
self._inspected_configs = {}
self._unknown_by_owner = {}
self._unknown_by_reference = {}
@abstractmethod
def _references_in(self, config: dict[str, Any]) -> NamedReferences:
"""Return what a configuration names, and of which entities."""
@abstractmethod
async def _async_unknown(
self, pairs: set[tuple[str, str | AttributeValue]]
) -> set[_UnknownName]:
"""Return the (entity ID, name) pairs the entity never has."""
async def async_activate(self) -> None:
"""Look again once one of its own entities was added or removed.
A reload removes what changed and adds it back once it is done, so
that is when there is something new to look at. The reload service
being called is too early: nothing has been reloaded yet.
"""
await super().async_activate()
prefix = f"{self.domain}."
@callback
def _own_entity_added_or_removed(event_data: Mapping[str, Any]) -> bool:
"""Return whether one of this domain's entities appeared or went."""
return event_data["entity_id"].startswith(prefix) and (
event_data.get("old_state") is None
or event_data.get("new_state") is None
)
@callback
def _look_again(_: Event) -> None:
self.inspect_debouncer.async_schedule_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
_look_again,
event_filter=_own_entity_added_or_removed,
)
)
def _named_in(
self, entity: Any, config: dict[str, Any]
) -> set[tuple[str, str | AttributeValue]]:
"""Return the (entity ID, name) pairs a configuration names.
Read once per configuration, remembered against the configuration
itself: a reload hands over a new one.
"""
named = self._named.get(entity.entity_id)
if named is None or named[0] is not config:
named = (config, self._references_in(config))
self._named[entity.entity_id] = named
references = named[1]
# Resolved every round rather than remembered: what a registry ID
# stands for moves when the entity is renamed, and what Spook's own
# trigger watches moves with the registry. That one is asked of the
# trigger itself, so it is exactly what the trigger would watch.
pairs = {
(entity_id, name)
for reference, name in references.pairs
if (
entity_id := er.async_resolve_entity_id(self.entity_registry, reference)
)
}
for trigger_config, name in references.followed:
pairs.update(
(entity_id, name)
for entity_id in async_watched_entity_ids(self.hass, trigger_config)
)
return pairs
def _is_inspected(self, entity: Any) -> bool:
"""Return whether the round looks at this entity, the way the base does."""
unavailable_class = self.unavailable_entity_class
# pylint: disable-next=isinstance-second-argument-not-valid-type
if unavailable_class is not None and isinstance(entity, unavailable_class):
return False
return self._should_inspect_entity(entity)
async def _async_setup_inspection(self) -> None:
"""Work out everything unknown of the round, in one go."""
entities = list(self.hass.data[DATA_INSTANCES][self.domain].entities)
configs: dict[str, Any] = {}
named: dict[str, set[tuple[str, str | AttributeValue]]] = {}
for entity in entities:
# Reading a configuration is CPU-bound, and one can be big: the
# event loop gets a turn after each.
await asyncio.sleep(0)
config = getattr(entity, "raw_config", None)
if not isinstance(config, dict) or not self._is_inspected(entity):
continue
configs[entity.entity_id] = config
named[entity.entity_id] = self._named_in(entity, config)
# Whatever is gone or not looked at this round is forgotten.
self._named = {
entity_id: remembered
for entity_id, remembered in self._named.items()
if entity_id in named
}
unknown = await self._async_unknown(
{pair for pairs in named.values() for pair in pairs}
)
by_pair = {(finding.entity_id, finding.name): finding for finding in unknown}
self._inspected_configs = configs
self._unknown_by_reference = {
_finding_reference(*pair): finding for pair, finding in by_pair.items()
}
self._unknown_by_owner = {
owner: {_finding_reference(*pair) for pair in pairs & by_pair.keys()}
for owner, pairs in named.items()
}
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return what this entity names that its entities never have.
Only for the very configuration it was worked out from, on the
entity that is there now. One that was reloaded while the recorder
was being asked, or while this round went by, may say something else
entirely, and the next round looks at that.
"""
current = self.hass.data[DATA_INSTANCES][self.domain].get_entity(
entity.entity_id
)
if (
current is not entity
or self._inspected_configs.get(entity.entity_id) is None
or getattr(entity, "raw_config", None)
is not self._inspected_configs[entity.entity_id]
):
return set()
return self._unknown_by_owner.get(entity.entity_id, set())
async def async_check_draft(self, draft: Any) -> list[str]:
"""Return what a draft names that is unknown, each with its best guess.
A draft is in no round: it is not among the entities a round goes by,
so it is asked about on its own, with the same readers.
"""
unknown = await self._async_unknown(self._named_in(draft, draft.raw_config))
found = []
for finding in unknown:
if isinstance(name := finding.name, AttributeValue):
line = f"{name.attribute} of {finding.entity_id}: {as_yaml(name.value)}"
else:
line = _finding_reference(finding.entity_id, name)
if finding.suggestion is not None:
line += f" (did you mean {finding.suggestion}?)"
found.append(line)
return sorted(found)
def _format_references(self, references: list[str]) -> str:
"""Return the list of findings, each with its entity and best guess."""
lines = []
for reference in references:
finding = self._unknown_by_reference[reference]
if isinstance(name := finding.name, AttributeValue):
line = (
f"- `{name.attribute}` of `{finding.entity_id}`: "
f"`{as_yaml(name.value)}`"
)
else:
line = self.finding_line.format(name=name, entity_id=finding.entity_id)
if finding.suggestion is not None:
line += f" (did you mean `{finding.suggestion}`?)"
lines.append(line)
return "\n".join(lines)
class AbstractSpookUnknownAttributesRepair(AbstractSpookUnknownEntityNamesRepair, ABC):
"""Base for repairs about attributes an entity never had."""
reference_label = "attributes"
finding_line = "- `{name}` of `{entity_id}`"
def _references_in(self, config: dict[str, Any]) -> NamedReferences:
"""Return the attributes a configuration names, and of which entities."""
return extract_attribute_references_from_config(config)
async def _async_unknown(
self, pairs: set[tuple[str, str | AttributeValue]]
) -> set[_UnknownName]:
"""Return the (entity ID, attribute) pairs the entity never had."""
attributes = {
(entity_id, name) for entity_id, name in pairs if isinstance(name, str)
}
return {
_UnknownName(finding.entity_id, finding.attribute, finding.suggestion)
for finding in await async_unknown_attributes(self.hass, attributes)
}
class AbstractSpookUnknownStatesRepair(AbstractSpookUnknownEntityNamesRepair, ABC):
"""Base for repairs about states an entity is never in.
Only for entities whose states are a fixed set; `state_checking` says
which those are. The values of an attribute a trigger or condition waits
for are in here too: the same mistake, made one level down, and fixed in
the same place. `attribute_value_checking` says which attributes.
"""
reference_label = "states"
finding_line = "- `{name}` for `{entity_id}`"
def _references_in(self, config: dict[str, Any]) -> NamedReferences:
"""Return the states a configuration names, and of which entities."""
return extract_state_references_from_config(config)
async def _async_unknown(
self, pairs: set[tuple[str, str | AttributeValue]]
) -> set[_UnknownName]:
"""Return the (entity ID, state or attribute value) pairs never there."""
states: set[tuple[str, str]] = set()
values: set[tuple[str, AttributeValue]] = set()
for entity_id, name in pairs:
if isinstance(name, AttributeValue):
values.add((entity_id, name))
else:
states.add((entity_id, name))
unknown = {
_UnknownName(finding.entity_id, finding.state, finding.suggestion)
for finding in await async_unknown_states(self.hass, states)
}
unknown.update(
_UnknownName(
finding.entity_id,
AttributeValue(
finding.attribute, finding.value, as_text=finding.as_text
),
finding.suggestion,
)
for finding in await async_unknown_attribute_values(self.hass, values)
)
return unknown
class AbstractSpookSingleShotRepairs(AbstractSpookRepairBase, ABC):
"""Abstract class to hold repairs that are single a shot."""
@@ -683,8 +1070,15 @@ class SpookRepairManager:
self.issue_registry = ir.async_get(self.hass)
LOGGER.debug("Spook repair manager initialized")
async def async_setup(self) -> None:
"""Set up the Spook repairs."""
async def async_setup(self, *, spread_first_inspections: bool = False) -> None:
"""Set up the Spook repairs.
Right after a start, the first inspections are spread out rather than
all run at once. Anything that happens before a repair's turn comes
is not looked at separately, as that first look sees how things ended
up anyway. On a reload or a first setup on a running instance, the
house is quiet and everything looks straight away, like it always did.
"""
LOGGER.debug("Setting up Spook repairs")
modules: list[ModuleType] = []
@@ -700,21 +1094,37 @@ class SpookRepairManager:
modules.append(importlib.import_module(f".{module_path}", __package__))
await self.hass.async_add_import_executor_job(_load_all_repair_modules)
first_inspection_delays = (
_first_inspection_delays(modules) if spread_first_inspections else {}
)
await asyncio.gather(
*(
create_eager_task(self._async_setup_repair_module(module))
create_eager_task(
self._async_setup_repair_module(
module, first_inspection_delays.get(module.__name__)
)
)
for module in modules
)
)
async def _async_setup_repair_module(self, module: ModuleType) -> None:
async def _async_setup_repair_module(
self, module: ModuleType, first_inspection_delay: timedelta | None = None
) -> None:
"""Set up a single repair module, isolating failures.
A repair that fails to set up must not prevent the rest of Spook
from loading.
"""
try:
await self.async_activate(module.SpookRepair(self.hass))
repair = module.SpookRepair(self.hass)
if first_inspection_delay is not None:
repair.first_inspection_delay = first_inspection_delay
await self.async_activate(repair)
# pylint: disable-next=broad-exception-caught
except Exception: # noqa: BLE001
LOGGER.exception(
@@ -767,6 +1177,35 @@ class SpookRepairManager:
await repair.async_deactivate()
def _takes_a_turn(repair_class: type | None) -> bool:
"""Return whether a repair waits for a turn at its first look."""
return (
isinstance(repair_class, type)
and issubclass(repair_class, AbstractSpookRepair)
and repair_class.first_inspection_delay is None
)
def _first_inspection_delays(modules: Iterable[ModuleType]) -> dict[str, timedelta]:
"""Give every repair module its turn for a first look, evenly spread.
In order of module name, so the same repair always looks at the same
moment after a start, which keeps it predictable for people and tests.
Repairs that set their own delay keep it: they wait for the recorder,
which is a later moment than any of these turns.
"""
taking_turns = sorted(
module.__name__
for module in modules
if _takes_a_turn(getattr(module, "SpookRepair", None))
)
if not taking_turns:
return {}
turn = FIRST_INSPECTIONS_SPREAD_OVER / len(taking_turns)
return {name: turn * index for index, name in enumerate(taking_turns)}
class RestartRequiredFixFlow(RepairsFlow):
"""Handler for a repairs issue flow that restarts Home Assistant."""
@@ -1354,8 +1793,16 @@ class HelperUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
automations: set[str] = set()
scripts: set[str] = set()
for entity in er.async_entries_for_config_entry(entity_registry, entry_id):
automations.update(automations_with_entity(self.hass, entity.entity_id))
scripts.update(scripts_with_entity(self.hass, entity.entity_id))
automations.update(
async_referencing(
self.hass, AUTOMATION_DOMAIN, "entities", entity.entity_id
)
)
scripts.update(
async_referencing(
self.hass, SCRIPT_DOMAIN, "entities", entity.entity_id
)
)
if not automations and not scripts:
return "It is not used by any automation or script."