35 files
This commit is contained in:
@@ -25,6 +25,7 @@ from .repairs import SpookRepairManager
|
||||
from .run_history import async_setup_run_history
|
||||
from .services import SpookServiceManager
|
||||
from .setup_helpers import async_forward_setup_entry
|
||||
from .statistics_sources import async_setup_abandoned_statistics_watching
|
||||
from .timed_states import async_setup_timed_states
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -94,6 +95,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await services.async_setup()
|
||||
entry.async_on_unload(services.async_on_unload)
|
||||
|
||||
# Watching before anything can start a wait it has to observe. A repair
|
||||
# can put a statistic on the clock the first time it looks, and on the
|
||||
# reload path that happens below rather than at some later event, so a
|
||||
# watcher registered afterwards would already have missed the beginning
|
||||
# of what it is meant to be watching.
|
||||
entry.async_on_unload(async_setup_abandoned_statistics_watching(hass))
|
||||
|
||||
# Who you gonna call? SpookRepairManager!
|
||||
repairs = SpookRepairManager(hass)
|
||||
|
||||
|
||||
@@ -349,4 +349,5 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
self.hass,
|
||||
entity_ids=all_entities,
|
||||
known_entity_ids=self._known_entity_ids,
|
||||
known_services=self._known_services,
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
)
|
||||
self.async_create_issue(
|
||||
issue_id=entity.entity_id,
|
||||
references=unknown_entities,
|
||||
is_fixable=True,
|
||||
data={
|
||||
"group_entity_id": entity.entity_id,
|
||||
|
||||
@@ -103,6 +103,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
title = dashboard.config.get("title", url_path)
|
||||
self.async_create_issue(
|
||||
issue_id=url_path,
|
||||
references=unknown_entities,
|
||||
translation_placeholders={
|
||||
"entities": async_describe_unknown_entities(
|
||||
self.hass, sorted(unknown_entities)
|
||||
|
||||
@@ -51,6 +51,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
):
|
||||
self.async_create_issue(
|
||||
issue_id=entry.entry_id,
|
||||
references=unknown_entities,
|
||||
translation_placeholders={
|
||||
"name": coordinator.name,
|
||||
"zones": async_describe_unknown_entities(
|
||||
|
||||
@@ -51,6 +51,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
):
|
||||
self.async_create_issue(
|
||||
issue_id=entry.entry_id,
|
||||
references=unknown_entities,
|
||||
translation_placeholders={
|
||||
"name": coordinator.name,
|
||||
"entities": async_describe_unknown_entities(
|
||||
|
||||
@@ -45,6 +45,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
if coordinator.proximity_zone_id not in known_entity_ids:
|
||||
self.async_create_issue(
|
||||
issue_id=entry.entry_id,
|
||||
references=[coordinator.proximity_zone_id],
|
||||
translation_placeholders={
|
||||
"name": coordinator.name,
|
||||
"zone": coordinator.proximity_zone_id,
|
||||
|
||||
@@ -60,6 +60,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
)
|
||||
self.async_create_issue(
|
||||
issue_id=entity.entity_id,
|
||||
references=unknown_entities,
|
||||
translation_placeholders={
|
||||
"entities": async_describe_unknown_entities(
|
||||
self.hass, sorted(unknown_entities)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components import script
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ....entity_filtering import async_get_all_entity_ids
|
||||
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
from ....template_extraction import (
|
||||
async_extract_entities_from_config,
|
||||
@@ -61,13 +61,19 @@ def extract_referenced_entities_from_script(entity: script.ScriptEntity) -> set[
|
||||
|
||||
|
||||
async def extract_template_entities_from_script_entity(
|
||||
hass: HomeAssistant, entity: Any
|
||||
hass: HomeAssistant,
|
||||
entity: Any,
|
||||
known_services: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entities from script configuration using Template analysis.
|
||||
|
||||
This function finds template strings in script 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.
|
||||
|
||||
``known_services`` is built once per inspection and handed down, because
|
||||
building it flattens every service Home Assistant has and every script
|
||||
with a template in it needs the same answer.
|
||||
"""
|
||||
# Get the script configuration
|
||||
config = None
|
||||
@@ -82,7 +88,7 @@ async def extract_template_entities_from_script_entity(
|
||||
if not config:
|
||||
return set()
|
||||
|
||||
return await async_extract_entities_from_config(hass, config)
|
||||
return await async_extract_entities_from_config(hass, config, known_services)
|
||||
|
||||
|
||||
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
@@ -104,6 +110,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
edit_url_pattern = "/config/script/edit/{unique_id}"
|
||||
|
||||
_known_entity_ids: set[str]
|
||||
_known_services: set[str]
|
||||
|
||||
def _get_blueprint_trigger_entities(self, entity: script.ScriptEntity) -> set[str]:
|
||||
"""Extract entity references from blueprint trigger inputs."""
|
||||
@@ -134,10 +141,16 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
return entities
|
||||
|
||||
async def _async_setup_inspection(self) -> None:
|
||||
"""Cache known entity IDs (including ALL/NONE) for this inspection cycle."""
|
||||
"""Cache what every script 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 script 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_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown entity IDs referenced by ``entity`` (incl. templates)."""
|
||||
@@ -149,11 +162,14 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
# Extract entities from Template objects within the script entity
|
||||
all_entities.update(
|
||||
await extract_template_entities_from_script_entity(self.hass, entity)
|
||||
await extract_template_entities_from_script_entity(
|
||||
self.hass, entity, self._known_services
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ from .listeners import async_listen_once_tracked
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
|
||||
# Entity domains to ignore when filtering unknown entities. These can be
|
||||
@@ -98,6 +98,7 @@ class EntityIDsCache:
|
||||
entity_ids_by_domain: dict[str, list[str]] | None = None
|
||||
created_scene_ids: set[str] | None = None
|
||||
rename_suggestions: dict[str, str | None] | None = None
|
||||
deleted_entities: dict[str, er.DeletedRegistryEntry] | None = None
|
||||
unsubscribe: Callable[[], None] | None = None
|
||||
|
||||
|
||||
@@ -138,7 +139,20 @@ def async_setup_all_entity_ids_cache_invalidation(
|
||||
cache.entity_ids = None
|
||||
cache.entity_ids_by_domain = None
|
||||
cache.created_scene_ids = None
|
||||
|
||||
@callback
|
||||
def _clear_cache_and_suggestions(*_args: Any) -> None:
|
||||
"""Clear the entity IDs and the rename suggestions worked out from them.
|
||||
|
||||
Kept apart from `_clear_cache` because the two cost wildly different
|
||||
amounts to rebuild. The entity IDs are a couple of set unions. A
|
||||
suggestion is a fuzzy comparison against every entity in its domain,
|
||||
and there is one per broken reference in the house, so throwing them
|
||||
away is tens of seconds of solid work on a large installation. #1667.
|
||||
"""
|
||||
_clear_cache()
|
||||
cache.rename_suggestions = None
|
||||
cache.deleted_entities = None
|
||||
|
||||
@callback
|
||||
def _state_entity_changed(event_data: Mapping[str, Any]) -> bool:
|
||||
@@ -147,17 +161,41 @@ def async_setup_all_entity_ids_cache_invalidation(
|
||||
event_data.get("old_state") is None or event_data.get("new_state") is None
|
||||
)
|
||||
|
||||
@callback
|
||||
def _registry_updated(event: Event[er.EventEntityRegistryUpdatedData]) -> None:
|
||||
"""Handle a registry entry being created, removed or changed.
|
||||
|
||||
The suggestions only go when the pool they were drawn from moves:
|
||||
an entity registered, unregistered, or renamed. Registry entries are
|
||||
written for plenty of other reasons (an icon, a category, a device
|
||||
being reassigned), and none of those change what an entity is called.
|
||||
"""
|
||||
data = event.data
|
||||
pool_moved = data["action"] != "update" or "entity_id" in data["changes"]
|
||||
|
||||
if pool_moved:
|
||||
_clear_cache_and_suggestions()
|
||||
return
|
||||
|
||||
_clear_cache()
|
||||
|
||||
# Listen for entity registry updates
|
||||
unsub_registry_update = hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED, _clear_cache
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED, _registry_updated
|
||||
)
|
||||
# Listen for Home Assistant start to ensure cache is clear then
|
||||
unsub_hass_start = async_listen_once_tracked(
|
||||
hass, EVENT_HOMEASSISTANT_START, _clear_cache
|
||||
hass, EVENT_HOMEASSISTANT_START, _clear_cache_and_suggestions
|
||||
)
|
||||
# Listen for components loading
|
||||
unsub_component_loaded = hass.bus.async_listen(EVENT_COMPONENT_LOADED, _clear_cache)
|
||||
# Listen for state-only entities being added or removed.
|
||||
unsub_component_loaded = hass.bus.async_listen(
|
||||
EVENT_COMPONENT_LOADED, _clear_cache_and_suggestions
|
||||
)
|
||||
# Listen for state-only entities being added or removed. The suggestions
|
||||
# are left standing here: an entity arriving or leaving the state machine
|
||||
# without touching the registry is the noisiest event in the house, and a
|
||||
# suggestion that is a few minutes out of date is a sentence in an issue
|
||||
# description, not a wrong answer about what is missing.
|
||||
unsub_state_changed = hass.bus.async_listen(
|
||||
EVENT_STATE_CHANGED,
|
||||
_clear_cache,
|
||||
@@ -165,7 +203,7 @@ def async_setup_all_entity_ids_cache_invalidation(
|
||||
)
|
||||
|
||||
# Perform an initial clear, just in case.
|
||||
_clear_cache()
|
||||
_clear_cache_and_suggestions()
|
||||
|
||||
def _unsubscribe_listeners() -> None:
|
||||
LOGGER.debug(
|
||||
@@ -175,10 +213,7 @@ def async_setup_all_entity_ids_cache_invalidation(
|
||||
unsub_hass_start()
|
||||
unsub_component_loaded()
|
||||
unsub_state_changed()
|
||||
cache.entity_ids = None
|
||||
cache.entity_ids_by_domain = None
|
||||
cache.created_scene_ids = None
|
||||
cache.rename_suggestions = None
|
||||
_clear_cache_and_suggestions()
|
||||
cache.unsubscribe = None # Mark as unsubscribed
|
||||
|
||||
cache.unsubscribe = _unsubscribe_listeners
|
||||
@@ -341,6 +376,52 @@ def async_get_rename_suggestion_cache(hass: HomeAssistant) -> dict[str, str | No
|
||||
return cache.rename_suggestions
|
||||
|
||||
|
||||
@callback
|
||||
def async_name_helper_in_the_registry(hass: HomeAssistant, entry_id: str) -> str:
|
||||
"""Return how to find the helper behind a config entry, by ID.
|
||||
|
||||
A helper is reported by its title, which is the only name anybody gave
|
||||
it, and a title is not something Home Assistant can be searched by. When
|
||||
the helper has gone strange the title is all somebody has, and it takes
|
||||
them to a Helpers page that does not show it. #1633.
|
||||
|
||||
So: the entity IDs it registered, which is what they were looking for.
|
||||
Failing that the config entry ID, because a helper with nothing in the
|
||||
entity registry is exactly the one that cannot be found by looking, and
|
||||
that ID is the handle it is stored under.
|
||||
"""
|
||||
registry = er.async_get(hass)
|
||||
entity_ids = sorted(
|
||||
entity.entity_id
|
||||
for entity in er.async_entries_for_config_entry(registry, entry_id)
|
||||
)
|
||||
|
||||
return ", ".join(entity_ids) if entity_ids else entry_id
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_deleted_entities(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, er.DeletedRegistryEntry]:
|
||||
"""Return the deleted entity registry entries, keyed by entity ID.
|
||||
|
||||
Cached, because the description of one broken reference is built per
|
||||
reference and every one of them wants this same map. Home Assistant keeps
|
||||
deleted entries around for a long while, so on an old installation there
|
||||
are thousands of them to walk.
|
||||
"""
|
||||
cache = _async_get_cache(hass)
|
||||
|
||||
if cache.deleted_entities is None:
|
||||
entity_registry = er.async_get(hass)
|
||||
cache.deleted_entities = {
|
||||
deleted.entity_id: deleted
|
||||
for deleted in entity_registry.deleted_entities.values()
|
||||
}
|
||||
|
||||
return cache.deleted_entities
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_all_area_ids(hass: HomeAssistant) -> set[str]:
|
||||
"""Return all area IDs, known to Home Assistant."""
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/frenck/spook/issues",
|
||||
"requirements": [],
|
||||
"version": "5.5.1"
|
||||
"version": "5.6.0"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"title": "Invertuj 👻"
|
||||
},
|
||||
"user": {
|
||||
"description": "Ovaj pomoćnik omogućava da obrnete ponašanje entiteta. Na primer, preokreće stanja otvoreno/zatvoreno ili uključeno/isključeno za binarne senzore i prekidače.",
|
||||
"description": "Ovaj pomoćnik ti omogućava da preokreneš ponašanje entiteta. Na primer, da obrneš stanja otvoreno/zatvoreno ili uključeno/isključeno za binarne senzore i prekidače.",
|
||||
"menu_options": {
|
||||
"binary_sensor": "Invertuj binarni senzor",
|
||||
"switch": "Invertuj prekidač"
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"requirements": [
|
||||
"cronsim==2.7"
|
||||
],
|
||||
"version": "5.5.1"
|
||||
"version": "5.6.0"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
@@ -36,15 +37,24 @@ 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.recorder import get_instance
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .dashboard_resources import is_yaml_managed, redundant_item_ids
|
||||
from .entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
|
||||
from .entity_suggestions import async_describe_unknown_entities
|
||||
from .entity_filtering import (
|
||||
async_filter_known_entity_ids,
|
||||
async_get_all_entity_ids,
|
||||
async_name_helper_in_the_registry,
|
||||
)
|
||||
from .entity_suggestions import (
|
||||
async_describe_unknown_entities,
|
||||
async_warm_rename_suggestions,
|
||||
)
|
||||
from .statistics_sources import async_settled_orphaned_statistic_ids
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Coroutine, Mapping, Sized
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sized
|
||||
from datetime import datetime, timedelta
|
||||
from types import ModuleType
|
||||
|
||||
@@ -57,6 +67,18 @@ if TYPE_CHECKING:
|
||||
# large installations.
|
||||
INSPECTION_YIELD_INTERVAL = 50
|
||||
|
||||
# How long to give the recorder to say it has cleared what it was asked to.
|
||||
# The same as Home Assistant allows its own Statistics page, which asks the
|
||||
# recorder the same question the same way.
|
||||
_CLEARING_TAKES_AT_MOST = 10
|
||||
|
||||
# Enough of a digest to tell two sets of findings apart. A collision would
|
||||
# mean one dismissal covering a different finding in the same place, which is
|
||||
# the thing this exists to prevent, and eight hex characters make that four
|
||||
# billion to one.
|
||||
_FINGERPRINT_LENGTH = 8
|
||||
|
||||
|
||||
# A min/max helper needs at least this many members to function; Spook must
|
||||
# not prune it below this.
|
||||
_MIN_MAX_MINIMUM_MEMBERS = 2
|
||||
@@ -67,6 +89,34 @@ def _plural(items: Sized) -> str:
|
||||
return "" if len(items) == 1 else "s"
|
||||
|
||||
|
||||
def _bulleted(items: Iterable[str]) -> str:
|
||||
"""Return the markdown list a repair puts its findings in."""
|
||||
return "\n".join(f"- `{item}`" for item in items)
|
||||
|
||||
|
||||
def _fingerprint(references: Iterable[str]) -> str:
|
||||
r"""Return a short digest of what a finding is about.
|
||||
|
||||
Sorted first, because the same findings in a different order are the same
|
||||
findings, and an ID that moved would resurface an issue somebody had
|
||||
already dealt with.
|
||||
|
||||
Each one is written down with its length in front of it, so that the
|
||||
digest reads two different sets two different ways. Joining them with a
|
||||
separator does not: a reference holding that separator borrows the one
|
||||
next to it, and `{"a\nb", "c"}` and `{"a", "b\nc"}` come out identical.
|
||||
Entity IDs cannot do that, but resource URLs, notifier names and
|
||||
customize keys are whatever somebody typed.
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
for reference in sorted(references):
|
||||
encoded = reference.encode()
|
||||
digest.update(f"{len(encoded)}:".encode())
|
||||
digest.update(encoded)
|
||||
|
||||
return digest.hexdigest()[:_FINGERPRINT_LENGTH]
|
||||
|
||||
|
||||
class AbstractSpookRepairBase(ABC):
|
||||
"""Abstract base class to hold a Spook repairs."""
|
||||
|
||||
@@ -103,6 +153,7 @@ class AbstractSpookRepairBase(ABC):
|
||||
issue_domain: str | None = None,
|
||||
issue_id: str,
|
||||
learn_more_url: str | None = None,
|
||||
references: Iterable[str] | None = None,
|
||||
severity: ir.IssueSeverity = ir.IssueSeverity.WARNING,
|
||||
translation_key: str | None = None,
|
||||
translation_placeholders: dict[str, str] | None = None,
|
||||
@@ -114,7 +165,18 @@ class AbstractSpookRepairBase(ABC):
|
||||
worded differently depending on what the house looks like, so that the
|
||||
alternative is a translated string of its own rather than a sentence
|
||||
smuggled in through a placeholder.
|
||||
|
||||
`references` is what the issue is reporting, when that is a list of
|
||||
things rather than the one place they were found in. Pass it and the
|
||||
ID follows the findings, so that pressing "ignore" means "not these"
|
||||
rather than "nothing here, ever". Without it an issue keyed to a
|
||||
script keeps its dismissal while its text is quietly rewritten
|
||||
underneath, and the next genuinely broken thing in that script is
|
||||
hidden by a decision somebody made about something else. #1395.
|
||||
"""
|
||||
if references is not None:
|
||||
issue_id = f"{issue_id}_{_fingerprint(references)}"
|
||||
|
||||
self.issue_ids.add(issue_id)
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
@@ -356,10 +418,9 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
|
||||
automatically_clean_up_issues = True
|
||||
|
||||
#: Entity class representing an unavailable/broken instance. Entities of
|
||||
#: this type are still tracked in ``possible_issue_ids`` but skipped during
|
||||
#: issue creation. ``None`` inspects every entity, including unavailable
|
||||
#: ones; repairs that diagnose *why* an entity is broken need exactly
|
||||
#: those.
|
||||
#: this type are skipped during 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.
|
||||
@@ -415,9 +476,14 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
|
||||
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()
|
||||
"""Trigger an inspection.
|
||||
|
||||
Nothing goes into ``possible_issue_ids`` here. An issue raised by this
|
||||
repair is keyed to its findings rather than to the entity they were
|
||||
found in, so a list of inspected entities no longer names anything
|
||||
that could be in the registry. What this repair left behind is read
|
||||
back out of the registry instead, which finds all of it.
|
||||
"""
|
||||
if self.domain not in (instances := self.hass.data.get(DATA_INSTANCES, {})):
|
||||
return
|
||||
|
||||
@@ -439,14 +505,19 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
|
||||
# after the round finished.
|
||||
entities = list(entity_component.entities)
|
||||
|
||||
# Collected first and reported afterwards, rather than an issue raised
|
||||
# the moment one is found. Describing a broken entity reference means
|
||||
# working out what it was probably meant to say, which is the most
|
||||
# expensive thing in the whole inspection, and gathering the round's
|
||||
# findings first lets all of that happen in one go. #1667.
|
||||
findings: list[tuple[Any, list[str]]] = []
|
||||
|
||||
for index, entity in enumerate(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)
|
||||
|
||||
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):
|
||||
@@ -459,10 +530,18 @@ class AbstractSpookEntityComponentUnknownReferencesRepair(AbstractSpookRepair, A
|
||||
if not unknown:
|
||||
continue
|
||||
|
||||
sorted_unknown = sorted(unknown)
|
||||
findings.append((entity, sorted(unknown)))
|
||||
|
||||
if self.references_are_entities and findings:
|
||||
await async_warm_rename_suggestions(
|
||||
self.hass,
|
||||
{reference for _, references in findings for reference in references},
|
||||
)
|
||||
|
||||
for entity, sorted_unknown in findings:
|
||||
self.async_create_issue(
|
||||
issue_id=entity.entity_id,
|
||||
references=sorted_unknown,
|
||||
translation_placeholders={
|
||||
self.reference_label: self._format_references(sorted_unknown),
|
||||
self.entity_label: entity.name,
|
||||
@@ -982,6 +1061,9 @@ class MinMaxUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
|
||||
data = self.data or {}
|
||||
return {
|
||||
"helper": str(data.get("helper", "")),
|
||||
"entity_id": async_name_helper_in_the_registry(
|
||||
self.hass, str(data.get(self._id_key, ""))
|
||||
),
|
||||
"sources": str(data.get("sources", "")),
|
||||
}
|
||||
|
||||
@@ -1040,6 +1122,9 @@ class HelperUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
|
||||
return {
|
||||
"helper": str(data.get("helper", "")),
|
||||
"domain": str(data.get("domain", "")),
|
||||
"entity_id": async_name_helper_in_the_registry(
|
||||
self.hass, str(data.get(self._id_key, ""))
|
||||
),
|
||||
"sources": str(data.get("sources", "")),
|
||||
"usage": self._usage_text(str(data.get("helper_config_entry_id", ""))),
|
||||
}
|
||||
@@ -1078,6 +1163,89 @@ class HelperUnknownSourcesFixFlow(_RemoveOrIgnoreFixFlow):
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
|
||||
class OrphanedStatisticsFixFlow(_RemoveOrIgnoreFixFlow):
|
||||
"""Handler for long-term statistics with no entity left behind them.
|
||||
|
||||
Clearing them is a websocket command the Statistics page calls and no
|
||||
action anybody can reach, so without this the only way to act on the
|
||||
report is to open that page and work through it by hand, which on a list
|
||||
of a couple of hundred is not really an offer at all. #1613.
|
||||
"""
|
||||
|
||||
_key = "statistics"
|
||||
_id_key = "orphaned_statistic_ids"
|
||||
|
||||
def _menu_placeholders(self) -> dict[str, str]:
|
||||
"""List what the report named, the way the report listed it."""
|
||||
return {"statistics": _bulleted(self._offered())}
|
||||
|
||||
def _offered(self) -> list[str]:
|
||||
"""Return the statistic IDs the report put in front of somebody."""
|
||||
written = str((self.data or {}).get(self._id_key, ""))
|
||||
return [statistic_id for statistic_id in written.split(",") if statistic_id]
|
||||
|
||||
async def async_step_remove(
|
||||
self,
|
||||
_: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Clear the statistics, after looking again to see if they still go.
|
||||
|
||||
Looked up again rather than taken from the issue, and then kept in
|
||||
common with it. An issue sits there until somebody opens it, which
|
||||
may be days, and this deletes history: nothing goes that was not on
|
||||
the list they read, and nothing goes that has come back since.
|
||||
|
||||
Asked the same way the report asked it, settling time and all. A
|
||||
glance would say yes to a sensor that came back long ago and happens
|
||||
to be between two brief windows right now, which is the case the
|
||||
wait exists for.
|
||||
"""
|
||||
offered = set(self._offered())
|
||||
if not offered:
|
||||
return self.async_abort(reason="nothing_to_clear")
|
||||
|
||||
still_gone = await async_settled_orphaned_statistic_ids(self.hass)
|
||||
clearing = sorted(offered & still_gone)
|
||||
if not clearing:
|
||||
# Nothing on the list still needs clearing. They may have an
|
||||
# entity behind them again, or somebody may have cleared them by
|
||||
# hand while the issue sat there. Either way there is nothing to
|
||||
# do, and which of the two it was is not worth guessing at.
|
||||
return self.async_abort(reason="nothing_to_clear")
|
||||
|
||||
# Queued rather than done: the recorder takes the work on its own
|
||||
# thread and says when it has landed. Answering before that would
|
||||
# close the issue on the strength of having asked, and a recorder
|
||||
# that is wedged would look like a job well done. Home Assistant's
|
||||
# own Statistics page waits on exactly this, for exactly this long.
|
||||
cleared = asyncio.Event()
|
||||
|
||||
def _done() -> None:
|
||||
"""Say so from the recorder's thread."""
|
||||
self.hass.loop.call_soon_threadsafe(cleared.set)
|
||||
|
||||
get_instance(self.hass).async_clear_statistics(clearing, on_done=_done)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(_CLEARING_TAKES_AT_MOST):
|
||||
await cleared.wait()
|
||||
except TimeoutError:
|
||||
# The work is still queued and will most likely land. Saying it
|
||||
# is done would be a guess, and leaving the issue up costs
|
||||
# nothing: the next round clears it if the statistics went, and
|
||||
# reports them again if they did not.
|
||||
LOGGER.debug(
|
||||
"Spook asked for %s to be cleared and the recorder has not "
|
||||
"said it is done",
|
||||
", ".join(clearing),
|
||||
)
|
||||
return self.async_abort(reason="clearing_took_too_long")
|
||||
|
||||
LOGGER.debug("Spook cleared orphaned statistics: %s", ", ".join(clearing))
|
||||
|
||||
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]] = {
|
||||
@@ -1090,6 +1258,7 @@ _REMOVE_OR_IGNORE_FLOWS: dict[str, type[_RemoveOrIgnoreFixFlow]] = {
|
||||
"group_entity_id": GroupUnknownMembersFixFlow,
|
||||
"min_max_config_entry_id": MinMaxUnknownSourcesFixFlow,
|
||||
"helper_config_entry_id": HelperUnknownSourcesFixFlow,
|
||||
"orphaned_statistic_ids": OrphanedStatisticsFixFlow,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Hast du die Readme gelesen? Hast du die Lizenz gelesen? Bist du dir ganz sicher?"
|
||||
"description": "Hast du die README gelesen? Hast du die Lizenz gelesen? Bist du dir wirklich sicher?"
|
||||
},
|
||||
"choice_restart": {
|
||||
"description": "Damit Spook die Einrichtung abschließen kann, muss Home Assistant neu gestartet werden.\n\nMöchtest du Home Assistant jetzt neu starten?",
|
||||
@@ -401,7 +401,7 @@
|
||||
}
|
||||
},
|
||||
"lovelace_missing_resources": {
|
||||
"description": "Spook hat einen Geist in deinen Dashboards gefunden 👻\n\nDieses Dashboard verweist auf Dateien, die die nicht mehr existieren:\n\n{resources}\n\n\n\nDies tritt in der Regel auf, wenn eine benutzerdefinierte Karte oder deren Dateien gelöscht wurden, die Ressource jedoch noch vorhanden ist. Um diesen Fehler zu beheben, öffnen Sie „Einstellungen“ > „Dashboards“ > „Ressourcen“ und entfernen oder korrigieren Sie diese Ressourcen.\n\nSpook 👻 Dein Kumpel.",
|
||||
"description": "Spook hat einen Geist in deinen Dashboards gefunden 👻\n\nDieses Dashboard verweist auf Dateien, die die nicht mehr existieren:\n\n{resources}\n\n\n\nDies passiert normalerweise, wenn eine benutzerdefinierte Karte oder deren Dateien entfernt wurden, die Ressource jedoch zurückgeblieben ist. Um diesen Fehler zu beheben, öffne die [Resources page](/config/lovelace/resources) und entferne oder korrigiere diese Ressourcen. Sie hat keinen eigenen Tab: Auf der Dashboards-Seite befindet sie sich oben rechts hinter den drei Punkten.\n\nSpook 👻 Dein Kumpel.",
|
||||
"title": "Die Ressourcen im Dashboard verweisen auf fehlende Dateien"
|
||||
},
|
||||
"lovelace_unknown_area_references": {
|
||||
@@ -483,7 +483,7 @@
|
||||
},
|
||||
"orphaned_statistics": {
|
||||
"title": "Verwaiste Langzeitstatistik gefunden",
|
||||
"description": "Spook hat einen Geist in deinem Recorder gefunden 👻\n\nHome Assistant bewahrt Langzeitstatistiken für Folgendes auf, aber es gibt keine passende Entität mehr dazu:\n\n{statistics}\n\n\n\nDas passiert meist, wenn ein Sensor entfernt wurde, während seine Statistiken erhalten blieben. Um diesen Fehler zu beheben, öffne Einstellungen > Werkzeuge > Statistiken und bringe sie dort in Ordnung.\n\nSpook 👻 Dein Kumpel."
|
||||
"description": "Spook hat einen Geist in deinem Recorder gefunden 👻\n\nHome Assistant speichert Langzeitstatistiken für Folgendes, aber es gibt dafür keine passende Entität mehr:\n\n{statistics}\n\n\n\nDies passiert normalerweise, wenn ein Sensor entfernt wurde, während seine Statistiken aufbewahrt wurden. Um diesen Fehler zu beheben, öffne Einstellungen > Werkzeuge > Statistiken und behebe ihn dort.\n\nSpook 👻 Dein Kumpel."
|
||||
},
|
||||
"person_unknown_device_trackers": {
|
||||
"fix_flow": {
|
||||
@@ -649,7 +649,7 @@
|
||||
},
|
||||
"homeassistant_delete_all_orphaned_entities": {
|
||||
"name": "Alle verwaisten Entitäten löschen",
|
||||
"description": "Löscht alle verwaisten Entitäten die keiner Integration mehr zugeordnet/bereitgestellt sind. Beachte, falls die Integration erst vor Kurzem gelöscht wurde, ist gegebenenfalls ein Neustart von Home Assistant nötigt, damit diese als verwaist angezeigt werden.\n**WARNUNG** Entitäten könnten auch als verwaist markiert sein, falls eine Integration offline ist oder nach einem Neustart von Home Assistant noch nicht funktioniert. Der Aufruf dieser Aktion löscht auch diese Entitäten."
|
||||
"description": "Löscht alle verwaisten Entitäten, für die keine Integration mehr zuständig ist (oder die von keiner mehr bereitgestellt werden). Bitte beachten: Falls die Integration gerade erst entfernt wurde, ist möglicherweise ein Neustart erforderlich, damit Home Assistant erkennt, dass sie verwaist sind.\n**WARNUNG** Entitäten könnten als verwaist markiert worden sein, weil eine Integration offline ist oder seit dem Start von Home Assistant nicht funktioniert. Das Ausführen dieser Aktion löscht auch diese Entitäten."
|
||||
},
|
||||
"homeassistant_create_area": {
|
||||
"fields": {
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Dashboard resource listed more than once: {resource}",
|
||||
"description": "Spook has found a ghost in your dashboards \ud83d\udc7b\n\nThis dashboard resource is listed {count} times:\n\n{resources}\n\nThis usually happens when a custom card was updated by adding a resource for the new version instead of editing the one already there. The browser loads both, the card tries to register itself twice, and the second attempt fails, which looks like a broken card rather than a duplicated resource.\n\nSpook can clear the extra copies and keep the most recently added one.\n\nSpook \ud83d\udc7b Your homie.",
|
||||
"description": "Spook has found a ghost in your dashboards 👻\n\nThis dashboard resource is listed {count} times:\n\n{resources}\n\nThis usually happens when a custom card was updated by adding a resource for the new version instead of editing the one already there. The browser loads both, the card tries to register itself twice, and the second attempt fails, which looks like a broken card rather than a duplicated resource.\n\nSpook can clear the extra copies and keep the most recently added one.\n\nSpook 👻 Your homie.",
|
||||
"menu_options": {
|
||||
"remove": "Clear the extra copies, keep the most recent",
|
||||
"ignore": "Leave them, stop telling me",
|
||||
@@ -644,7 +644,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Unknown members used in min/max helper: {helper}",
|
||||
"description": "Spook has found a ghost in your helpers 👻\n\nThe min/max helper {helper} combines the following members, which are unknown to Home Assistant:\n\n{sources}\n\nRemove them so the helper carries on with the members that remain, or keep them and Spook will stop pointing them out.\n\nSpook 👻 Your homie.",
|
||||
"description": "Spook has found a ghost in your helpers 👻\n\nThe min/max helper {helper} (`{entity_id}`) combines the following members, which are unknown to Home Assistant:\n\n{sources}\n\nRemove them so the helper carries on with the members that remain, or keep them and Spook will stop pointing them out.\n\nSpook 👻 Your homie.",
|
||||
"menu_options": {
|
||||
"remove": "Remove these members",
|
||||
"manage": "Let me fix it myself",
|
||||
@@ -664,8 +664,26 @@
|
||||
"title": "Unknown members in notify group: {group}"
|
||||
},
|
||||
"orphaned_statistics": {
|
||||
"description": "Spook has found a ghost in your recorder 👻\n\nHome Assistant is keeping long-term statistics for the following, but there is no matching entity for them anymore:\n\n{statistics}\n\n\n\nThis usually happens when a sensor was removed while its statistics were kept. To fix this error, open Settings > Tools > Statistics and fix them there.\n\nSpook 👻 Your homie.",
|
||||
"title": "Orphaned long-term statistics found"
|
||||
"title": "Orphaned long-term statistics found",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Orphaned long-term statistics found",
|
||||
"description": "Spook has found a ghost in your recorder 👻\n\nHome Assistant is keeping long-term statistics for the following, but there is no matching entity for them anymore:\n\n{statistics}\n\nThis usually happens when a sensor was removed while its statistics were kept. Clearing them throws that history away for good, so Spook looks again before it does, and leaves alone anything that has come back in the meantime.\n\nSpook 👻 Your homie.",
|
||||
"menu_options": {
|
||||
"remove": "Clear these statistics",
|
||||
"manage": "Let me do it myself",
|
||||
"ignore": "Keep them, stop telling me"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"issue_ignored": "Spook will keep quiet about these statistics from now on.",
|
||||
"manage": "You can clear these yourself on the [Statistics page](/config/tools/statistics).",
|
||||
"nothing_to_clear": "Nothing on that list still needs clearing, so nothing has been thrown away. Either the statistics have an entity behind them again, or they are already gone.",
|
||||
"clearing_took_too_long": "Spook asked for the statistics to be cleared and the recorder has not said it is done. The work is queued and will most likely land, so this repair stays up until Spook has looked again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"person_unknown_device_trackers": {
|
||||
"title": "Unknown device trackers tracked by: {person}",
|
||||
@@ -748,11 +766,11 @@
|
||||
"title": "Unknown triggers used in: {script}"
|
||||
},
|
||||
"template_unknown_entity_references": {
|
||||
"description": "Spook has found a ghost in your template helpers 👻\n\nWhile floating around, Spook crossed path with the following template helper:\n\n[{helper}]({edit})\n\nIts configuration references the following entities, which are unknown to Home Assistant:\n\n{entities}\n\n\n\nThis often happens when an entity was renamed or removed. To fix this error, reconfigure the template helper to reference existing entities.\n\nSpook 👻 Your homie.",
|
||||
"description": "Spook has found a ghost in your template helpers 👻\n\nWhile floating around, Spook crossed path with the following template helper:\n\n[{helper}]({edit}) (`{entity_id}`)\n\nIts configuration references the following entities, which are unknown to Home Assistant:\n\n{entities}\n\n\n\nThis often happens when an entity was renamed or removed. To fix this error, reconfigure the template helper to reference existing entities.\n\nSpook 👻 Your homie.",
|
||||
"title": "Unknown entities used in template helper: {helper}"
|
||||
},
|
||||
"template_unknown_service_references": {
|
||||
"description": "Spook has found a ghost in your template helpers \ud83d\udc7b\n\nWhile floating around, Spook crossed path with the following template helper:\n\n[{helper}]({edit})\n\nIt references the following actions, which are unknown to Home Assistant:\n\n{services}\n\n\n\nThis often happens when an action provider was removed, or a script was renamed or deleted. To fix this error, reconfigure the template helper to use existing actions.\n\nSpook \ud83d\udc7b Your homie.",
|
||||
"description": "Spook has found a ghost in your template helpers 👻\n\nWhile floating around, Spook crossed path with the following template helper:\n\n[{helper}]({edit}) (`{entity_id}`)\n\nIt references the following actions, which are unknown to Home Assistant:\n\n{services}\n\n\n\nThis often happens when an action provider was removed, or a script was renamed or deleted. To fix this error, reconfigure the template helper to use existing actions.\n\nSpook 👻 Your homie.",
|
||||
"title": "Unknown actions used in template helper: {helper}"
|
||||
},
|
||||
"unknown_customized_entities": {
|
||||
@@ -765,7 +783,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Unknown source entities used in helper: {helper}",
|
||||
"description": "Spook has found a ghost in your helpers 👻\n\nThe helper {helper} (`{domain}`) references the following source entities, which are unknown to Home Assistant:\n\n{sources}\n\nWithout its sources this helper cannot work. {usage}\n\nRemove the helper, or keep it and Spook will stop pointing it out.\n\nSpook 👻 Your homie.",
|
||||
"description": "Spook has found a ghost in your helpers 👻\n\nThe helper {helper} (`{domain}`, `{entity_id}`) references the following source entities, which are unknown to Home Assistant:\n\n{sources}\n\nWithout its sources this helper cannot work. {usage}\n\nRemove the helper, or keep it and Spook will stop pointing it out.\n\nSpook 👻 Your homie.",
|
||||
"menu_options": {
|
||||
"remove": "Remove this helper",
|
||||
"manage": "Let me fix it myself",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Da li ste pročitali README? Da li ste pročitali licencu? Da li ste stvarno sigurni?"
|
||||
"description": "Da li ste pročitali readme? Da li ste pročitali licencu? Da li ste stvarno sigurni?"
|
||||
},
|
||||
"choice_restart": {
|
||||
"description": "Da bi Spook završio podešavanje, Home Assistant mora biti restartovan.\n\nDa li želite da restartujete Home Assistant sada?",
|
||||
@@ -1220,7 +1220,7 @@
|
||||
"description": "ID prostorije sa koje treba ukloniti sprat."
|
||||
}
|
||||
},
|
||||
"name": "Ukloni pseudonim iz prostorije",
|
||||
"name": "Ukloni prostoriju sa sprata",
|
||||
"description": "Uklanjanje prostorije sa sprata. Pošto prostorija može biti samo na jednom spratu, ovaj poziv ne mora da navodi sprat."
|
||||
},
|
||||
"input_number_decrement": {
|
||||
|
||||
Reference in New Issue
Block a user