291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
@@ -13,17 +13,92 @@ 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
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):
@@ -64,26 +139,25 @@ class SpookRepair(AbstractSpookRepair):
continue
extracted_entities = self.__async_extract_entities(config)
if unknown_entities := async_filter_known_entity_ids(
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,
):
# 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
) | _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,
references=unknown_entities | not_entity_ids,
translation_placeholders={
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
"entities": await _async_describe(
self.hass, unknown_entities, not_entity_ids
),
"dashboard": title,
"edit": f"/{url_path}/{first_view_path}?edit=1",
@@ -95,7 +169,7 @@ class SpookRepair(AbstractSpookRepair):
"and created an issue for it; Entities: %s"
),
title,
", ".join(unknown_entities),
", ".join(unknown_entities | not_entity_ids),
)
@callback
@@ -109,4 +183,14 @@ class SpookRepair(AbstractSpookRepair):
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