Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions custom_components/spook/dashboard_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,196 @@ def extract_icons_from_dashboard_node(node: Any) -> set[str]:
return icons


# Whatever a template or a card's own variables turn into is not known until
# they run. Jinja (a comment too), button-card's `[[[ ]]]`, decluttering-card's
# `[[ ]]` and config-template-card's `${ }`. Only the opening is asked for: a
# half-written one is nothing to judge either.
TEMPLATE_MARKERS = ("{{", "{%", "{#", "[[", "${")

# The core cards that put on the theme their `theme` names, each with its own
# call to `applyThemesOnElement` in `src/panels/lovelace/cards`. The entity
# button and the sensor card are the button and the entity card under another
# name, the shopping list is the to-do list. Every other card leaves the key
# alone, so a name there that is gone changes nothing.
_CARDS_WITH_THEME = frozenset(
{
"alarm-panel",
"button",
"calendar",
"entities",
"entity",
"entity-button",
"gauge",
"glance",
"humidifier",
"light",
"logbook",
"markdown",
"media-control",
"picture",
"picture-elements",
"picture-entity",
"picture-glance",
"plant-status",
"sensor",
"shopping-list",
"statistic",
"thermostat",
"todo-list",
"weather-forecast",
}
)

# The core cards that put the cards in their `cards` on the dashboard. Any
# other card leaves such a key alone.
_CARDS_WITH_CARDS = frozenset({"grid", "horizontal-stack", "vertical-stack"})

# The conditional card puts the one card in its `card` on the dashboard.
_CONDITIONAL_CARD = "conditional"

# The entity filter card hands its rows to an entities card, unless its own
# `card` names another type.
_ENTITY_FILTER_CARD = "entity-filter"
_ENTITY_FILTER_DEFAULT_CARD = "entities"

# The two parts of a sections view that each hold a card.
_VIEW_PARTS_WITH_A_CARD = ("header", "footer")

# The view layouts, as the frontend's `getViewType` picks them. The cards of a
# sections view are only shown while editing, to be moved into a section.
_MASONRY_VIEW = "masonry"
_VIEWS_WITH_CARDS = frozenset({_MASONRY_VIEW, "sidebar"})
_PANEL_VIEW = "panel"
_SECTIONS_VIEW = "sections"

# The one section layout the frontend draws. A section without a type gets it,
# any other core type turns into an error card that shows none of its content.
_GRID_SECTION = "grid"


def _view_type(view: dict[str, Any]) -> Any:
"""Return the layout the frontend picks for a view.

Its `getViewType` asks JavaScript whether each key is set, and to
JavaScript an empty list is: `sections: []` makes a sections view.
"""
if (view_type := view.get("type")) not in _JAVASCRIPT_FALSY:
return view_type
if view.get("panel") not in _JAVASCRIPT_FALSY:
return _PANEL_VIEW
if view.get("sections") not in _JAVASCRIPT_FALSY:
return _SECTIONS_VIEW
if view.get("cards") not in _JAVASCRIPT_FALSY:
return _MASONRY_VIEW
return _SECTIONS_VIEW


def _collect_theme(node: dict[str, Any], themes: set[str]) -> None:
"""Collect the theme a dashboard node names, if it names one.

The frontend looks the name up exactly as written, spaces and all. Only
an empty one is no theme at all, and a template is no name until it runs.
"""
if (
isinstance(theme := node.get("theme"), str)
and theme
and not any(marker in theme for marker in TEMPLATE_MARKERS)
):
themes.add(theme)


def _collect_card_themes(card: Any, themes: set[str]) -> None:
"""Collect the themes a core card, and the cards in it, put on."""
if not isinstance(card, dict) or not isinstance(card_type := card.get("type"), str):
return

# What a custom card does with a `theme`, or with the cards it holds, is
# its own business.
if card_type.startswith(_CUSTOM_PREFIX):
return

if card_type in _CARDS_WITH_THEME:
_collect_theme(card, themes)

# The stacks and the grid hold cards, the conditional and the entity
# filter card hold one.
if card_type in _CARDS_WITH_CARDS:
_collect_cards_themes(card.get("cards"), themes)
elif card_type == _CONDITIONAL_CARD:
_collect_card_themes(card.get("card"), themes)
elif card_type == _ENTITY_FILTER_CARD and isinstance(
inner_card := card.get("card"), dict
):
_collect_card_themes(
{"type": _ENTITY_FILTER_DEFAULT_CARD, **inner_card}, themes
)


def _collect_cards_themes(cards: Any, themes: set[str]) -> None:
"""Collect the themes a list of cards puts on."""
if not isinstance(cards, list):
return

for card in cards:
_collect_card_themes(card, themes)


def _collect_section_themes(sections: Any, themes: set[str]) -> None:
"""Collect the themes a list of sections, and what is in them, put on.

The section wrapper puts on a section's theme whatever its type, so a
custom section's own theme counts, and the cards and sections in it are
left to it. Only the grid layout draws what is in it. A strategy section
makes its config when it opens, so none of it is known up front.
"""
if not isinstance(sections, list):
return

for section in sections:
if not isinstance(section, dict) or "strategy" in section:
continue

_collect_theme(section, themes)
if (section.get("type") or _GRID_SECTION) == _GRID_SECTION:
_collect_cards_themes(section.get("cards"), themes)
Comment thread
frenck marked this conversation as resolved.
_collect_section_themes(section.get("sections"), themes)


def extract_themes_from_dashboard_view(view: Any) -> set[str]:
"""Return the themes a view, its sections and its core cards put on.

The view container puts on a view's theme whatever its type, so a custom
view's own theme counts, and what is in it does not. A strategy view makes
its config when it opens, so none of it is known up front.
"""
themes: set[str] = set()
if not isinstance(view, dict) or "strategy" in view:
return themes

_collect_theme(view, themes)
if _is_custom(view):
return themes

# Each layout only draws its own part of the config. Whatever another
# layout would use is left lying around and changes nothing.
view_type = _view_type(view)
if view_type in _VIEWS_WITH_CARDS:
_collect_cards_themes(view.get("cards"), themes)
elif view_type == _PANEL_VIEW and isinstance(cards := view.get("cards"), list):
# A panel view shows its first card, and only warns about the rest.
_collect_cards_themes(cards[:1], themes)
elif view_type == _SECTIONS_VIEW:
_collect_section_themes(view.get("sections"), themes)
if isinstance(sidebar := view.get("sidebar"), dict):
_collect_section_themes(sidebar.get("sections"), themes)

for part in _VIEW_PARTS_WITH_A_CARD:
if isinstance(holder := view.get(part), dict):
_collect_card_themes(holder.get("card"), themes)

return themes


# What `Number()` and `trim()` in JavaScript take for whitespace: tab, line
# and form feeds, the byte order mark, every space separator and the line
# terminators. Python's own `\s` and `strip()` take a few more, like the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Spook - Your homie."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from homeassistant.components.frontend import DATA_THEMES, DEFAULT_THEME, VALUE_NO_THEME
from homeassistant.components.lovelace import DOMAIN
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
EVENT_THEMES_UPDATED,
)
from homeassistant.core import callback

from ....const import LOGGER
from ....dashboard_extraction import extract_themes_from_dashboard_view
from ....repairs import AbstractSpookRepair
from ..dashboards import async_dashboard_configs

if TYPE_CHECKING:
from homeassistant.components.lovelace.dashboard import (
LovelaceStorage,
LovelaceYAML,
)

# The frontend's own look, built in and never in the list of themes, and the
# name Home Assistant itself uses for no theme at all.
_BUILT_IN_THEMES = frozenset({DEFAULT_THEME, VALUE_NO_THEME})


class SpookRepair(AbstractSpookRepair):
"""Spook repair tries to find themes in dashboards that do not exist.

The frontend only puts on a theme it has. A card or view naming one that
was renamed or removed gets the default look, and nothing says why.
"""

domain = DOMAIN
repair = "lovelace_unknown_theme_references"
inspect_events = {
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
EVENT_THEMES_UPDATED,
}
inspect_config_entry_changed = True
inspect_on_reload = 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)

# Without the frontend there is no list of themes to hold anything
# against, and nothing that puts one on either. Home Assistant sets it
# up before anything else, so this is a guard, not a waiting room.
if (known_themes := self.hass.data.get(DATA_THEMES)) is None:
return

async for dashboard, url_path, config in async_dashboard_configs(
self._dashboards
):
self.possible_issue_ids.add(url_path)
if config is None:
continue

themes = self.__async_extract_themes(config)
unknown_themes = {
theme
for theme in themes
if theme not in known_themes and theme not in _BUILT_IN_THEMES
}
if not unknown_themes:
continue

first_view_path = next(
view_path
for theme, view_path in themes.items()
if theme in unknown_themes
)
title = "Overview"
if dashboard.config:
title = dashboard.config.get("title", url_path)
self.async_create_issue(
issue_id=url_path,
references=unknown_themes,
translation_placeholders={
"themes": "\n".join(
f"- `{theme}`" for theme in sorted(unknown_themes)
),
"dashboard": title,
"edit": f"/{url_path}/{first_view_path}?edit=1",
},
)

@callback
def __async_extract_themes(self, config: Any) -> dict[str, int | str]:
"""Extract the themes a dashboard puts on, keyed by their view path.

A dashboard run by a strategy has no views stored, only the strategy,
and the views it makes are not known until it opens.
"""
themes: dict[str, int | str] = {}
if (
not isinstance(config, dict)
or "strategy" in config
or not isinstance(views := config.get("views"), list)
):
return themes

for view_index, view in enumerate(views):
if not isinstance(view, dict):
continue

view_path: int | str = view.get("path") or view_index
for theme in sorted(extract_themes_from_dashboard_view(view)):
themes.setdefault(theme, view_path)

return themes
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from ....const import LOGGER
from ....dashboard_extraction import (
TEMPLATE_MARKERS,
extract_navigation_paths_from_dashboard_node,
javascript_number,
)
Expand All @@ -33,12 +34,6 @@
# before it looks for a view by that name.
_UNUSED_ENTITIES_VIEW = "hass-unused-entities"

# Whatever a template or a card's own variables turn into is not known until
# they run. Jinja (a comment too), button-card's `[[[ ]]]`, decluttering-card's
# `[[ ]]` and config-template-card's `${ }`. Only the opening is asked for: a
# half-written one is no path to judge either.
_TEMPLATE_MARKERS = ("{{", "{%", "{#", "[[", "${")

# The browser drops these from a URL, or reads them as a slash, before the
# frontend gets to see the path. What it ends up as is the browser's call.
_REWRITTEN_BY_THE_BROWSER = ("\t", "\n", "\r", "\\")
Expand All @@ -54,7 +49,7 @@ def _dashboard_and_view(path: str) -> tuple[str, str] | None:
if (
not path.startswith("/")
or path != path.strip()
or any(marker in path for marker in _TEMPLATE_MARKERS)
or any(marker in path for marker in TEMPLATE_MARKERS)
or any(character in path for character in _REWRITTEN_BY_THE_BROWSER)
):
return None
Expand Down
1 change: 1 addition & 0 deletions custom_components/spook/repair_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"lovelace_unknown_entity_references": "lovelace#unknown-referenced-entities",
"lovelace_unknown_icons": "lovelace#unknown-icons",
"lovelace_unknown_service_references": "lovelace#unknown-actions",
"lovelace_unknown_theme_references": "lovelace#unknown-themes",
"lovelace_unknown_user_references": "lovelace#removed-users",
"lovelace_unknown_view_references": "lovelace#unknown-views",
"lovelace_unknown_state_references": "lovelace#unknown-referenced-states",
Expand Down
4 changes: 4 additions & 0 deletions custom_components/spook/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,10 @@
"description": "Spook has found a ghost in your dashboards 👻\n\nWhile floating around, Spook crossed path with the following dashboard:\n\n[{dashboard}]({edit})\n\nThis dashboard navigates to the following views, which do not exist:\n\n{paths}\n\n\n\nA button or card navigating to one of these opens the first view of that dashboard instead, without saying so. This often happens when a view was renamed, moved or removed. To fix this error, [edit the dashboard]({edit}) and point these to a view that exists.\n\nSpook 👻 Your homie.",
"title": "Unknown views navigated to in dashboard: {dashboard}"
},
"lovelace_unknown_theme_references": {
"description": "Spook has found a ghost in your dashboards 👻\n\nWhile floating around, Spook crossed path with the following dashboard:\n\n[{dashboard}]({edit})\n\nThis dashboard uses the following themes, which do not exist:\n\n{themes}\n\n\n\nA card, section or view with one of these themes gets the default look instead, without saying so. This often happens when a theme was renamed or removed. To fix this error, [edit the dashboard]({edit}) and pick a theme that exists, or add the theme back.\n\nSpook 👻 Your homie.",
"title": "Unknown themes used in dashboard: {dashboard}"
},
"min_max_unknown_sources": {
"title": "Unknown members used in min/max helper: {helper}",
"fix_flow": {
Expand Down
20 changes: 20 additions & 0 deletions documentation/integrations/lovelace.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ To resolve the raised issue, edit the dashboard and pick the users that should s
- A dashboard run by a strategy, like the areas dashboard, is not checked.
:::

### Unknown themes

Dashboards are inspected for the themes they use: the theme of a view, of a section, and of the cards that take one, also inside stacks, grids, conditional cards and the header and footer of a sections view. When that theme does not exist, Spook raises a repair issue naming the dashboard and the themes that are missing.

The frontend does not tell you when a theme is missing. It only puts on a theme it has, so a card or view asking for one that is gone gets the default look, and nothing says why. This usually happens when a theme was renamed, or the file it came from was removed.

A theme is found by its name, exactly as written, so `midnight` is not `Midnight`. `default` and `none` are fine: those are built in, not themes of your own. Spook looks again when the themes are reloaded, so adding a theme back clears the issue as well.

To resolve the raised issue, edit the dashboard and pick a theme that exists, or add the theme back. Spook will automatically remove the repair issue once the issue is fixed.

:::{attention} Known limitations
:class: dropdown

- Only the cards that put on a theme are checked. A theme on any other card, like a tile card or a stack, does nothing either way.
- Only what the view's layout draws is checked. A panel view shows its first card only, and the sections, header and footer of a view that is not a sections view are never shown.
- Custom cards, and the cards inside them, are not checked. What a custom card does with a `theme` is up to the card. The theme of a custom view or section is checked, as the frontend puts that one on itself.
- A theme that is a template, or uses a card's own variables, is not checked.
- Views, sections and dashboards run by a strategy are not checked. Those are only made when they open.
:::

### Missing dashboard resources

Dashboard resources tell Home Assistant which extra JavaScript and CSS files to load, which is how custom cards get there. Spook checks the ones it can: a resource served from `/local/` or `/hacsfiles/` maps to a file on disk, so Spook can see whether that file is there. If it is not, it will raise a repair issue listing the resources in question.
Expand Down
Loading
Loading