39 files
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user