456 files
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
|
||||
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
|
||||
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
|
||||
capability gate. It registers twenty-three commands. It advertises twenty-seven
|
||||
capabilities: twenty-two command capabilities plus five additive flags
|
||||
capability gate. It advertises command capabilities and additive flags
|
||||
(dashboards_doc_search, device_registry_child_semantics, search_visibility,
|
||||
search_entity_membership, and search_visibility_allowlist_authorization);
|
||||
search_entity_membership, search_visibility_allowlist_authorization, and
|
||||
search_unified);
|
||||
the info handshake carries no capability entry:
|
||||
|
||||
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
||||
@@ -16,6 +16,8 @@ the info handshake carries no capability entry:
|
||||
* ``ha_mcp_tools/search`` — a unified in-process search over live registries and
|
||||
states, joined and scored, mirroring today's ``ha_search`` response envelope.
|
||||
The search_entity_membership flag gates opt-in generic group metadata.
|
||||
The search_unified flag covers area/floor resolution, state filtering before
|
||||
pagination, queryless listing, and search windows beyond the advisory limit.
|
||||
* ``ha_mcp_tools/overview`` — the raw in-process reads the server's
|
||||
``get_system_overview`` + ``ha_get_overview`` wrapper consume (states,
|
||||
services, entity/device/area registries, ``hass.config``, persistent
|
||||
@@ -179,6 +181,13 @@ the info handshake carries no capability entry:
|
||||
All awaiting work lives in :func:`_bulk_call_service_prep`;
|
||||
:func:`_do_bulk_call_service` is a pure formatter that reuses the single
|
||||
``call_service`` guard / transition / diff helpers.
|
||||
* ``ha_mcp_tools/template_diagnose`` — renders a template in-process and reports
|
||||
the stage (``compile`` / ``render`` / ``timeout`` / ``none``) and, for a
|
||||
failure, the error with the template ``line`` and ``source_line`` it points
|
||||
at, which Core's ``render_template`` drops (#2522). The render is guarded by
|
||||
Core's own ``async_render_will_timeout`` before the template is rendered on
|
||||
the loop; see
|
||||
:mod:`.template_diagnose`.
|
||||
|
||||
* ``ha_mcp_tools/config_entries`` — config entries as the ``config_entries/get``
|
||||
WS shape (``created_at`` / ``modified_at`` / ``entry_id`` / ``domain`` /
|
||||
@@ -302,6 +311,7 @@ from homeassistant.helpers import (
|
||||
label_registry as lr,
|
||||
)
|
||||
|
||||
from . import template_diagnose
|
||||
from .const import (
|
||||
CHANNEL_DEV,
|
||||
CHANNEL_STABLE,
|
||||
@@ -313,6 +323,11 @@ from .const import (
|
||||
OPT_CHANNEL,
|
||||
OPT_PIP_SPEC,
|
||||
)
|
||||
from .search_locations import (
|
||||
add_location_metadata,
|
||||
add_registry_failures,
|
||||
resolve_search_location,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -342,6 +357,7 @@ WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry"
|
||||
WS_SERVER_ENTRY_UPDATE = f"{WS_API_PREFIX}/server_entry_update"
|
||||
WS_CALL_SERVICE = f"{WS_API_PREFIX}/call_service"
|
||||
WS_BULK_CALL_SERVICE = f"{WS_API_PREFIX}/bulk_call_service"
|
||||
WS_TEMPLATE_DIAGNOSE = f"{WS_API_PREFIX}/template_diagnose"
|
||||
|
||||
# Wire-format generation of the request/response envelopes. Bumped only on an
|
||||
# *incompatible* shape change to an existing command; additive fields do not
|
||||
@@ -354,6 +370,8 @@ SCHEMA_VERSION = 1
|
||||
# capability key of its own.)
|
||||
CAPABILITIES: list[str] = [
|
||||
"search",
|
||||
# Location/state filters precede pagination; search accepts any result window.
|
||||
"search_unified",
|
||||
# A flag on search: gates its additive result_fields request and generic
|
||||
# is_group/member_entity_ids response fields.
|
||||
"search_entity_membership",
|
||||
@@ -417,6 +435,9 @@ CAPABILITIES: list[str] = [
|
||||
# component route on this; a component that lacks it is never sent a batch
|
||||
# write and stays on the legacy per-entity path.
|
||||
"bulk_call_service",
|
||||
# The server's ha_eval_template asks for a failed template's line only when
|
||||
# this is advertised; without it the error is returned as Core reported it.
|
||||
"template_diagnose",
|
||||
]
|
||||
|
||||
# The registry kinds ``ha_mcp_tools/registries`` can serve. The WS schema gates
|
||||
@@ -648,6 +669,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
|
||||
_do_bulk_call_service,
|
||||
_bulk_call_service_prep,
|
||||
),
|
||||
(_template_diagnose_schema(), _do_template_diagnose, _template_diagnose_prep),
|
||||
]
|
||||
|
||||
|
||||
@@ -674,6 +696,45 @@ def _info_schema() -> dict[Any, Any]:
|
||||
return {vol.Required("type"): WS_INFO}
|
||||
|
||||
|
||||
# The same bound as ha_eval_template's timeout parameter; it keeps one diagnosis
|
||||
# from holding a render thread longer than a caller could usefully wait.
|
||||
TEMPLATE_DIAGNOSE_MAX_TIMEOUT = 60.0
|
||||
|
||||
|
||||
def _template_diagnose_schema() -> dict[Any, Any]:
|
||||
return {
|
||||
vol.Required("type"): WS_TEMPLATE_DIAGNOSE,
|
||||
vol.Required("template"): str,
|
||||
vol.Optional("variables"): dict,
|
||||
vol.Optional("strict", default=False): bool,
|
||||
vol.Optional("timeout", default=3.0): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=0.1, max=TEMPLATE_DIAGNOSE_MAX_TIMEOUT)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _template_diagnose_prep(
|
||||
hass: HomeAssistant, msg: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Async pre-step for ``template_diagnose``: the guarded in-process render."""
|
||||
return {
|
||||
"diagnosis": await template_diagnose.async_diagnose(
|
||||
hass,
|
||||
msg["template"],
|
||||
msg.get("variables"),
|
||||
msg["strict"],
|
||||
msg["timeout"],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _do_template_diagnose(
|
||||
hass: HomeAssistant, params: dict[str, Any], *, diagnosis: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Return the diagnosis :func:`_template_diagnose_prep` produced."""
|
||||
return diagnosis
|
||||
|
||||
|
||||
# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type
|
||||
# (seven id/name lists, two bool flags), plus the optional
|
||||
# ``allowlist_authorization`` precedence flag the server adds for a component that
|
||||
@@ -730,9 +791,7 @@ def _search_schema() -> dict[Any, Any]:
|
||||
vol.Optional("exact", default=True): bool,
|
||||
vol.Optional("include_hidden", default=True): bool,
|
||||
vol.Optional("include_config", default=False): bool,
|
||||
vol.Optional("limit", default=DEFAULT_LIMIT): vol.All(
|
||||
int, vol.Range(min=1, max=MAX_RESULTS)
|
||||
),
|
||||
vol.Optional("limit", default=DEFAULT_LIMIT): vol.All(int, vol.Range(min=1)),
|
||||
vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)),
|
||||
# Opt-in entity visibility for component search. The component advertises
|
||||
# ``search_visibility`` and ``search_visibility_allowlist_authorization``;
|
||||
@@ -1032,6 +1091,9 @@ class _RegistryView:
|
||||
floor: Any = None
|
||||
label: Any = None
|
||||
device: Any = None
|
||||
_access_failures: set[str] = dataclass_field(
|
||||
default_factory=set, init=False, repr=False, compare=False
|
||||
)
|
||||
|
||||
# One request-local, conflict-filtered semantic snapshot plus the identities
|
||||
# removed from it. Visibility filtering consumes the former and its warning
|
||||
@@ -1132,6 +1194,11 @@ def _do_search(
|
||||
# load_hidden_set warnings).
|
||||
visibility_warnings: list[str] = []
|
||||
hidden: set[str] = set()
|
||||
location = (
|
||||
resolve_search_location(view, area_filter)
|
||||
if area_filter and SEARCH_TYPE_ENTITY in search_types
|
||||
else None
|
||||
)
|
||||
|
||||
# ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
|
||||
# plaintext from the config-body match corpus: a YAML-loaded automation/script/
|
||||
@@ -1152,7 +1219,7 @@ def _do_search(
|
||||
exact=exact,
|
||||
include_hidden=include_hidden,
|
||||
domain_filter=domain_filter,
|
||||
area_filter=area_filter,
|
||||
area_filter=location.area_ids if location else None,
|
||||
state_filter=state_filter,
|
||||
include_membership=membership_requested,
|
||||
)
|
||||
@@ -1206,6 +1273,9 @@ def _do_search(
|
||||
entities = [
|
||||
_project_entity(r, include_membership=membership_requested) for r in page
|
||||
]
|
||||
add_registry_failures(
|
||||
location, view._access_failures, diagnostics, partial_reasons
|
||||
)
|
||||
|
||||
# --- Config surfaces (automations + scripts + scenes + helpers) ----------
|
||||
# One combined pagination window, mirroring the server's config branch.
|
||||
@@ -1279,7 +1349,7 @@ def _do_search(
|
||||
# ha_search consumer merges these into the response's top-level warnings.
|
||||
if visibility_warnings:
|
||||
result["visibility_warnings"] = visibility_warnings
|
||||
return result
|
||||
return add_location_metadata(result, location)
|
||||
|
||||
|
||||
def _sort_key(rec: dict[str, Any]) -> str:
|
||||
@@ -1401,13 +1471,12 @@ def _search_entities(
|
||||
exact: bool,
|
||||
include_hidden: bool,
|
||||
domain_filter: str | None,
|
||||
area_filter: str | None,
|
||||
area_filter: set[str] | None,
|
||||
state_filter: str | None,
|
||||
include_membership: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""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").
|
||||
@@ -1423,9 +1492,7 @@ def _search_entities(
|
||||
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
|
||||
):
|
||||
if area_filter is not None and rec["_area_id"] not in area_filter:
|
||||
continue
|
||||
|
||||
if match_all:
|
||||
@@ -1628,14 +1695,6 @@ def _entity_record(
|
||||
}
|
||||
|
||||
|
||||
def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
|
||||
area_id = rec.get("_area_id")
|
||||
if area_id and str(area_id).lower() == area_filter_lower:
|
||||
return True
|
||||
area_name = rec.get("area")
|
||||
return bool(area_name and str(area_name).lower() == area_filter_lower)
|
||||
|
||||
|
||||
def _project_entity(
|
||||
rec: dict[str, Any], *, include_membership: bool = False
|
||||
) -> dict[str, Any]:
|
||||
@@ -2351,16 +2410,22 @@ def _iter_config_entries(hass: HomeAssistant) -> list[Any]:
|
||||
|
||||
|
||||
def _reg_entity(view: _RegistryView, entity_id: str) -> Any:
|
||||
return _call_lookup(view.entity, "async_get", entity_id)
|
||||
return _call_lookup(view, "entity", "async_get", entity_id)
|
||||
|
||||
|
||||
def _device(view: _RegistryView, device_id: str | None) -> Any:
|
||||
if not device_id:
|
||||
return None
|
||||
return _call_lookup(view.device, "async_get", device_id)
|
||||
return _call_lookup(view, "device", "async_get", device_id)
|
||||
|
||||
|
||||
def _device_collection_values(collection: Any, *, collection_name: str) -> list[Any]:
|
||||
def _device_collection_values(
|
||||
view: _RegistryView,
|
||||
collection: Any,
|
||||
*,
|
||||
collection_name: str,
|
||||
mapping_like: bool = False,
|
||||
) -> list[Any]:
|
||||
"""Enumerate a Core device collection across old and 2026.9 shapes.
|
||||
|
||||
Before Core 2026.9 ``registry.devices`` was a mapping-like container. Core
|
||||
@@ -2371,10 +2436,11 @@ def _device_collection_values(collection: Any, *, collection_name: str) -> list[
|
||||
"""
|
||||
if collection is None:
|
||||
return []
|
||||
if isinstance(collection, Mapping):
|
||||
if mapping_like or isinstance(collection, Mapping):
|
||||
try:
|
||||
return list(collection.values())
|
||||
except Exception: # pragma: no cover - defensive
|
||||
view._access_failures.add("device")
|
||||
_LOGGER.warning(
|
||||
"failed to enumerate device registry collection %s",
|
||||
collection_name,
|
||||
@@ -2384,6 +2450,7 @@ def _device_collection_values(collection: Any, *, collection_name: str) -> list[
|
||||
try:
|
||||
return list(collection)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
view._access_failures.add("device")
|
||||
_LOGGER.warning(
|
||||
"failed to enumerate device registry collection %s",
|
||||
collection_name,
|
||||
@@ -2411,16 +2478,20 @@ def _unambiguous_device_entries(view: _RegistryView) -> dict[str, Any]:
|
||||
main_collection = getattr(reg, "devices", None)
|
||||
if hasattr(reg, "child_devices"):
|
||||
candidates = _device_collection_values(
|
||||
main_collection, collection_name="devices"
|
||||
view, main_collection, collection_name="devices"
|
||||
)
|
||||
candidates.extend(
|
||||
_device_collection_values(
|
||||
getattr(reg, "child_devices", None), collection_name="child_devices"
|
||||
view,
|
||||
getattr(reg, "child_devices", None),
|
||||
collection_name="child_devices",
|
||||
)
|
||||
)
|
||||
else:
|
||||
# The pre-2026.9 container is mapping-like and iterates ids, not entries.
|
||||
candidates = _mapping_values(main_collection)
|
||||
candidates = _device_collection_values(
|
||||
view, main_collection, collection_name="devices", mapping_like=True
|
||||
)
|
||||
|
||||
by_id: dict[str, Any] = {}
|
||||
conflicts: set[str] = set()
|
||||
@@ -2527,7 +2598,7 @@ def _effective_device_area_id(view: _RegistryView, device: Any) -> str | None:
|
||||
def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
|
||||
if not area_id:
|
||||
return None
|
||||
area = _call_lookup(view.area, "async_get_area", area_id)
|
||||
area = _call_lookup(view, "area", "async_get_area", area_id)
|
||||
name = getattr(area, "name", None) if area is not None else None
|
||||
return str(name) if name else None
|
||||
|
||||
@@ -2535,11 +2606,11 @@ def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
|
||||
def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None:
|
||||
if not area_id:
|
||||
return None
|
||||
area = _call_lookup(view.area, "async_get_area", area_id)
|
||||
area = _call_lookup(view, "area", "async_get_area", area_id)
|
||||
floor_id = getattr(area, "floor_id", None) if area is not None else None
|
||||
if not floor_id:
|
||||
return None
|
||||
floor = _call_lookup(view.floor, "async_get_floor", floor_id)
|
||||
floor = _call_lookup(view, "floor", "async_get_floor", floor_id)
|
||||
name = getattr(floor, "name", None) if floor is not None else None
|
||||
return str(name) if name else None
|
||||
|
||||
@@ -2547,13 +2618,14 @@ def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None
|
||||
def _label_names(view: _RegistryView, label_ids: Any) -> list[str]:
|
||||
names: list[str] = []
|
||||
for label_id in sorted(label_ids or []):
|
||||
label = _call_lookup(view.label, "async_get_label", label_id)
|
||||
label = _call_lookup(view, "label", "async_get_label", label_id)
|
||||
name = getattr(label, "name", None) if label is not None else None
|
||||
names.append(str(name) if name else str(label_id))
|
||||
return names
|
||||
|
||||
|
||||
def _call_lookup(registry: Any, method: str, key: str) -> Any:
|
||||
def _call_lookup(view: _RegistryView, registry_name: str, method: str, key: str) -> Any:
|
||||
registry = getattr(view, registry_name)
|
||||
if registry is None:
|
||||
return None
|
||||
getter = getattr(registry, method, None)
|
||||
@@ -2561,7 +2633,8 @@ def _call_lookup(registry: Any, method: str, key: str) -> Any:
|
||||
return None
|
||||
try:
|
||||
return getter(key)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
except Exception:
|
||||
view._access_failures.add(registry_name)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user