429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

+559 -107
View File
@@ -4,43 +4,67 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from contextlib import suppress
from dataclasses import dataclass, field
import importlib
from pathlib import Path
from typing import TYPE_CHECKING, Any, final
from homeassistant.components import blueprint
from homeassistant.components.automation import automations_with_entity
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.config_entries import (
SIGNAL_CONFIG_ENTRY_CHANGED,
ConfigEntry,
ConfigEntryChange,
)
from homeassistant.const import CONF_ENTITIES
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
issue_registry as ir,
label_registry as lr,
)
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.entity_platform import DATA_ENTITY_PLATFORM
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.async_ import create_eager_task
from .const import DOMAIN, LOGGER
from .entity_filtering import async_get_all_entity_ids
from .entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
from .entity_suggestions import async_describe_unknown_entities
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Mapping
from collections.abc import Callable, Coroutine, Mapping, Sized
from datetime import datetime, timedelta
from types import ModuleType
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.util.event_type import EventType
# Yield to the event loop after every batch of this many inspected
# entities; inspections are CPU-bound and must not stall the loop on
# large installations.
INSPECTION_YIELD_INTERVAL = 50
# A min/max helper needs at least this many members to function; Spook must
# not prune it below this.
_MIN_MAX_MINIMUM_MEMBERS = 2
def _plural(items: Sized) -> str:
"""Return the plural suffix for a sized collection."""
return "" if len(items) == 1 else "s"
class AbstractSpookRepairBase(ABC):
"""Abstract base class to hold a Spook repairs."""
@@ -138,6 +162,11 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
inspect_config_entry_changed: bool | str = False
inspect_on_reload: bool | str = False
#: Re-run the inspection on this fixed interval, on top of any event
#: triggers. Needed by repairs whose findings change with the passage of
#: time alone (e.g. something going stale), not in response to an event.
inspect_interval: timedelta | None = None
automatically_clean_up_issues: bool = False
possible_issue_ids: set[str]
@@ -149,29 +178,57 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
self._event_subs = set()
self.possible_issue_ids = set()
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:
return
if not self.automatically_clean_up_issues:
await self.async_inspect()
return
# Issues registered by earlier inspections. Anything not re-registered
# during this inspection is no longer valid, including issues for
# items that were removed entirely since the previous inspection.
previous_issue_ids = self.issue_ids.copy()
# Issues persisted in the issue registry for this repair. Covers
# leftovers from before a restart, including those for items that
# were removed while Home Assistant was down.
prefix = f"{self.repair}_"
registry_issue_ids = {
issue_id.removeprefix(prefix)
for domain, issue_id in self.issue_registry.issues
if domain == DOMAIN and issue_id.startswith(prefix)
}
# Reset registered issues. If they are still valid, they will be
# re-registered during the inspection.
self.issue_ids.clear()
try:
await self.async_inspect()
except Exception:
# Restore the bookkeeping so the next successful inspection can
# still clean up issues from before the failure.
self.issue_ids.update(previous_issue_ids)
raise
# Remove issues that are no longer valid after the inspection:
# - previous_issue_ids covers issues whose item was resolved or
# removed since the previous inspection.
# - registry_issue_ids covers stale issues persisted from an earlier
# runtime.
# - possible_issue_ids covers inspected items, as a safety net.
stale_issue_ids = (
previous_issue_ids | registry_issue_ids | self.possible_issue_ids
) - self.issue_ids
for issue_id in stale_issue_ids:
self.async_delete_issue(issue_id)
async def async_activate(self) -> None: # noqa: C901
"""Handle the activating a repair."""
async def _async_inspect() -> None:
# Don't inspect if we are stopping
if self.hass.is_stopping:
return
if self.automatically_clean_up_issues:
# Reset registered issues. If they are still valid, they will be
# re-registered during the inspection.
self.issue_ids.clear()
await self.async_inspect()
if self.automatically_clean_up_issues:
# Remove issues that are not longer created after inspection.
for issue_id in self.possible_issue_ids - self.issue_ids:
self.async_delete_issue(issue_id)
# Remove issues that are no longer valid.
for issue_id in self.issue_ids - self.possible_issue_ids:
self.async_delete_issue(issue_id)
# Debouncer to prevent multiple inspections / inspections fired quickly
# after each other.
self.inspect_debouncer = Debouncer(
@@ -179,22 +236,34 @@ class AbstractSpookRepair(AbstractSpookRepairBase):
LOGGER,
cooldown=3,
immediate=False,
function=_async_inspect,
function=self._async_inspect_with_cleanup,
)
# Spook says: Bounce!
await self.inspect_debouncer.async_call()
if self.inspect_events is None:
return
async def _async_call_inspect_debouncer(_: Event) -> None:
# Trigger an inspection when an event is received from the event bus.
await self.inspect_debouncer.async_call()
for event in self.inspect_events:
if self.inspect_events is not None:
for event in self.inspect_events:
self._event_subs.add(
self.hass.bus.async_listen(event, _async_call_inspect_debouncer),
)
if self.inspect_interval is not None:
async def _async_call_inspect_debouncer_interval(_: datetime) -> None:
# Trigger an inspection when the interval timer fires.
await self.inspect_debouncer.async_call()
self._event_subs.add(
self.hass.bus.async_listen(event, _async_call_inspect_debouncer),
async_track_time_interval(
self.hass,
_async_call_inspect_debouncer_interval,
self.inspect_interval,
),
)
if self.inspect_on_reload:
@@ -268,8 +337,10 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
#: Entity class representing an unavailable/broken instance. Entities of
#: this type are still tracked in ``possible_issue_ids`` but skipped during
#: issue creation.
unavailable_entity_class: type
#: issue creation. ``None`` inspects every entity, including unavailable
#: ones; repairs that diagnose *why* an entity is broken need exactly
#: those.
unavailable_entity_class: type | None = None
#: Translation placeholder key holding the entity's display name (e.g.
#: ``"automation"`` or ``"script"``).
@@ -283,6 +354,16 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
#: ``{unique_id}`` field (e.g. ``"/config/automation/edit/{unique_id}"``).
edit_url_pattern: str
#: When the references are entities, enrich each with why it is unknown
#: (deleted on/by, or a likely rename). Only set on entity repairs.
references_are_entities: bool = False
def _format_references(self, references: list[str]) -> str:
"""Return the bulleted reference list for the issue message."""
if self.references_are_entities:
return async_describe_unknown_entities(self.hass, references)
return "\n".join(f"- `{reference}`" for reference in references)
async def _async_setup_inspection(self) -> None:
"""Prepare per-inspection state (called once per inspection cycle).
@@ -303,6 +384,16 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return the set of unknown referenced IDs for a single entity."""
def _edit_url(self, entity: Any) -> str:
"""Return the URL to edit the given entity.
Items created in YAML can lack a unique ID, in which case there is no
editor to deep-link to; fall back to the domain's overview page.
"""
if entity.unique_id is None:
return f"/config/{self.domain}/dashboard"
return self.edit_url_pattern.format(unique_id=entity.unique_id)
async def async_inspect(self) -> None:
"""Trigger an inspection."""
self.possible_issue_ids.clear()
@@ -316,10 +407,17 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
await self._async_setup_inspection()
for entity in entity_component.entities:
for index, entity in enumerate(entity_component.entities):
if index and index % INSPECTION_YIELD_INTERVAL == 0:
# Inspections are CPU-bound; periodically yield to the event
# loop so large installations do not stall it.
await asyncio.sleep(0)
self.possible_issue_ids.add(entity.entity_id)
if isinstance(entity, self.unavailable_entity_class):
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):
continue
if not self._should_inspect_entity(entity):
@@ -334,11 +432,9 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
self.async_create_issue(
issue_id=entity.entity_id,
translation_placeholders={
self.reference_label: "\n".join(
f"- `{item}`" for item in sorted_unknown
),
self.reference_label: self._format_references(sorted_unknown),
self.entity_label: entity.name,
"edit": self.edit_url_pattern.format(unique_id=entity.unique_id),
"edit": self._edit_url(entity),
"entity_id": entity.entity_id,
},
)
@@ -351,68 +447,6 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
)
class AbstractSpookEntityPlatformUnknownSourceRepair(AbstractSpookRepair, ABC):
"""Base class for repairs that find unknown source entities on helpers.
Handles the shared boilerplate for inspecting entities loaded via
`EntityPlatform` (e.g. ``switch_as_x``, ``integration``, ``utility_meter``,
``trend``): walking the platforms, optionally filtering by platform domain,
pulling each entity's source attribute via a subclass hook, and raising an
issue when the source is no longer known to Home Assistant.
"""
automatically_clean_up_issues = True
#: When set, only entities living on platforms whose ``domain`` equals this
#: value are inspected. ``None`` (the default) inspects every platform of
#: the integration domain.
source_platform_domain: str | None = None
@abstractmethod
def _get_source_entity_id(self, entity: Any) -> str:
"""Return the source entity ID for the given helper entity."""
async def async_inspect(self) -> None:
"""Trigger an inspection."""
self.possible_issue_ids.clear()
LOGGER.debug("Spook is inspecting: %s", self.repair)
platforms: list[EntityPlatform] | None
if not (
platforms := self.hass.data.get(DATA_ENTITY_PLATFORM, {}).get(self.domain)
):
return # Nothing to do, integration is not loaded.
known_entity_ids = async_get_all_entity_ids(self.hass)
for platform in platforms:
if (
self.source_platform_domain is not None
and platform.domain != self.source_platform_domain
):
continue
for entity in platform.entities.values():
self.possible_issue_ids.add(entity.entity_id)
source = self._get_source_entity_id(entity)
if source not in known_entity_ids:
self.async_create_issue(
issue_id=entity.entity_id,
translation_placeholders={
"entity_id": entity.entity_id,
"helper": entity.name,
"source": source,
},
)
LOGGER.debug(
"Spook found unknown source entity %s in %s "
"and created an issue for it",
source,
entity.entity_id,
)
class AbstractSpookSingleShotRepairs(AbstractSpookRepairBase, ABC):
"""Abstract class to hold repairs that are single a shot."""
@@ -459,11 +493,28 @@ class SpookRepairManager:
await self.hass.async_add_import_executor_job(_load_all_repair_modules)
await asyncio.gather(
*(
create_eager_task(self.async_activate(module.SpookRepair(self.hass)))
create_eager_task(self._async_setup_repair_module(module))
for module in modules
)
)
async def _async_setup_repair_module(self, module: ModuleType) -> 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))
# pylint: disable-next=broad-exception-caught
except Exception: # noqa: BLE001
LOGGER.exception(
"Spook repair %s failed to set up and has been skipped; "
"please report this issue at "
"https://github.com/frenck/spook/issues",
module.__name__,
)
async def async_activate(self, repair: AbstractSpookRepair) -> None:
"""Register a Spook repair."""
LOGGER.debug(
@@ -488,11 +539,10 @@ class SpookRepairManager:
if self.hass.is_stopping:
continue
# Remove issues created by this Spook repair
# Remove issues created by this Spook repair. Issue IDs are
# created as "<repair>_<issue_id>" (see async_create_issue).
for domain, issue_id in list(self.issue_registry.issues):
if domain == DOMAIN and issue_id.startswith(
f"{repair.domain}_{repair.repair}",
):
if domain == DOMAIN and issue_id.startswith(f"{repair.repair}_"):
self.issue_registry.async_delete(domain, issue_id)
@@ -523,12 +573,414 @@ class RestartRequiredFixFlow(RepairsFlow):
return self.async_show_form(step_id="confirm_restart")
class _RemoveOrIgnoreFixFlow(RepairsFlow):
"""Base for a leftover registry thing: remove it, or keep and stop nagging.
Leftover registry things (empty areas, empty floors, unused labels) are
tidiness, not breakage, so keeping one is a valid choice. A fixable issue
cannot be dismissed from the repairs UI, so the flow offers an explicit
"keep it" option that ignores the issue instead. Subclasses set ``_key``
(the placeholder key), ``_id_key`` (the data key holding the id), and
implement ``_remove``.
"""
#: Placeholder key naming the thing (e.g. "area").
_key: str
#: Data key holding the thing's id (e.g. "empty_area_id").
_id_key: str
async def async_step_init(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Offer to remove the thing, fix it yourself, or keep and ignore it."""
return self.async_show_menu(
step_id="init",
menu_options=["remove", "manage", "ignore"],
description_placeholders=self._menu_placeholders(),
)
def _menu_placeholders(self) -> dict[str, str]:
"""Return the placeholders naming the thing in the menu step."""
return {self._key: str((self.data or {}).get(self._key, ""))}
async def async_step_manage(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Point the user at where to fix it themselves.
Aborts rather than completing, so the issue stays until the user
actually resolves it. The abort message links to the right page.
"""
return self.async_abort(reason="manage")
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the thing, if it still exists."""
self._remove(str((self.data or {}).get(self._id_key, "")))
return self.async_create_entry(data={})
async def async_step_ignore(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Keep the thing and ignore the issue.
Aborting (rather than creating an entry) keeps the issue so the
ignore sticks; a completed fix flow would delete it and it would
just come back on the next inspection.
"""
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, ignore=True)
return self.async_abort(reason="issue_ignored")
@callback
def _remove(self, thing_id: str) -> None:
"""Remove the thing by id. Implemented by subclasses."""
raise NotImplementedError
class EmptyAreaFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for an empty area: remove it, or keep it and stop nagging."""
_key = "area"
_id_key = "empty_area_id"
@callback
def _remove(self, thing_id: str) -> None:
"""Remove the area, if it still exists."""
registry = ar.async_get(self.hass)
# The area may already be gone if removed elsewhere meanwhile.
if registry.async_get_area(thing_id):
registry.async_delete(thing_id)
class EmptyFloorFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for an empty floor: remove it, or keep it and stop nagging."""
_key = "floor"
_id_key = "empty_floor_id"
@callback
def _remove(self, thing_id: str) -> None:
"""Remove the floor, if it still exists."""
registry = fr.async_get(self.hass)
# The floor may already be gone if removed elsewhere meanwhile.
if registry.async_get_floor(thing_id):
registry.async_delete(thing_id)
class UnusedLabelFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for an unused label: remove it, or keep it and stop nagging."""
_key = "label"
_id_key = "unused_label_id"
@callback
def _remove(self, thing_id: str) -> None:
"""Remove the label, if it still exists."""
registry = lr.async_get(self.hass)
# The label may already be gone if removed elsewhere meanwhile.
if registry.async_get_label(thing_id):
registry.async_delete(thing_id)
class StaleAccessTokenFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for a stale access token: revoke it, or keep it and stop nagging.
A fixable issue cannot be dismissed from the repairs UI, so keeping a
token you still want is offered explicitly as the "keep it" option.
"""
_key = "token"
_id_key = "stale_access_token_id"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the token, owner, and last-used date in the menu step."""
data = self.data or {}
return {
key: str(data.get(key, "")) for key in ("token", "owner", "last_active")
}
@callback
def _remove(self, thing_id: str) -> None:
"""Revoke the token, if it still exists."""
# The token may already be gone if revoked elsewhere meanwhile.
if token := self.hass.auth.async_get_refresh_token(thing_id):
self.hass.auth.async_remove_refresh_token(token)
class UnusedBlueprintFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for an unused blueprint: remove it, or keep it and stop nagging.
Blueprint removal deletes a file, so it overrides ``async_step_remove``
with the async removal instead of the synchronous ``_remove`` hook.
"""
_key = "blueprint"
_id_key = "unused_blueprint_path"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the blueprint and its domain in the menu step."""
data = self.data or {}
return {
"blueprint": str(data.get("blueprint", "")),
"domain": str(data.get("unused_blueprint_domain", "")),
}
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the blueprint file, if it still exists and is not in use."""
data = self.data or {}
domain = str(data.get("unused_blueprint_domain", ""))
path = str(data.get("unused_blueprint_path", ""))
domain_blueprints: dict[str, blueprint.DomainBlueprints] = self.hass.data.get(
blueprint.DOMAIN, {}
)
if domain_blueprint := domain_blueprints.get(domain):
# It may already be gone, or have gained a consumer meanwhile.
with suppress(FileNotFoundError, blueprint.BlueprintInUse):
await domain_blueprint.async_remove_blueprint(path)
return self.async_create_entry(data={})
class PersonUnknownDeviceTrackerFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for a person's unknown device trackers.
Offers to strip the unknown device trackers from the person, or to keep
them and ignore the issue. Removal reuses Spook's own
``person.remove_device_tracker`` action, which only edits storage-backed
persons; a YAML person cannot be edited, so that is reported instead.
"""
_key = "person"
_id_key = "person_entity_id"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the person and the offending device trackers in the menu."""
data = self.data or {}
return {
"person": str(data.get("person", "")),
"device_trackers": str(data.get("device_trackers", "")),
}
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the still-unknown device trackers from the person."""
data = self.data or {}
entity_id = str(data.get("person_entity_id", ""))
candidates = [t for t in str(data.get("unknown_trackers", "")).split(",") if t]
entity_registry = er.async_get(self.hass)
unknown = [
tracker
for tracker in candidates
if entity_registry.async_get(tracker) is None
and self.hass.states.get(tracker) is None
]
if unknown:
try:
await self.hass.services.async_call(
"person",
"remove_device_tracker",
{"entity_id": entity_id, "device_tracker": unknown},
blocking=True,
)
except HomeAssistantError:
# YAML persons are not editable; nothing Spook can remove.
return self.async_abort(reason="not_editable")
return self.async_create_entry(data={})
class GroupUnknownMembersFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for a group's unknown members.
Prunes the missing members from a UI-managed group's config entry. Only
config-entry groups can be edited; a YAML group cannot, so that is
reported instead.
"""
_key = "group"
_id_key = "group_entity_id"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the group and its missing members in the menu step."""
data = self.data or {}
return {
"group": str(data.get("group", "")),
"entity_id": str(data.get("group_entity_id", "")),
"entities": str(data.get("entities", "")),
}
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the members that no longer exist from the group."""
entity_id = str((self.data or {}).get("group_entity_id", ""))
entity_registry = er.async_get(self.hass)
entry_entity = entity_registry.async_get(entity_id)
if entry_entity is None or entry_entity.config_entry_id is None:
# A YAML group; its members live in configuration.yaml.
return self.async_abort(reason="not_editable")
# If the config entry itself is already gone, there is nothing left
# to prune; the group's own entity is gone too, so the issue clears
# on the next inspection. Completing the flow is the honest outcome.
entry = self.hass.config_entries.async_get_entry(entry_entity.config_entry_id)
if entry is not None:
members = list(entry.options.get(CONF_ENTITIES) or [])
remaining = [
member
for member in members
if entity_registry.async_get(member) is not None
or self.hass.states.get(member) is not None
]
if remaining != members:
self.hass.config_entries.async_update_entry(
entry, options={**entry.options, CONF_ENTITIES: remaining}
)
return self.async_create_entry(data={})
class MinMaxUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for a min/max helper's missing members.
Prunes the members that no longer exist from the helper's config entry,
keeping the ones that remain, so the helper carries on working.
"""
_key = "helper"
_id_key = "min_max_config_entry_id"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the helper and its missing members in the menu step."""
data = self.data or {}
return {
"helper": str(data.get("helper", "")),
"sources": str(data.get("sources", "")),
}
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the members that no longer exist from the helper."""
entry_id = str((self.data or {}).get("min_max_config_entry_id", ""))
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is not None:
entity_registry = er.async_get(self.hass)
known_entity_ids = async_get_all_entity_ids(self.hass)
members = list(entry.options.get("entity_ids") or [])
remaining = [
value
for value in members
if (resolved := er.async_resolve_entity_id(entity_registry, value))
is not None
and not async_filter_known_entity_ids(
self.hass, [resolved], known_entity_ids=known_entity_ids
)
]
if remaining != members:
if len(remaining) < _MIN_MAX_MINIMUM_MEMBERS:
# A min/max helper needs at least two members; pruning
# would leave too few and break it. Let the user decide.
return self.async_abort(reason="too_few_members")
self.hass.config_entries.async_update_entry(
entry, options={**entry.options, "entity_ids": remaining}
)
return self.async_create_entry(data={})
class HelperUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
"""Handler for a helper whose source entities are gone.
A single-source helper whose source no longer exists is broken; there is
nothing to prune, so the fix is to remove the whole helper. Warns, up
front, when the helper is still used by automations or scripts, and is
honest that those become dangling references, which Spook itself will
then point out.
"""
_key = "helper"
_id_key = "helper_config_entry_id"
def _menu_placeholders(self) -> dict[str, str]:
"""Name the helper, its missing sources, and where it is used."""
data = self.data or {}
return {
"helper": str(data.get("helper", "")),
"domain": str(data.get("domain", "")),
"sources": str(data.get("sources", "")),
"usage": self._usage_text(str(data.get("helper_config_entry_id", ""))),
}
def _usage_text(self, entry_id: str) -> str:
"""Describe which automations and scripts still use the helper."""
entity_registry = er.async_get(self.hass)
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))
if not automations and not scripts:
return "It is not used by any automation or script."
parts: list[str] = []
if automations:
parts.append(f"{len(automations)} automation{_plural(automations)}")
if scripts:
parts.append(f"{len(scripts)} script{_plural(scripts)}")
return (
f"It is still used by {' and '.join(parts)}. Removing the helper "
"leaves them referencing a missing entity, which Spook will then "
"point out for you."
)
async def async_step_remove(
self,
_: dict[str, str] | None = None,
) -> FlowResult:
"""Remove the whole helper, if it still exists."""
entry_id = str((self.data or {}).get("helper_config_entry_id", ""))
if self.hass.config_entries.async_get_entry(entry_id) is not None:
await self.hass.config_entries.async_remove(entry_id)
return self.async_create_entry(data={})
# Remove-or-ignore fix flows, keyed by the data field that identifies their
# leftover registry thing.
_REMOVE_OR_IGNORE_FLOWS: dict[str, type[_RemoveOrIgnoreFixFlow]] = {
"stale_access_token_id": StaleAccessTokenFixFlow,
"empty_area_id": EmptyAreaFixFlow,
"empty_floor_id": EmptyFloorFixFlow,
"unused_label_id": UnusedLabelFixFlow,
"unused_blueprint_path": UnusedBlueprintFixFlow,
"person_entity_id": PersonUnknownDeviceTrackerFixFlow,
"group_entity_id": GroupUnknownMembersFixFlow,
"min_max_config_entry_id": MinMaxUnknownSourcesFixFlow,
"helper_config_entry_id": HelperUnknownSourcesFixFlow,
}
async def async_create_fix_flow(
_hass: HomeAssistant,
issue_id: str,
_data: dict[str, str | int | float | None] | None,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id == "restart_required":
return RestartRequiredFixFlow()
if data:
for key, flow in _REMOVE_OR_IGNORE_FLOWS.items():
if data.get(key):
return flow()
return ConfirmRepairFlow()