429 files
This commit is contained in:
@@ -2,9 +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-two commands (twenty-two capabilities — the
|
||||
``search_visibility`` capability is a flag on the existing ``search`` command,
|
||||
and ``info`` itself carries no capability entry):
|
||||
capability gate. It registers twenty-three commands. It advertises twenty-five
|
||||
capabilities: twenty-two command capabilities plus three additive flags
|
||||
(dashboards_doc_search, search_visibility, and search_entity_membership);
|
||||
the info handshake carries no capability entry:
|
||||
|
||||
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
||||
+ ``component_version`` + advisory ``limits`` + the instance ``timezone``
|
||||
@@ -13,6 +14,7 @@ and ``info`` itself carries no capability entry):
|
||||
negotiation, NOT a version floor).
|
||||
* ``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.
|
||||
* ``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
|
||||
@@ -262,7 +264,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Collection, Mapping
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
@@ -336,12 +338,15 @@ WS_BULK_CALL_SERVICE = f"{WS_API_PREFIX}/bulk_call_service"
|
||||
# bump it (the server checks ``schema_version >= N`` before using a new shape).
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Which commands exist. Grows one entry per shipped command; the server gates
|
||||
# Advertised command support and additive feature flags; the server gates
|
||||
# each consumer on ``capability in caps.capabilities``. Never remove an entry
|
||||
# without a major bump. (``info`` is always present in 1.1.0+, so it carries no
|
||||
# capability key of its own.)
|
||||
CAPABILITIES: list[str] = [
|
||||
"search",
|
||||
# A flag on search: gates its additive result_fields request and generic
|
||||
# is_group/member_entity_ids response fields.
|
||||
"search_entity_membership",
|
||||
"overview",
|
||||
"helpers_list",
|
||||
"states",
|
||||
@@ -526,9 +531,31 @@ _SPLIT_RE = re.compile(r"[._\-\s]+")
|
||||
def async_register_commands(hass: HomeAssistant) -> None:
|
||||
"""Register the ``ha_mcp_tools/*`` WebSocket commands.
|
||||
|
||||
Called from BOTH config-entry setups since component 2.1.0: the tools entry
|
||||
(alongside its service registrations) and the server entry (#2289/#2291).
|
||||
The surface is entry-agnostic — every handler reads HA-core state, none of
|
||||
it the tools entry's ``hass.data`` — so a server-entry-only install gets it
|
||||
too; only the filesystem/YAML HA *services* remain tools-entry-only.
|
||||
|
||||
Idempotent: HA's ``async_register_command`` overwrites an existing handler,
|
||||
so re-running on a config-entry reload is harmless. Called from the tools
|
||||
config-entry setup alongside the service registrations.
|
||||
so re-running on a config-entry reload, or from both entries on a dual-entry
|
||||
install, is harmless.
|
||||
|
||||
There is NO unregister. HA's ``websocket_api`` exposes no counterpart to
|
||||
``async_register_command``, so the commands outlive an entry unload and stay
|
||||
on the connection surface until Home Assistant restarts. That is deliberate
|
||||
and accepted (#2292). Unloading one entry must not strip the surface the
|
||||
other entry still serves from, and a surface left behind by a FULLY unloaded
|
||||
component holds no privilege of its own: every handler works off live
|
||||
HA-core state rather than anything the unloaded entry cached, HA core
|
||||
authenticates the connection, and ``@require_admin`` gates each command — so
|
||||
a caller reaching it can already do the same through HA's own WS API. The
|
||||
write commands are no exception: their D1 domain block refuses
|
||||
``domain == "ha_mcp_tools"`` unconditionally, so the leftover surface can
|
||||
never reach the privileged filesystem/YAML services, which
|
||||
:func:`~custom_components.ha_mcp_tools._async_unload_tools_entry` does remove
|
||||
on unload. Admin-gated commands answering from live core state until the
|
||||
next restart is the trade this makes.
|
||||
"""
|
||||
for schema, do_fn, prep in _command_specs():
|
||||
websocket_api.async_register_command(hass, _build_handler(schema, do_fn, prep))
|
||||
@@ -650,6 +677,7 @@ def _visibility_param_schema() -> Any:
|
||||
|
||||
|
||||
def _search_schema() -> dict[Any, Any]:
|
||||
"""Build the schema for search WebSocket requests."""
|
||||
return {
|
||||
vol.Required("type"): WS_SEARCH,
|
||||
vol.Optional("query"): vol.Any(str, None),
|
||||
@@ -657,6 +685,7 @@ def _search_schema() -> dict[Any, Any]:
|
||||
vol.Optional("domain_filter"): str,
|
||||
vol.Optional("area_filter"): str,
|
||||
vol.Optional("state_filter"): str,
|
||||
vol.Optional("result_fields"): [vol.In(("is_group", "member_entity_ids"))],
|
||||
vol.Optional("exact", default=True): bool,
|
||||
vol.Optional("include_hidden", default=True): bool,
|
||||
vol.Optional("include_config", default=False): bool,
|
||||
@@ -905,11 +934,18 @@ def _bulk_call_service_schema() -> dict[Any, Any]:
|
||||
def _do_info(hass: HomeAssistant | None = None) -> dict[str, Any]:
|
||||
"""Return the handshake payload.
|
||||
|
||||
``timezone`` is an additive field (``hass.config.time_zone``) consumers detect
|
||||
by presence — it carries NO capability entry and does NOT bump
|
||||
``timezone`` (``hass.config.time_zone``) and ``tools_services`` (whether
|
||||
the tools entry's HA services are registered) are additive fields consumers
|
||||
detect by presence — they carry NO capability entry and do NOT bump
|
||||
``schema_version``. ``hass`` is optional (defaulting to ``None`` so a direct
|
||||
``_do_info()`` still works for callers that only need the static handshake);
|
||||
when absent, ``timezone`` degrades to ``None``.
|
||||
when absent, both degrade to ``None``.
|
||||
|
||||
``tools_services`` exists because 2.1.0 registers this command surface from
|
||||
BOTH entry types (#2289): ``info`` answering no longer implies the tools
|
||||
entry — and its filesystem/YAML services — are present (#2292). The server's
|
||||
filesystem/YAML gate reads this field to keep raising its actionable
|
||||
"tools entry not set up" error on server-entry-only installs.
|
||||
"""
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
@@ -917,6 +953,7 @@ def _do_info(hass: HomeAssistant | None = None) -> dict[str, Any]:
|
||||
"capabilities": list(CAPABILITIES),
|
||||
"limits": dict(LIMITS),
|
||||
"timezone": _config_time_zone(hass),
|
||||
"tools_services": _tools_services_loaded(hass),
|
||||
}
|
||||
|
||||
|
||||
@@ -927,6 +964,23 @@ def _config_time_zone(hass: HomeAssistant | None) -> str | None:
|
||||
return tz if isinstance(tz, str) and tz else None
|
||||
|
||||
|
||||
def _tools_services_loaded(hass: HomeAssistant | None) -> bool | None:
|
||||
"""True when the tools entry's filesystem/YAML HA services are registered.
|
||||
|
||||
Probes the service registry itself (``read_file`` — one of the services
|
||||
``_async_setup_tools_entry`` registers and its unload removes) rather than
|
||||
the config-entry list, so the answer tracks what a caller can actually
|
||||
invoke. ``None`` when ``hass`` is absent (the direct ``_do_info()`` call);
|
||||
the WS handler always passes ``hass``, so the wire value is a bool.
|
||||
"""
|
||||
services = getattr(hass, "services", None)
|
||||
if services is None:
|
||||
return None
|
||||
# Literal matches SERVICE_READ_FILE in __init__.py (importing it here would
|
||||
# be circular).
|
||||
return bool(services.has_service(DOMAIN, "read_file"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ha_mcp_tools/search
|
||||
# =============================================================================
|
||||
@@ -1011,6 +1065,7 @@ def _do_search(
|
||||
domain_filter = params.get("domain_filter")
|
||||
area_filter = params.get("area_filter")
|
||||
state_filter = params.get("state_filter")
|
||||
membership_requested = bool(params.get("result_fields"))
|
||||
# Opt-in visibility filter (search_visibility capability). A non-empty dict of
|
||||
# the server's raw VisibilityConfig fields; applied as a hard entity exclude.
|
||||
visibility = params.get("visibility")
|
||||
@@ -1023,6 +1078,7 @@ def _do_search(
|
||||
# filter is applied. Surfaced additively so the fast path isn't silent about
|
||||
# incomplete filtering (parity with the server's load_hidden_set warnings).
|
||||
visibility_warnings: list[str] = []
|
||||
hidden: set[str] = set()
|
||||
|
||||
# ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
|
||||
# plaintext from the config-body match corpus: a YAML-loaded automation/script/
|
||||
@@ -1045,6 +1101,7 @@ def _do_search(
|
||||
domain_filter=domain_filter,
|
||||
area_filter=area_filter,
|
||||
state_filter=state_filter,
|
||||
include_membership=membership_requested,
|
||||
)
|
||||
# Opt-in visibility filter: a hard exclude applied BEFORE counts/pagination,
|
||||
# exactly where the legacy path drops ``visibility_hidden`` entities at the
|
||||
@@ -1078,8 +1135,17 @@ def _do_search(
|
||||
scored_entities.sort(key=lambda r: (-r["score"], r["entity_id"]))
|
||||
entity_total = len(scored_entities)
|
||||
page = scored_entities[offset : offset + limit]
|
||||
_redact_hidden_members(
|
||||
page,
|
||||
hidden,
|
||||
view=view,
|
||||
include_hidden=include_hidden,
|
||||
enabled=membership_requested,
|
||||
)
|
||||
entity_has_more = offset + len(page) < entity_total
|
||||
entities = [_project_entity(r) for r in page]
|
||||
entities = [
|
||||
_project_entity(r, include_membership=membership_requested) for r in page
|
||||
]
|
||||
|
||||
# --- Config surfaces (automations + scripts + scenes + helpers) ----------
|
||||
# One combined pagination window, mirroring the server's config branch.
|
||||
@@ -1277,6 +1343,7 @@ def _search_entities(
|
||||
domain_filter: str | None,
|
||||
area_filter: 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]] = []
|
||||
@@ -1286,7 +1353,7 @@ def _search_entities(
|
||||
# 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)
|
||||
rec = _entity_record(state, view, include_membership=include_membership)
|
||||
if domain_filter and rec["domain"] != domain_filter:
|
||||
continue
|
||||
if rec["_hidden"] and not include_hidden:
|
||||
@@ -1448,12 +1515,15 @@ def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]:
|
||||
def _entity_record(
|
||||
state: Any, view: _RegistryView, *, include_membership: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Join a state with the entity/device/area/floor/label registries."""
|
||||
entity_id = getattr(state, "entity_id", "") or ""
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
attrs = getattr(state, "attributes", None) or {}
|
||||
friendly = attrs.get("friendly_name", entity_id)
|
||||
members = _normalize_member_entity_ids(attrs) if include_membership else None
|
||||
|
||||
join = _registry_enrichment(view, entity_id)
|
||||
area_name = join["area"]
|
||||
@@ -1481,6 +1551,14 @@ def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]:
|
||||
"floor": floor_name,
|
||||
"labels": label_names,
|
||||
"aliases": aliases,
|
||||
**(
|
||||
{
|
||||
"is_group": members is not None,
|
||||
**({"member_entity_ids": members} if members is not None else {}),
|
||||
}
|
||||
if include_membership
|
||||
else {}
|
||||
),
|
||||
"_hidden": join["_hidden"],
|
||||
"_area_id": join["_area_id"],
|
||||
"_match_texts": match_texts,
|
||||
@@ -1495,7 +1573,9 @@ def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
|
||||
return bool(area_name and str(area_name).lower() == area_filter_lower)
|
||||
|
||||
|
||||
def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
|
||||
def _project_entity(
|
||||
rec: dict[str, Any], *, include_membership: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Strip internal ``_``-prefixed keys for the wire response."""
|
||||
return {
|
||||
"entity_id": rec["entity_id"],
|
||||
@@ -1508,9 +1588,80 @@ def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
|
||||
"aliases": rec["aliases"],
|
||||
"score": rec["score"],
|
||||
"match_type": rec["match_type"],
|
||||
**(
|
||||
{"is_group": rec["is_group"]}
|
||||
if include_membership and "is_group" in rec
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"member_entity_ids": rec["member_entity_ids"]}
|
||||
if include_membership and "member_entity_ids" in rec
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _redact_hidden_members(
|
||||
records: list[dict[str, Any]],
|
||||
hidden: set[str],
|
||||
*,
|
||||
view: _RegistryView | None = None,
|
||||
include_hidden: bool = True,
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
"""Withhold members excluded by visibility or include_hidden."""
|
||||
if not enabled:
|
||||
return
|
||||
for record in records:
|
||||
members = record.get("member_entity_ids")
|
||||
denied = bool(members and hidden.intersection(members))
|
||||
if members and not include_hidden and view is not None:
|
||||
denied = denied or any(
|
||||
getattr(_reg_entity(view, member), "hidden_by", None) is not None
|
||||
for member in members
|
||||
)
|
||||
if denied:
|
||||
record.pop("member_entity_ids", None)
|
||||
|
||||
|
||||
def _normalize_member_entity_ids(attributes: Any) -> list[str] | None:
|
||||
"""Normalize HA's modern or historical explicit group membership."""
|
||||
if not isinstance(attributes, Mapping):
|
||||
return None
|
||||
for key in ("group_entities", "entity_id"):
|
||||
raw = attributes.get(key)
|
||||
if isinstance(raw, (str, bytes, bytearray, Mapping)):
|
||||
continue
|
||||
if not isinstance(raw, Collection):
|
||||
continue
|
||||
members: set[str] = set()
|
||||
valid = True
|
||||
for value in raw:
|
||||
if not _is_entity_id(value):
|
||||
valid = False
|
||||
break
|
||||
members.add(value)
|
||||
if valid:
|
||||
return sorted(members)
|
||||
return None
|
||||
|
||||
|
||||
def _is_entity_id(value: Any) -> bool:
|
||||
"""Return whether a value has the Home Assistant entity ID shape."""
|
||||
if not isinstance(value, str) or value.count(".") != 1:
|
||||
return False
|
||||
domain, object_id = value.split(".", 1)
|
||||
return bool(
|
||||
domain
|
||||
and object_id
|
||||
and value == value.lower()
|
||||
and all(
|
||||
char in "abcdefghijklmnopqrstuvwxyz0123456789_"
|
||||
for char in domain + object_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --- Config surfaces (automation/script/scene) -------------------------------
|
||||
def _search_config_surface(
|
||||
hass: HomeAssistant,
|
||||
@@ -3316,7 +3467,7 @@ def _do_config_entries(
|
||||
) -> dict[str, Any]:
|
||||
"""Return config entries in the ``config_entries/get`` WS shape.
|
||||
|
||||
``{entries: [{created_at, modified_at, entry_id, domain, title, state, source,
|
||||
``{entries: [{created_at, modified_at, entry_id, domain, unique_id, title, state, source,
|
||||
supports_options, supports_remove_device, supports_unload, supports_reconfigure,
|
||||
supported_subentry_types, pref_disable_new_entities, pref_disable_polling,
|
||||
disabled_by, reason, error_reason_translation_key,
|
||||
@@ -3324,7 +3475,9 @@ def _do_config_entries(
|
||||
The FULL ``as_json_fragment`` field set (``created_at`` / ``modified_at`` as
|
||||
``.timestamp()`` floats, ``supported_subentry_types`` as core emits it), so the
|
||||
component row carries the same fields the legacy REST row does — no field is
|
||||
dropped on the component path. Filtered by ``domain`` when
|
||||
dropped on the component path — PLUS ``unique_id``, the one deliberate
|
||||
superset field (core withholds it everywhere; the reconfigure identity
|
||||
anchors need it). Filtered by ``domain`` when
|
||||
given, or the single entry by ``entry_id``
|
||||
(``hass.config_entries.async_get_entry`` — an id that matches nothing,
|
||||
including an empty string, yields an empty list). Only a WHOLLY ABSENT
|
||||
@@ -3424,6 +3577,15 @@ def _config_entry_row(entry: Any, secret_values: frozenset[str]) -> dict[str, An
|
||||
"modified_at": _timestamp(getattr(entry, "modified_at", None)),
|
||||
"entry_id": getattr(entry, "entry_id", None),
|
||||
"domain": getattr(entry, "domain", None),
|
||||
# The ONE field this row adds beyond core's as_json_fragment. Core
|
||||
# deliberately withholds unique_id from every config-entry endpoint
|
||||
# (REST list, config_entries/get and get_single all serialize that
|
||||
# fragment), so a server needing it as an identity anchor has no other
|
||||
# source. Additive within schema_version 1: a server reading an older
|
||||
# component sees the KEY ABSENT, which is distinguishable from a
|
||||
# present-but-None value, so this needs no version gate — the same
|
||||
# discipline as device_get's opt-in entities join.
|
||||
"unique_id": getattr(entry, "unique_id", None),
|
||||
"title": getattr(entry, "title", None),
|
||||
"state": _enum_value(getattr(entry, "state", None)),
|
||||
"source": getattr(entry, "source", None),
|
||||
|
||||
Reference in New Issue
Block a user