Files
HomeAssistantVS/custom_components/spook/ectoplasms/lovelace/repairs/unknown_entity_references.py
T

197 lines
7.5 KiB
Python

"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.lovelace import DOMAIN
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
)
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from ....const import LOGGER
from ....dashboard_extraction import (
extract_entities_from_dashboard_node,
extract_not_entity_ids_from_dashboard_node,
)
from ....entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
from ....entity_suggestions import async_describe_unknown_entities
from ....repairs import AbstractSpookRepair
from ..dashboards import async_dashboard_configs
if TYPE_CHECKING:
from collections.abc import Iterable
from homeassistant.components.lovelace.dashboard import (
LovelaceStorage,
LovelaceYAML,
)
from homeassistant.core import HomeAssistant
@callback
def _async_miscased_entity_ids(
hass: HomeAssistant, entity_ids: Iterable[str]
) -> set[str]:
"""Return the entity IDs written with capitals, exactly as written.
The frontend looks an entity up exactly as the card names it, and every
entity Home Assistant has is lower case. So `light.Kitchen` is never
found, not even when `light.kitchen` exists, and the card shows it as
unavailable. Reported as written, so the repair shows what was typed.
Only one that is an entity ID once lower cased counts, and it passes the
same checks as any other; anything else was never meant as one.
"""
written_as: dict[str, set[str]] = {}
for entity_id in entity_ids:
if (lower_cased := entity_id.lower()) != entity_id:
written_as.setdefault(lower_cased, set()).add(entity_id)
# Nothing counts as known here: whether the lower case one exists or
# not, the card names something that does not.
return {
entity_id
for lower_cased in async_filter_known_entity_ids(
hass, entity_ids=written_as, known_entity_ids=set()
)
for entity_id in written_as[lower_cased]
}
async def _async_describe(
hass: HomeAssistant, unknown_entities: set[str], not_entity_ids: set[str]
) -> str:
"""Return the issue's list of entities, saying which are no entity ID.
No guess at what one of those was meant to be. `cover.blind.position` may
have meant the cover, its position or something else entirely, and a
wrong suggestion is worse than none.
"""
lines = [
await async_describe_unknown_entities(hass, sorted(unknown_entities)),
*(f"- `{value}` (not an entity ID)" for value in sorted(not_entity_ids)),
]
return "\n".join(line for line in lines if line)
def _first_view_with(
config: Any, unknown_entities: set[str], not_entity_ids: set[str]
) -> int | str:
"""Return the path of the first view holding something reported.
Each view is asked with the walk that reported it: the same malformed
value may sit on a custom card on an earlier view, which only the entity
walk reads, and that view would be the wrong one to open.
"""
views = config.get("views") if isinstance(config, dict) else None
for view_index, view in enumerate(views if isinstance(views, list) else []):
if not isinstance(view, dict):
continue
if unknown_entities & extract_entities_from_dashboard_node(
view
) or not_entity_ids & extract_not_entity_ids_from_dashboard_node(view):
return view.get("path") or view_index
# A strategy dashboard has no views stored; its views open from the first.
return 0
class SpookRepair(AbstractSpookRepair):
"""Spook repair tries to find unknown referenced entity in dashboards."""
domain = DOMAIN
repair = "lovelace_unknown_entity_references"
inspect_events = {
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
er.EVENT_ENTITY_REGISTRY_UPDATED,
}
inspect_config_entry_changed = True
inspect_on_reload = True
inspect_on_entity_added_or_removed = True
automatically_clean_up_issues = True
_dashboards: dict[str | None, LovelaceStorage | LovelaceYAML]
async def async_activate(self) -> None:
"""Handle the activating a repair."""
self._dashboards = self.hass.data["lovelace"].dashboards
await super().async_activate()
async def async_inspect(self) -> None:
"""Trigger a inspection."""
LOGGER.debug("Spook is inspecting: %s", self.repair)
known_entity_ids = async_get_all_entity_ids(self.hass, include_all_none=True)
# Loop over all dashboards and check if there are unknown entities
# referenced in the dashboards.
async for dashboard, url_path, config in async_dashboard_configs(
self._dashboards
):
self.possible_issue_ids.add(url_path)
if config is None:
continue
extracted_entities = self.__async_extract_entities(config)
not_entity_ids = extract_not_entity_ids_from_dashboard_node(config)
unknown_entities = async_filter_known_entity_ids(
self.hass,
entity_ids=set(extracted_entities.keys()),
known_entity_ids=known_entity_ids,
) | _async_miscased_entity_ids(self.hass, extracted_entities)
if unknown_entities or not_entity_ids:
first_view_path = _first_view_with(
config, unknown_entities, not_entity_ids
)
title = "Overview"
if dashboard.config:
title = dashboard.config.get("title", url_path)
self.async_create_issue(
issue_id=url_path,
references=unknown_entities | not_entity_ids,
translation_placeholders={
"entities": await _async_describe(
self.hass, unknown_entities, not_entity_ids
),
"dashboard": title,
"edit": f"/{url_path}/{first_view_path}?edit=1",
},
)
LOGGER.debug(
(
"Spook found unknown entities in dashboard %s "
"and created an issue for it; Entities: %s"
),
title,
", ".join(unknown_entities | not_entity_ids),
)
@callback
def __async_extract_entities(self, config: dict[str, Any]) -> dict[str, int | str]:
"""Extract entities from a dashboard config, keyed by their view path."""
entities: dict[str, int | str] = {}
if isinstance(config, dict) and (views := config.get("views")):
for view_index, view in enumerate(views):
if not isinstance(view, dict):
continue
view_path: int | str = view.get("path") or view_index
for entity_id in extract_entities_from_dashboard_node(view):
entities.setdefault(entity_id, view_path)
# A dashboard run by a strategy, like the areas dashboard, has no views
# stored at all: only the strategy and its options. The views it makes
# are opened from the first one.
if isinstance(config, dict) and isinstance(
strategy := config.get("strategy"), dict
):
for entity_id in extract_entities_from_dashboard_node(strategy):
entities.setdefault(entity_id, 0)
return entities