This commit is contained in:
Home Assistant Version Control
2026-08-02 15:33:12 +00:00
parent 60dd13b52c
commit 2ce8792155
39 changed files with 1992 additions and 157 deletions
+132 -12
View File
@@ -357,6 +357,13 @@ CAPABILITIES: list[str] = [
"backup_prep",
"registries",
"dashboards",
# A flag, not a standalone command: gates the additive whole-document
# search-result keys on ``ha_mcp_tools/dashboards`` mode=search
# (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
# The server's ha_search dashboard bucket routes through the component only
# when this is advertised — an older component without the keys would
# silently narrow coverage to the card-scoped walk and hide load failures.
"dashboards_doc_search",
"services_list",
"reference_data",
# A flag, not a standalone command: gates the optional ``visibility`` param
@@ -1274,13 +1281,20 @@ def _search_entities(
"""Score every state against the query over the joined registry view."""
results: list[dict[str, Any]] = []
area_filter_lower = area_filter.lower() if area_filter else None
# Lower the state filter once; the entity state is lowered per record so the
# compare is case-insensitive (e.g. an input_select holding "Vacation"
# matches state_filter="vacation").
state_filter_lower = state_filter.lower() if state_filter is not None else None
for state in _iter_states(hass):
rec = _entity_record(state, view)
if domain_filter and rec["domain"] != domain_filter:
continue
if rec["_hidden"] and not include_hidden:
continue
if state_filter is not None and rec["state"] != state_filter:
if (
state_filter_lower is not None
and (rec["state"] or "").lower() != state_filter_lower
):
continue
if area_filter_lower is not None and not _entity_matches_area(
rec, area_filter_lower
@@ -1963,8 +1977,7 @@ def _name_tier(query_lower: str, texts: Any, *, exact: bool) -> int | None:
if query_norm and query_norm in _sep_normalized(text_lower):
return 100
ratio = _calc_ratio(query_lower, text_lower)
if ratio > best_ratio:
best_ratio = ratio
best_ratio = max(best_ratio, ratio)
if not exact and best_ratio >= FUZZY_THRESHOLD:
return best_ratio
return None
@@ -3192,7 +3205,7 @@ def _exposure_enrichment(
rather than crashing the join).
"""
join = _registry_enrichment(view, entity_id)
domain = entity_id.split(".")[0] if "." in entity_id else ""
domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else ""
info: dict[str, Any] = {
"domain": domain,
"area": join["area"],
@@ -4079,6 +4092,15 @@ def _do_dashboards(
"available": True,
"matches": matches,
"truncated": truncated,
# ``dashboards_doc_search`` additions (issue #2008): the whole-
# document per-dashboard verdicts + honesty counters the server's
# ha_search dashboard bucket needs. Additive — a pre-#2008 server
# ignores them.
"document_matches": _dashboard_document_matches(
prepped.get("docs") or [], query_lower
),
"yaml_skipped": prepped.get("yaml_skipped", 0),
"load_failed": prepped.get("load_failed", 0),
}
return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []}
@@ -4101,7 +4123,11 @@ async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str
if mode == "get":
prepped.update(await _dashboard_get_config(dashboards_map, msg.get("url_path")))
elif mode == "search":
prepped["docs"] = await _dashboard_search_docs(dashboards_map)
(
prepped["docs"],
prepped["yaml_skipped"],
prepped["load_failed"],
) = await _dashboard_search_docs(dashboards_map)
else:
prepped["rows"] = _dashboard_list_rows(dashboards_map)
return {"prepped": prepped}
@@ -4191,15 +4217,42 @@ async def _dashboard_get_config(
async def _dashboard_search_docs(
dashboards_map: Mapping[Any, Any],
) -> list[dict[str, Any]]:
) -> tuple[list[dict[str, Any]], int, int]:
"""Load every STORAGE dashboard's config for the ``search`` walk.
Only storage dashboards are loaded YAML bodies are never searched/emitted.
A per-dashboard load error is skipped (fail-soft) rather than failing the
whole search. Returns ``[{url_path, title, config}, ...]`` plain dicts.
Returns ``(docs, yaml_skipped, load_failed)``: ``docs`` are
``[{url_path, title, registry_title, config}, ...]`` plain dicts
``title`` stays the config body's (the card-scoped ``matches`` records pin
byte parity with the server's legacy MODE 4 walk on it) while the additive
``registry_title`` carries the list-row metadata title that
``document_matches`` emits (what the legacy ha_search bucket records
carry); ``yaml_skipped`` counts
the YAML-mode entries this walk never reads, INCLUDING a default dashboard
forced to YAML (``lovelace: mode: yaml``), which has no ``list`` row for
the server to count the server treats a non-zero count as its
fall-back-to-legacy signal, since the legacy walk DOES read YAML bodies
and coverage must not depend on which path served (issue #2008 review);
``load_failed`` counts storage
dashboards whose config load raised or returned a non-dict real gaps the
caller must surface as partial rather than fail-soft into a clean-looking
result. A ``ConfigNotFound`` load is a clean skip, not a failure: an
auto-generated (never taken control of) dashboard has no stored config to
scan. If core drift breaks the guarded ``ConfigNotFound`` import, those
loads degrade to ``load_failed`` over-reported as partial, never silent.
"""
try:
from homeassistant.components.lovelace.const import ConfigNotFound
except Exception: # pragma: no cover - defensive; core drift
ConfigNotFound = None
docs: list[dict[str, Any]] = []
yaml_skipped = 0
load_failed = 0
for url_path, dash in dashboards_map.items():
if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
yaml_skipped += 1
continue
if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE:
continue
loader = getattr(dash, "async_load", None)
@@ -4207,19 +4260,29 @@ async def _dashboard_search_docs(
continue
try:
config = await loader(False)
except Exception: # skip an unreadable dashboard, keep going (fail-soft)
except Exception as err:
if ConfigNotFound is not None and isinstance(err, ConfigNotFound):
# Auto-generated dashboard: nothing stored, nothing to scan.
continue
load_failed += 1
continue
if not isinstance(config, dict):
load_failed += 1
continue
meta = getattr(dash, "config", None)
registry_title = meta.get("title") if isinstance(meta, Mapping) else None
title = config.get("title")
docs.append(
{
"url_path": url_path,
"title": str(title) if title is not None else None,
"registry_title": (
str(registry_title) if registry_title is not None else None
),
"config": config,
}
)
return docs
return docs, yaml_skipped, load_failed
def _dashboard_mode(dash: Any) -> str | None:
@@ -4228,6 +4291,52 @@ def _dashboard_mode(dash: Any) -> str | None:
return str(mode) if isinstance(mode, str) else None
def _dashboard_document_matches(
docs: list[dict[str, Any]], query_lower: str
) -> list[dict[str, Any]]:
"""Per-dashboard whole-document verdicts: ``[{url_path, title}, ...]``.
One entry per doc whose ENTIRE config contains ``query_lower`` the
coverage the server's legacy ``_search_in_dict`` walk provides (view
titles, dashboard-level keys, every leaf), which the card-scoped
``matches`` walk deliberately narrows to. ``title`` is the registry
metadata's (falling back to the body's) what the legacy ha_search
bucket records carry. An empty query matches nothing. Bounded by the
dashboard count, so no cap/truncation applies.
"""
if not query_lower:
return []
return [
{
"url_path": doc.get("url_path"),
"title": doc.get("registry_title") or doc.get("title"),
}
for doc in docs
if _doc_contains(doc.get("config"), query_lower)
]
def _doc_contains(data: Any, query_lower: str) -> bool:
"""Case-insensitive substring test over keys and every leaf of a config.
Exact port of the server's ``_search_in_dict_exact`` (keys + string
leaves + ``str()`` of non-None scalars) so the component-served verdict
matches the legacy walk's, leaf for leaf.
"""
if isinstance(data, dict):
return any(
query_lower in str(key).lower() or _doc_contains(value, query_lower)
for key, value in data.items()
)
if isinstance(data, list):
return any(_doc_contains(item, query_lower) for item in data)
if isinstance(data, str):
return query_lower in data.lower()
if data is not None:
return query_lower in str(data).lower()
return False
def _search_dashboard_docs(
docs: list[dict[str, Any]], query_lower: str
) -> tuple[list[dict[str, Any]], bool]:
@@ -4631,7 +4740,18 @@ async def _fetch_service_translations(
exc_info=True,
)
return {}
return result if isinstance(result, Mapping) else {}
if isinstance(result, Mapping):
return result
# Same visibility as the failure above: discarding the catalog leaves the
# service list untranslated, and the type is the only useful clue. Kept in
# step with the mirrored seam in config_flow.
_LOGGER.warning(
"services_list: ignoring the %s service translations: expected a "
"Mapping, got %s",
language,
type(result).__name__,
)
return {}
def _filter_service_translations(
@@ -5075,7 +5195,7 @@ def _assist_default_exposed(hass: HomeAssistant, entity_id: str) -> bool:
or getattr(entry, "hidden_by", None) is not None
):
return False
domain = entity_id.split(".")[0] if "." in entity_id else entity_id
domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else entity_id
if domain in DEFAULT_EXPOSED_DOMAINS:
return True
from homeassistant.exceptions import HomeAssistantError