374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""Spook - Your homie."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from homeassistant.components.lovelace import DOMAIN
|
|
from homeassistant.components.lovelace.const import ConfigNotFound
|
|
from homeassistant.const import (
|
|
EVENT_COMPONENT_LOADED,
|
|
EVENT_LOVELACE_UPDATED,
|
|
EVENT_STATE_CHANGED,
|
|
)
|
|
from homeassistant.core import Event, callback
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from ....const import LOGGER
|
|
from ....entity_filtering import (
|
|
async_filter_known_entity_ids,
|
|
async_get_all_entity_ids,
|
|
split_comma_separated_entity_ids,
|
|
)
|
|
from ....repairs import AbstractSpookRepair
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
|
|
from homeassistant.components.lovelace.dashboard import (
|
|
LovelaceStorage,
|
|
LovelaceYAML,
|
|
)
|
|
|
|
|
|
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
|
|
automatically_clean_up_issues = True
|
|
|
|
_dashboards: dict[str, LovelaceStorage | LovelaceYAML]
|
|
|
|
async def async_activate(self) -> None:
|
|
"""Handle the activating a repair."""
|
|
self._dashboards = self.hass.data["lovelace"].dashboards
|
|
await super().async_activate()
|
|
|
|
@callback
|
|
def _state_entity_changed(event_data: Mapping[str, Any]) -> bool:
|
|
"""Return if a state entity was added or removed."""
|
|
return (
|
|
event_data.get("old_state") is None
|
|
or event_data.get("new_state") is None
|
|
)
|
|
|
|
@callback
|
|
def _async_call_inspect_debouncer(_: Event) -> None:
|
|
"""Trigger an inspection when a state entity is added or removed."""
|
|
self.inspect_debouncer.async_schedule_call()
|
|
|
|
self._event_subs.add(
|
|
self.hass.bus.async_listen(
|
|
EVENT_STATE_CHANGED,
|
|
_async_call_inspect_debouncer,
|
|
event_filter=_state_entity_changed,
|
|
),
|
|
)
|
|
|
|
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.
|
|
for dashboard in self._dashboards.values():
|
|
url_path = dashboard.url_path or "lovelace"
|
|
self.possible_issue_ids.add(url_path)
|
|
try:
|
|
config = await dashboard.async_load(force=False)
|
|
except ConfigNotFound:
|
|
LOGGER.debug("Config for dashboard %s not found, skipping", url_path)
|
|
continue
|
|
|
|
extracted_entities = self.__async_extract_entities(config)
|
|
if unknown_entities := async_filter_known_entity_ids(
|
|
self.hass,
|
|
entity_ids=set(extracted_entities.keys()),
|
|
known_entity_ids=known_entity_ids,
|
|
):
|
|
# Get the view path of the first unknown entity (by view order)
|
|
first_view_path = next(
|
|
path
|
|
for entity_id, path in extracted_entities.items()
|
|
if entity_id in unknown_entities
|
|
)
|
|
title = "Overview"
|
|
if dashboard.config:
|
|
title = dashboard.config.get("title", url_path)
|
|
self.async_create_issue(
|
|
issue_id=url_path,
|
|
translation_placeholders={
|
|
"entities": "\n".join(
|
|
f"- `{entity_id}`" for entity_id in sorted(unknown_entities)
|
|
),
|
|
"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),
|
|
)
|
|
|
|
@callback
|
|
def __async_extract_entities(self, config: dict[str, Any]) -> dict[str, int | str]:
|
|
"""Extract entities from a dashboard config."""
|
|
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_raw in self.__async_extract_entities_from_view(view):
|
|
for entity_id in split_comma_separated_entity_ids(entity_id_raw):
|
|
if entity_id not in entities:
|
|
entities[entity_id] = view_path
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_view(self, config: dict[Any]) -> set[str]:
|
|
"""Extract entities from a view config."""
|
|
entities = set()
|
|
if badges := config.get("badges"):
|
|
for badge in badges:
|
|
entities.update(self.__async_extract_entities_from_badge(badge))
|
|
if cards := config.get("cards"):
|
|
for card in cards:
|
|
entities.update(self.__async_extract_entities_from_card(card))
|
|
if sections := config.get("sections"):
|
|
for section in sections:
|
|
entities.update(self.__async_extract_entities_from_section(section))
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_section(self, config: dict[Any]) -> set[str]:
|
|
"""Extract entities from a section config."""
|
|
entities = set()
|
|
if cards := config.get("cards"):
|
|
for card in cards:
|
|
entities.update(self.__async_extract_entities_from_card(card))
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_common(self, config: dict[str, Any] | str) -> set[str]:
|
|
"""Extract entities from common dashboard config."""
|
|
entities: set[str] = set()
|
|
|
|
if not isinstance(config, dict):
|
|
return entities
|
|
|
|
for key in ("camera_image", "entity", "entities", "entity_id"):
|
|
if entity := config.get(key):
|
|
if isinstance(entity, str):
|
|
entities.add(entity)
|
|
if isinstance(entity, list):
|
|
for item in entity:
|
|
if isinstance(item, str):
|
|
entities.add(item)
|
|
elif (
|
|
isinstance(item, dict)
|
|
and "entity" in item
|
|
and isinstance(item["entity"], str)
|
|
):
|
|
entities.add(item["entity"])
|
|
if (
|
|
isinstance(entity, dict)
|
|
and "entity" in entity
|
|
and isinstance(entity["entity"], str)
|
|
):
|
|
entities.add(entity["entity"])
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_badge(
|
|
self,
|
|
config: dict[str, Any] | str,
|
|
) -> set[str]:
|
|
"""Extract entities from a dashboard badge config."""
|
|
if isinstance(config, str):
|
|
return {config}
|
|
if isinstance(config, dict):
|
|
if (entity_id := config.get("entity")) and isinstance(entity_id, str):
|
|
return {config["entity"]}
|
|
if (entities := config.get("entities")) and isinstance(entities, list):
|
|
extracted = []
|
|
for entity in entities:
|
|
if isinstance(entity, str):
|
|
extracted.append(entity)
|
|
elif (
|
|
isinstance(entity, dict)
|
|
and "entity" in entity
|
|
and isinstance(entity["entity"], str)
|
|
):
|
|
extracted.append(entity["entity"])
|
|
return set(extracted)
|
|
return set()
|
|
|
|
@callback
|
|
# pylint: disable-next=too-many-branches
|
|
def __async_extract_entities_from_card( # noqa: C901, PLR0912
|
|
self,
|
|
config: dict[str, Any],
|
|
) -> set[str]:
|
|
"""Extract entities from a dashboard card config."""
|
|
if not isinstance(config, dict):
|
|
return set()
|
|
|
|
entities = self.__async_extract_common(config)
|
|
entities.update(self.__async_extract_entities_from_actions(config))
|
|
|
|
if condition := config.get("condition"):
|
|
entities.update(
|
|
self.__async_extract_entities_from_condition(condition),
|
|
)
|
|
|
|
if card := config.get("card"):
|
|
entities.update(self.__async_extract_entities_from_card(card))
|
|
|
|
if cards := config.get("cards"):
|
|
for card in cards:
|
|
entities.update(self.__async_extract_entities_from_card(card))
|
|
|
|
for key in ("header", "footer"):
|
|
if header_footer := config.get(key):
|
|
entities.update(
|
|
self.__async_extract_entities_from_header_footer(header_footer),
|
|
)
|
|
|
|
if elements := config.get("elements"):
|
|
for element in elements:
|
|
entities.update(self.__async_extract_entities_from_element(element))
|
|
|
|
# Mushroom
|
|
if chips := config.get("chips"):
|
|
for chip in chips:
|
|
entities.update(self.__async_extract_entities_from_mushroom_chip(chip))
|
|
|
|
# Heading card
|
|
if badges := config.get("badges"):
|
|
for badge in badges:
|
|
if isinstance(badge, dict):
|
|
entities.update(self.__async_extract_common(badge))
|
|
entities.update(self.__async_extract_entities_from_actions(badge))
|
|
|
|
visibility = config.get("visibility")
|
|
if isinstance(visibility, list):
|
|
for condition in visibility:
|
|
if isinstance(condition, dict):
|
|
entities.update(
|
|
self.__async_extract_entities_from_condition(condition)
|
|
)
|
|
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_actions(self, config: dict[str, Any]) -> set[str]:
|
|
"""Extract entities from a dashboard config containing actions."""
|
|
entities = set()
|
|
for key in (
|
|
"tap_action",
|
|
"hold_action",
|
|
"double_tap_action",
|
|
"subtitle_tap_action",
|
|
):
|
|
if isinstance(config, dict) and (action := config.get(key)):
|
|
entities.update(self.__async_extract_entities_from_action(action))
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_action(self, config: dict[str, Any]) -> set[str]:
|
|
"""Extract entities from a dashboard action config."""
|
|
entities = set()
|
|
for key in ("service_data", "target"):
|
|
if (
|
|
isinstance(config, dict)
|
|
and (target := config.get(key))
|
|
and isinstance(target, dict)
|
|
and (entity_id := target.get("entity_id"))
|
|
):
|
|
if isinstance(entity_id, str):
|
|
entities.add(entity_id)
|
|
if isinstance(entity_id, list):
|
|
entities.update(entity_id)
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_condition(
|
|
self,
|
|
config: dict[str, Any],
|
|
) -> set[str]:
|
|
"""Extract entities from a dashboard element config."""
|
|
if "entity" in config and isinstance(config["entity"], str):
|
|
return {config["entity"]}
|
|
return set()
|
|
|
|
@callback
|
|
def __async_extract_entities_from_element(self, config: dict[str, Any]) -> set[str]:
|
|
"""Extract entities from a dashboard element config."""
|
|
if not isinstance(config, dict):
|
|
return set()
|
|
|
|
entities = self.__async_extract_common(config)
|
|
entities.update(self.__async_extract_entities_from_actions(config))
|
|
entities.update(self.__async_extract_entities_from_action(config))
|
|
|
|
if conditions := config.get("conditions"):
|
|
for condition in conditions:
|
|
entities.update(self.__async_extract_entities_from_condition(condition))
|
|
|
|
if elements := config.get("elements"):
|
|
for element in elements:
|
|
entities.update(self.__async_extract_entities_from_element(element))
|
|
|
|
visibility = config.get("visibility")
|
|
if isinstance(visibility, list):
|
|
for condition in visibility:
|
|
if isinstance(condition, dict):
|
|
entities.update(
|
|
self.__async_extract_entities_from_condition(condition)
|
|
)
|
|
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_header_footer(
|
|
self,
|
|
config: dict[str, Any],
|
|
) -> set[str]:
|
|
"""Extract entities from a dashboard haeder footer config."""
|
|
entities = self.__async_extract_common(config)
|
|
entities.update(self.__async_extract_entities_from_actions(config))
|
|
return entities
|
|
|
|
@callback
|
|
def __async_extract_entities_from_mushroom_chip(
|
|
self,
|
|
config: dict[str, Any],
|
|
) -> set[str]:
|
|
"""Extract entities from mushroom chips."""
|
|
if not isinstance(config, dict):
|
|
return set()
|
|
entities = self.__async_extract_common(config)
|
|
if chip := config.get("chip"):
|
|
entities.update(
|
|
self.__async_extract_entities_from_mushroom_chip(chip),
|
|
)
|
|
if conditions := config.get("conditions"):
|
|
for condition in conditions:
|
|
entities.update(self.__async_extract_entities_from_condition(condition))
|
|
return entities
|