6111 lines
264 KiB
Python
6111 lines
264 KiB
Python
"""In-process WebSocket command surface for the ha_mcp_tools component.
|
|
|
|
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):
|
|
|
|
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
|
+ ``component_version`` + advisory ``limits`` + the instance ``timezone``
|
|
(an additive field consumers detect by presence — no capability entry). One
|
|
cached probe tells the server which commands are live (capability
|
|
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.
|
|
* ``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
|
|
notifications, repairs issues) in one call, so the server builds its existing
|
|
overview envelope with no extra HA round-trips.
|
|
* ``ha_mcp_tools/helpers_list`` — collection helpers (live state-attribute
|
|
bodies) AND flow helpers (``ConfigEntry.options``/``title``/``entry_id`` —
|
|
never ``entry.data``), each with the CURRENT entity_id + display name from the
|
|
registry (renamed helpers show current values — issue #1794), closing the
|
|
documented "flow helpers cannot be listed" gap with no OptionsFlow dance. The
|
|
response's ``covered_types`` names which helper_type values were authoritatively
|
|
enumerated, so the server falls back to its legacy ``<type>/list`` path for an
|
|
uncovered type (e.g. ``tag``, which has no state entity) instead of trusting an
|
|
empty result.
|
|
* ``ha_mcp_tools/states`` — a bulk state read: ``State.as_dict()`` for each
|
|
requested entity_id (a pure ``hass.states.get`` in-memory read) plus the list
|
|
of ids with no state, so the server's ``ha_get_state`` serves a 100-entity
|
|
bulk call from one in-process frame instead of up to 100 REST GETs. The body
|
|
is byte-identical to the REST ``/api/states/<id>`` serialization by
|
|
construction; the server maps found/missing onto its per-id error contract.
|
|
* ``ha_mcp_tools/blueprint_get`` — the full body of one installed blueprint
|
|
(``{metadata, config}``), which core's ``blueprint/list`` never returns (it
|
|
serves only ``{metadata}``). The path is jailed under
|
|
``<config>/blueprints/<domain>/`` (symlink-safe containment, mirroring the
|
|
file-tool jail) and the file read + parse run off the event loop in the async
|
|
prep. ``!input`` markers are preserved; every other custom tag (``!secret`` /
|
|
``!include`` / …) is neutralized to ``None`` at load time, so no resolved
|
|
secret plaintext can ever reach the body.
|
|
* ``ha_mcp_tools/device_get`` — one device registry entry by id
|
|
(``{device: <DeviceEntry.dict_repr> | None}``), so a single-device lookup no
|
|
longer pulls the entire device registry. The body is core's
|
|
``DeviceEntry.dict_repr`` returned VERBATIM — byte-identical to one element of
|
|
``config/device_registry/list`` (which sends ``json_bytes(entry.dict_repr)``)
|
|
by construction, since this command's ``connection.send_result`` runs the same
|
|
JSON encoder over the same dict. Consumers keep their own transforms over the
|
|
raw shape; ``device`` is ``None`` when no such device exists. With
|
|
``include_entities`` set, a sibling ``entities`` key carries the device's
|
|
entity-registry rows (``RegistryEntry.as_partial_dict``, the
|
|
``config/entity_registry/list`` shape, disabled entities included) so listing a
|
|
device's entities no longer pulls the whole entity registry either — the raw
|
|
DeviceEntry stays untouched; the join is a sibling.
|
|
* ``ha_mcp_tools/device_list`` — every device registry entry as that same raw
|
|
``DeviceEntry.dict_repr`` shape (``{devices: [...]}``): the in-process
|
|
equivalent of ``config/device_registry/list`` served through the component
|
|
seam, so ``ha_get_device`` list mode need not mix a legacy WS read with the
|
|
component path.
|
|
* ``ha_mcp_tools/entity_enrich`` — the area/floor/labels/aliases join for a set
|
|
of entity_ids (``{entities: {id: {area, floor, labels, aliases}}}``), computed
|
|
by the SAME ``_entity_record``/``_RegistryView`` registry join the search path
|
|
uses (device-inherited area/labels included). Lets ``ha_get_entity`` add the
|
|
resolved-name enrichment fields the raw registry entry lacks (it carries
|
|
``area_id`` / label *ids*, not resolved names) without the caller fanning out
|
|
its own area/floor/label registry reads. Registry-only entities (no state) are
|
|
enriched too — the join keys off the registry, not the state machine.
|
|
* ``ha_mcp_tools/exposure`` — voice-assistant exposure with names/areas attached.
|
|
List mode mirrors core's ``ws_list_exposed_entities`` (``{exposed_entities:
|
|
{id: {assistant: True}}}`` — byte-identical to ``homeassistant/expose_entity/
|
|
list``); single-entity mode reads core's module-level
|
|
``async_get_entity_settings``. Both add a sibling ``entity_info`` map enriching
|
|
each id through the same registry join (friendly_name/domain/area/floor/labels),
|
|
so the server no longer needs a second search + manual correlation to name an
|
|
exposed entity. Three parity guardrails hold: only ``should_expose``-true
|
|
assistants are reported (the raw helper is not pre-filtered like the legacy
|
|
shape); core's ``HomeAssistantError("Unknown entity")`` on a junk id degrades to
|
|
the not-exposed default (the legacy path never raises on junk); and a missing
|
|
``hass.states.get(id)`` omits the live-state fields (friendly_name/state) rather
|
|
than crashing.
|
|
* ``ha_mcp_tools/dashboards`` — Lovelace dashboards read in-process, three modes.
|
|
``list`` mirrors the ``lovelace/dashboards/list`` row shape (id/url_path/title/
|
|
icon/show_in_sidebar/require_admin) with an additive per-row ``mode`` so the
|
|
server can exclude YAML dashboards; ``get`` returns one dashboard's config body
|
|
(``await store.async_load`` in the async prep) with a structured
|
|
``yaml_excluded`` status for YAML-mode dashboards (the server falls back to
|
|
legacy for those — a YAML body may carry resolved ``!secret`` plaintext);
|
|
``search`` walks every STORAGE dashboard's views/cards/sections (plus view-level
|
|
badges and sections-view header cards) for a query substring (capped at 200 with a
|
|
``truncated`` flag). YAML-dashboard bodies are never emitted — storage-only. All
|
|
Store loads run in :func:`_dashboards_prep`.
|
|
* ``ha_mcp_tools/services_list`` — the REST ``/api/services`` service catalog
|
|
(``async_get_all_descriptions``) joined with the ``services`` backend
|
|
translations (``async_get_translations``), both loaded in the async prep.
|
|
Filtered by ``domain`` (exact) only; the server re-runs its exact query filter +
|
|
pagination over the payload. No ``query`` coarse-filter (a per-service superset of
|
|
the server's concatenation-based filter cannot be built cheaply, and no consumer
|
|
forwards ``query``).
|
|
* ``ha_mcp_tools/reference_data`` — the service index + entity-id universe the
|
|
config-reference validator consumes: ``{services: [{domain, services:{name:{}}}],
|
|
entity_ids: [...]}``. The ``services`` shape is the REST ``/api/services`` list
|
|
``build_service_index`` reads (bodies are empty dicts — the index only reads
|
|
keys); ``entity_ids`` is every ``hass.states.async_all()`` id. Pure, no prep.
|
|
* ``ha_mcp_tools/search`` (``search_visibility`` capability) — the search command
|
|
additionally accepts a raw ``visibility`` config dict; when present it excludes
|
|
the server's opt-in hidden entities before counts/pagination (the pure
|
|
:func:`_visibility_hidden_set` mirrors the server's ``hidden_entity_ids``, with
|
|
the Assist dimension delegated to core's ``async_should_expose``), so
|
|
``ha_search`` can route through the component even with an active filter. A
|
|
degraded dimension (unknown ``exclude_category`` / empty-registry allowlist /
|
|
unavailable Assist) fails open, and :func:`_visibility_warnings` returns the
|
|
resolver-parity ``visibility_warnings`` the response carries so the filtering is
|
|
not silently incomplete.
|
|
* ``ha_mcp_tools/server_entry`` — the component locates its OWN server config
|
|
entry (``entry.data[CONF_ENTRY_TYPE] == server``, the one marker key it reads
|
|
from ``entry.data``) and returns ``{entry_id, channel, pip_spec}`` (channel /
|
|
pip_spec from ``entry.options``), so ``ha_dev_manage_server`` need not probe
|
|
every ``ha_mcp_tools``-domain entry's options-flow schema from the outside.
|
|
* ``ha_mcp_tools/server_entry_update`` — the WRITE counterpart of ``server_entry``:
|
|
applies a ``channel`` / ``pip_spec`` delta to the server entry via
|
|
``hass.config_entries.async_update_entry`` DIRECTLY (what core's finish-flow
|
|
does), collapsing ``ha_dev_manage_server(update_source)``'s options-flow start +
|
|
submit round-trip in embedded mode. The delta is MERGED against the LIVE
|
|
``entry.options`` AT APPLY time (every existing key preserved — a concurrent
|
|
change to another key during the flush window is not clobbered — no blanking of
|
|
the URL/secret overrides), so no preserved-key resend is needed. The
|
|
``async_update_entry`` fires the entry's own update listener → reload → the
|
|
hardened reinstall; because that reload tears down the very server thread
|
|
answering this frame, the call is NOT made inline — it is scheduled on a
|
|
HASS-level background task (:func:`hass.async_create_background_task`, NOT the
|
|
entry's, so the reload can't cancel it) after :data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`,
|
|
and the prep returns ``{scheduled: True, ...}`` immediately so the WS response
|
|
flushes first. A no-op (merged options equal the current ones) returns
|
|
``{scheduled: False, unchanged: True, ...}`` without scheduling; no server entry
|
|
raises ``HomeAssistantError`` (→ the server's command-error fallback to its legacy
|
|
options-flow path). All awaiting-adjacent work (the deferred apply) lives in
|
|
:func:`_server_entry_update_prep`; :func:`_do_server_entry_update` is a pure formatter.
|
|
* ``ha_mcp_tools/call_service`` — the FIRST write capability. Fires exactly one
|
|
``hass.services.async_call`` in-process and returns the REAL pre→post state
|
|
transition for the target ``entity_ids`` — event-confirmed via an
|
|
``EVENT_STATE_CHANGED`` listener registered BEFORE the dispatch (closing the
|
|
fast-entity race). The server's optional ``expected_state`` hint governs only WHEN
|
|
the confirming state is settled — the waiter confirms on reaching it (skipping a
|
|
multi-phase service's intermediate states and attribute-only noise) and immediate-
|
|
matches an idempotent no-op — but the RETURNED transition is always the real
|
|
observed one, never the hint. All awaiting work (the dispatch, the immediate-match,
|
|
the bounded confirmation wait) runs in :func:`_call_service_prep`;
|
|
:func:`_do_call_service` is a pure formatter. An AUTHORITATIVE component-side
|
|
domain block refuses ``domain == "ha_mcp_tools"`` (case/whitespace-normalized)
|
|
BEFORE any ``has_service``/dispatch, independent of (and in addition to) the
|
|
server-side guard — so this second write path can NEVER be turned into an
|
|
in-process invoker of the admin-gated ``ha_mcp_tools.*`` services
|
|
(``get_caller_token`` → arbitrary config-dir file/YAML writes). A confirmation
|
|
timeout is reported as ``partial`` (``success`` still holds); a failure BEFORE
|
|
the dispatch raises, a failure AFTER it does not (the call already landed).
|
|
* ``ha_mcp_tools/bulk_call_service`` — the BATCH write capability (D5a). One frame
|
|
runs the D1 ``ha_mcp_tools`` domain block for EVERY operation FIRST, before any
|
|
dispatch or listener: a batch is fail-closed — one refused op raises the whole
|
|
frame and NOTHING dispatches, so no partial batch can smuggle a
|
|
``ha_mcp_tools.*`` (or unknown-service) op past the guard. It then registers ALL
|
|
confirmation listeners in one synchronous pass BEFORE any dispatch
|
|
(register-before-fire is trivially correct for the batch), fires the operations
|
|
(``parallel`` by default, or sequentially), and waits on ONE shared deadline for
|
|
every op's transition. A per-op ``async_call`` failure under ``parallel`` is
|
|
captured on that op's result (``error`` + ``dispatched: false``) WITHOUT aborting
|
|
the others; a post-dispatch confirmation timeout is ``partial``, never a failure.
|
|
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/config_entries`` — config entries as the ``config_entries/get``
|
|
WS shape (``created_at`` / ``modified_at`` / ``entry_id`` / ``domain`` /
|
|
``title`` / ``state`` / ``source`` / ``supports_*`` / ``supported_subentry_types``
|
|
/ ``pref_disable_*`` / ``disabled_by`` / ``reason`` / ``options`` /
|
|
``subentries`` — the full ``as_json_fragment`` field set), filtered by ``domain``
|
|
or fetched by
|
|
``entry_id``. ``state`` is serialized as ``ConfigEntryState.value`` (mirroring
|
|
core's ``as_json_fragment``). ``entry.data`` (integration credentials) is
|
|
NEVER read; ``options`` is passed through a resolved-``!secret`` scrub (an
|
|
options leaf equal to a ``secrets.yaml`` value becomes ``"**redacted**"``,
|
|
loaded off the loop by :func:`_config_entries_prep`) — data minimization
|
|
parity with the flow-helper indexing.
|
|
* ``ha_mcp_tools/registry_lookup`` — entity-registry rows
|
|
(``RegistryEntry.as_partial_dict``, the ``config/entity_registry/list`` shape,
|
|
disabled entities included) for either a set of ``entity_ids`` (missing ids in
|
|
a sibling ``missing`` list) or ALL entities bound to a ``config_entry_id``. The
|
|
config-entry scan returns EVERY match — it does not reuse the single-valued
|
|
``_entities_by_config_entry`` index, so a multi-entity flow helper
|
|
(utility_meter + its tariffs) does not silently lose its sub-entities. Exactly
|
|
one of the two is required; a request with NEITHER raises
|
|
``HomeAssistantError`` rather than silently returning an empty result.
|
|
* ``ha_mcp_tools/system_snapshot`` — one consistent synchronous pass over the
|
|
live objects the health path reads: ``config_entries`` (identity fields only —
|
|
no options/subentries), ``issues`` (the ``_overview_repairs`` slice),
|
|
``entities`` (the ``registry_lookup`` row shape), ``states``
|
|
(``State.as_dict()``). ``include_*`` flags gate each section. Reading them in a
|
|
single frame kills the 3x ``config_entries/get`` TOCTOU the server had.
|
|
* ``ha_mcp_tools/entity_lookup`` — registry entries whose ``unique_id`` matches
|
|
(optionally narrowed by ``domain`` / ``platform``), returned as
|
|
``{matches: [{entity_id, unique_id, platform, domain, config_entry_id,
|
|
categories, disabled_by, hidden_by}]}``. Multiple matches across platforms are
|
|
all returned; the server picks. The in-process read is authoritative
|
|
immediately (no registry-write settle retry).
|
|
* ``ha_mcp_tools/backup_prep`` — the backup identity the server needs before a
|
|
create: ``{agent_ids, local_agent_id, default_password}`` read from the backup
|
|
integration's in-process ``DATA_MANAGER``. ``local_agent_id`` uses the SAME
|
|
preference the server's ``_get_local_backup_agent_id`` does (``hassio.local``
|
|
over ``backup.local``). A missing backup integration / manager raises
|
|
``HomeAssistantError`` so the server's command-error fallback fires. The
|
|
password is sensitive but the legacy ``backup/config/info`` already serves it
|
|
to the same admin connection — parity, not new exposure.
|
|
* ``ha_mcp_tools/registries`` — the area / floor / label / category registries as
|
|
the FULL-FIELD ``config/<x>_registry/list`` shapes (byte-compatible with the
|
|
legacy WS list responses; timestamps as ``created_at`` / ``modified_at``
|
|
floats via ``.timestamp()``). Only the requested ``registries`` keys are
|
|
present; ``category`` REQUIRES a non-empty ``category_scopes`` (categories are
|
|
scoped) — a ``category`` request without one raises ``HomeAssistantError``
|
|
rather than silently serving ``{}``. ``category_registry`` is imported
|
|
function-locally (not needed at module top).
|
|
|
|
``ha_mcp_tools/config_get`` was withdrawn before release: it served an entity's
|
|
``raw_config``, whose freshness lags the config file between a write and the next
|
|
completed reload (no version marker distinguishes a fresh body from a stale one),
|
|
so a get racing a reload returned a pre-edit body. ``ha_config_get_{automation,
|
|
script}`` stay on the legacy REST path (which reads the fresh config file);
|
|
scenes were already legacy-only. A file-reading redesign may return (issue #1813).
|
|
|
|
Design notes that are load-bearing:
|
|
|
|
* **Capability negotiation, not version-lockstep.** ``CAPABILITIES`` grows one
|
|
entry per shipped command (except the always-present ``info`` handshake); the
|
|
server asks "do you support ``search``?" rather than "are you >= X". The
|
|
manifest version is reported for display only.
|
|
* **Data minimization.** Flow-helper indexing reads ``ConfigEntry.options`` /
|
|
``title`` only — **never** ``ConfigEntry.data`` (integration credentials).
|
|
* **YAML config bodies are never emitted.** automation/script/scene bodies are
|
|
indexed for *matching*, but a matched item's ``config`` body is returned only
|
|
when it is storage/editor-backed AND ``include_config`` is set. YAML-loaded
|
|
items return identity/metadata only (their ``raw_config`` may carry resolved
|
|
``!secret`` plaintext). Body emission for YAML belongs to a future file-based
|
|
tool.
|
|
* **Resolved secrets are scrubbed from the match corpus.** Because YAML bodies
|
|
(and flow-helper options) can hold ``!secret`` values resolved to plaintext,
|
|
a body leaf that exactly equals a ``secrets.yaml`` value is dropped before
|
|
scoring (:func:`_load_secret_values`) — otherwise a query equal to a suspected
|
|
secret would confirm it via ``match_in_config`` (a probe oracle). Blocked, not
|
|
merely unemitted.
|
|
* **Event-loop hygiene.** Every registry/state join is a pure in-memory read
|
|
over live data — run synchronously, no persistent index (always fresh, zero
|
|
cache-invalidation surface). The one blocking read — ``secrets.yaml`` for the
|
|
match-corpus scrub — runs in the executor via the command wrapper's async
|
|
pre-step (:func:`_search_prep`), never on the event loop.
|
|
|
|
Extension point — to add another command later: write ``_do_<name>(hass,
|
|
params)``, append its capability to :data:`CAPABILITIES`, and add one row to
|
|
:func:`_command_specs`. ``info`` enumerates the rest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from difflib import SequenceMatcher
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
import yaml # type: ignore[import-untyped]
|
|
from homeassistant.components import websocket_api
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import (
|
|
area_registry as ar,
|
|
)
|
|
from homeassistant.helpers import (
|
|
device_registry as dr,
|
|
)
|
|
from homeassistant.helpers import (
|
|
entity_registry as er,
|
|
)
|
|
from homeassistant.helpers import (
|
|
floor_registry as fr,
|
|
)
|
|
from homeassistant.helpers import (
|
|
issue_registry as ir,
|
|
)
|
|
from homeassistant.helpers import (
|
|
label_registry as lr,
|
|
)
|
|
|
|
from .const import (
|
|
CHANNEL_DEV,
|
|
CHANNEL_STABLE,
|
|
COMPONENT_VERSION,
|
|
CONF_ENTRY_TYPE,
|
|
DEFAULT_PIP_SPEC,
|
|
DOMAIN,
|
|
ENTRY_TYPE_SERVER,
|
|
OPT_CHANNEL,
|
|
OPT_PIP_SPEC,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# --- Wire contract -----------------------------------------------------------
|
|
WS_API_PREFIX = "ha_mcp_tools"
|
|
WS_INFO = f"{WS_API_PREFIX}/info"
|
|
WS_SEARCH = f"{WS_API_PREFIX}/search"
|
|
WS_OVERVIEW = f"{WS_API_PREFIX}/overview"
|
|
WS_HELPERS_LIST = f"{WS_API_PREFIX}/helpers_list"
|
|
WS_STATES = f"{WS_API_PREFIX}/states"
|
|
WS_BLUEPRINT_GET = f"{WS_API_PREFIX}/blueprint_get"
|
|
WS_DEVICE_GET = f"{WS_API_PREFIX}/device_get"
|
|
WS_DEVICE_LIST = f"{WS_API_PREFIX}/device_list"
|
|
WS_ENTITY_ENRICH = f"{WS_API_PREFIX}/entity_enrich"
|
|
WS_EXPOSURE = f"{WS_API_PREFIX}/exposure"
|
|
WS_CONFIG_ENTRIES = f"{WS_API_PREFIX}/config_entries"
|
|
WS_REGISTRY_LOOKUP = f"{WS_API_PREFIX}/registry_lookup"
|
|
WS_SYSTEM_SNAPSHOT = f"{WS_API_PREFIX}/system_snapshot"
|
|
WS_ENTITY_LOOKUP = f"{WS_API_PREFIX}/entity_lookup"
|
|
WS_BACKUP_PREP = f"{WS_API_PREFIX}/backup_prep"
|
|
WS_REGISTRIES = f"{WS_API_PREFIX}/registries"
|
|
WS_DASHBOARDS = f"{WS_API_PREFIX}/dashboards"
|
|
WS_SERVICES_LIST = f"{WS_API_PREFIX}/services_list"
|
|
WS_REFERENCE_DATA = f"{WS_API_PREFIX}/reference_data"
|
|
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"
|
|
|
|
# Wire-format generation of the request/response envelopes. Bumped only on an
|
|
# *incompatible* shape change to an existing command; additive fields do not
|
|
# 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
|
|
# 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",
|
|
"overview",
|
|
"helpers_list",
|
|
"states",
|
|
"blueprint_get",
|
|
"device_get",
|
|
"device_list",
|
|
"entity_enrich",
|
|
"exposure",
|
|
"config_entries",
|
|
"registry_lookup",
|
|
"system_snapshot",
|
|
"entity_lookup",
|
|
"backup_prep",
|
|
"registries",
|
|
"dashboards",
|
|
"services_list",
|
|
"reference_data",
|
|
# A flag, not a standalone command: gates the optional ``visibility`` param
|
|
# the server may pass to ``ha_mcp_tools/search`` so an old component that
|
|
# would ignore the param is never sent it (param-sniffing is banned for
|
|
# routing; the CAPABILITIES flag is what the server gates on).
|
|
"search_visibility",
|
|
"server_entry",
|
|
# The WRITE counterpart of ``server_entry`` (Phase 3). The server gates its
|
|
# ``ha_dev_manage_server(update_source)`` embedded-mode direct-write on this; an
|
|
# old component that lacks it is never sent the frame and the server stays on its
|
|
# legacy options-flow submit.
|
|
"server_entry_update",
|
|
# The first WRITE capability (Phase 3). The server gates its ``ha_call_service``
|
|
# component route on this; an old component that lacks it is never sent a
|
|
# component write and stays on the legacy REST path.
|
|
"call_service",
|
|
# The BATCH write capability (Phase 3, D5a). The server gates its bulk-control
|
|
# 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 registry kinds ``ha_mcp_tools/registries`` can serve. The WS schema gates
|
|
# on this so an out-of-range kind never reaches the reader; ``category`` also
|
|
# requires ``category_scopes`` (categories are scoped).
|
|
REGISTRY_KINDS = ("area", "floor", "label", "category")
|
|
|
|
# Blueprint domains this component will read a body for. Mirrors core's blueprint
|
|
# domains; the WS schema gates on it so an out-of-range domain never reaches the
|
|
# path jail. Kept next to the blueprint command it governs.
|
|
BLUEPRINT_DOMAINS = ("automation", "script")
|
|
|
|
# Advisory caps advertised in ``info.limits`` so no single WS frame balloons.
|
|
MAX_RESULTS = 500
|
|
MAX_BODY_BYTES = 1_000_000
|
|
LIMITS = {"max_results": MAX_RESULTS, "max_body_bytes": MAX_BODY_BYTES}
|
|
|
|
DEFAULT_LIMIT = 10
|
|
|
|
# ``call_service`` confirmation-wait bounds. The default mirrors the legacy
|
|
# ``ha_call_service`` 10s subscribe-and-sample window; the cap bounds a
|
|
# caller-supplied ``timeout`` (schema ``vol.Range``) so a single write frame can
|
|
# never park the WS connection for longer than this. ``blocking=True`` only means
|
|
# HA finished DISPATCHING — a mesh device (Zigbee/Z-Wave) may still be settling —
|
|
# so the wait is bounded and its expiry is ``partial``, never a failure (D4).
|
|
CALL_SERVICE_DEFAULT_TIMEOUT = 10.0
|
|
CALL_SERVICE_MAX_TIMEOUT = 60.0
|
|
|
|
# Delay before ``server_entry_update``'s deferred ``async_update_entry`` fires, so
|
|
# the WS ``{scheduled: True}`` response flushes to the calling (embedded) server
|
|
# BEFORE the resulting entry reload tears that server's thread down. Mirrors the
|
|
# server side's ``tools_dev._SELF_ACTION_FLUSH_DELAY_S`` (its legacy options-flow
|
|
# self-restart uses the same headroom for the ingress/webhook hop).
|
|
SERVER_ENTRY_UPDATE_FLUSH_DELAY_S = 1.0
|
|
|
|
# pip_spec defence-in-depth cap (the SERVER already validates per D6): a single-line
|
|
# requirement string under this many chars. Mirrors ``tools_dev._update_source``.
|
|
SERVER_ENTRY_UPDATE_MAX_PIP_SPEC = 500
|
|
|
|
# Fuzzy floor + hidden penalty, mirrored from the server so the two scorers do
|
|
# not drift (guarded by the golden parity test).
|
|
FUZZY_THRESHOLD = 70
|
|
HIDDEN_SCORE_PENALTY = 20
|
|
|
|
# --- Search surfaces ---------------------------------------------------------
|
|
SEARCH_TYPE_ENTITY = "entity"
|
|
SEARCH_TYPE_AUTOMATION = "automation"
|
|
SEARCH_TYPE_SCRIPT = "script"
|
|
SEARCH_TYPE_SCENE = "scene"
|
|
SEARCH_TYPE_HELPER = "helper"
|
|
ALL_SEARCH_TYPES = [
|
|
SEARCH_TYPE_ENTITY,
|
|
SEARCH_TYPE_AUTOMATION,
|
|
SEARCH_TYPE_SCRIPT,
|
|
SEARCH_TYPE_SCENE,
|
|
SEARCH_TYPE_HELPER,
|
|
]
|
|
# raw_config surfaces reached via each domain's EntityComponent in hass.data.
|
|
CONFIG_SEARCH_TYPES = (
|
|
SEARCH_TYPE_AUTOMATION,
|
|
SEARCH_TYPE_SCRIPT,
|
|
SEARCH_TYPE_SCENE,
|
|
)
|
|
|
|
# Collection ("storage collection") helpers — entities in the state machine.
|
|
# Matched on entity_id / friendly_name AND the live state-attribute body (an
|
|
# input_select's ``options``, an input_number's ``min``/``max``/``step``, …).
|
|
COLLECTION_HELPER_DOMAINS = frozenset(
|
|
{
|
|
"input_boolean",
|
|
"input_number",
|
|
"input_text",
|
|
"input_select",
|
|
"input_datetime",
|
|
"input_button",
|
|
"counter",
|
|
"timer",
|
|
"schedule",
|
|
}
|
|
)
|
|
# Flow (config-entry-backed) helpers. Indexed from ``entry.options`` / ``title``
|
|
# directly — no OptionsFlow start/abort dance, and NEVER ``entry.data``.
|
|
FLOW_HELPER_DOMAINS = frozenset(
|
|
{
|
|
"template",
|
|
"group",
|
|
"utility_meter",
|
|
"threshold",
|
|
"derivative",
|
|
"integration",
|
|
"min_max",
|
|
"statistics",
|
|
"trend",
|
|
"tod",
|
|
"random",
|
|
"switch_as_x",
|
|
"mold_indicator",
|
|
"history_stats",
|
|
"bayesian",
|
|
"filter",
|
|
"generic_thermostat",
|
|
"generic_hygrostat",
|
|
"combine",
|
|
}
|
|
)
|
|
|
|
# Collection helper domains enumerated by ``ha_mcp_tools/helpers_list``: the
|
|
# collection helpers ``search`` indexes PLUS zone/person, which are state-machine
|
|
# entities the server's ``ha_config_list_helpers`` also accepts. Kept SEPARATE
|
|
# from :data:`COLLECTION_HELPER_DOMAINS` so search behaviour is unchanged — zones
|
|
# and persons are not indexed as "helpers" by ``ha_mcp_tools/search``.
|
|
#
|
|
# ``tag`` is deliberately EXCLUDED: tags are a storage collection with no state
|
|
# entity (the server reaches them via ``tag/list``, and its create/list paths
|
|
# special-case ``tag`` precisely because it has no entity_id), so a from-states
|
|
# scan can never enumerate them. Advertising it as covered would make an empty
|
|
# result indistinguishable from "no tags exist" (a silent-wrong listing); it is
|
|
# left OUT of ``covered_types`` so the server falls back to its legacy
|
|
# ``tag/list`` path for that type. See :func:`_do_helpers_list`.
|
|
HELPERS_LIST_COLLECTION_DOMAINS = COLLECTION_HELPER_DOMAINS | frozenset(
|
|
{"zone", "person"}
|
|
)
|
|
|
|
# Every ``EntityComponent`` self-registers here (core's
|
|
# ``entity_component.DATA_INSTANCES``). Collection-helper domains (input_*,
|
|
# counter, timer, schedule) do NOT set ``hass.data[DOMAIN]`` and their
|
|
# ``StorageCollection`` is a setup-local (``helpers/collection.py`` writes
|
|
# nothing to ``hass.data``), so this registry is how their component — and thus
|
|
# each entity's storage ``_config`` body — is reached. See
|
|
# :func:`_collection_storage_index`.
|
|
ENTITY_COMPONENTS_KEY = "entity_components"
|
|
|
|
_SPLIT_RE = re.compile(r"[._\-\s]+")
|
|
|
|
|
|
# =============================================================================
|
|
# Registration (thin @websocket_command wrappers over the pure `_do_*` funcs)
|
|
# =============================================================================
|
|
def async_register_commands(hass: HomeAssistant) -> None:
|
|
"""Register the ``ha_mcp_tools/*`` WebSocket commands.
|
|
|
|
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.
|
|
"""
|
|
for schema, do_fn, prep in _command_specs():
|
|
websocket_api.async_register_command(hass, _build_handler(schema, do_fn, prep))
|
|
_LOGGER.debug(
|
|
"Registered ha_mcp_tools WS commands: schema_version=%s capabilities=%s",
|
|
SCHEMA_VERSION,
|
|
CAPABILITIES,
|
|
)
|
|
|
|
|
|
def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
|
|
"""The (schema, pure-handler, async-prep) rows. Append one row per command.
|
|
|
|
``prep`` (or ``None``) is an ``async`` pre-step run before the pure handler;
|
|
it returns keyword args merged into the ``do_fn`` call. It is the seam for a
|
|
command that must touch the filesystem/network off the event loop —
|
|
:func:`_search_prep` loads ``secrets.yaml`` in the executor — keeping every
|
|
``_do_*`` function a pure, synchronous in-memory read.
|
|
"""
|
|
return [
|
|
(_info_schema(), lambda hass, msg: _do_info(hass), None),
|
|
(_search_schema(), _do_search, _search_prep),
|
|
(_overview_schema(), _do_overview, None),
|
|
(_helpers_list_schema(), _do_helpers_list, _helpers_list_prep),
|
|
(_states_schema(), _do_states, None),
|
|
(_blueprint_get_schema(), _do_blueprint_get, _blueprint_get_prep),
|
|
(_device_get_schema(), _do_device_get, None),
|
|
(_device_list_schema(), _do_device_list, None),
|
|
(_entity_enrich_schema(), _do_entity_enrich, None),
|
|
(_exposure_schema(), _do_exposure, None),
|
|
(_config_entries_schema(), _do_config_entries, _config_entries_prep),
|
|
(_registry_lookup_schema(), _do_registry_lookup, None),
|
|
(_system_snapshot_schema(), _do_system_snapshot, None),
|
|
(_entity_lookup_schema(), _do_entity_lookup, None),
|
|
(_backup_prep_schema(), _do_backup_prep, None),
|
|
(_registries_schema(), _do_registries, None),
|
|
(_dashboards_schema(), _do_dashboards, _dashboards_prep),
|
|
(_services_list_schema(), _do_services_list, _services_list_prep),
|
|
(_reference_data_schema(), _do_reference_data, None),
|
|
(_server_entry_schema(), _do_server_entry, None),
|
|
# The WRITE counterpart of server_entry: the deferred ``async_update_entry``
|
|
# scheduling is inherently async, so it lives in ``_server_entry_update_prep``
|
|
# and ``_do_server_entry_update`` is a pure formatter (same seam as the
|
|
# call_service write below).
|
|
(
|
|
_server_entry_update_schema(),
|
|
_do_server_entry_update,
|
|
_server_entry_update_prep,
|
|
),
|
|
# The first WRITE command: the dispatch + the bounded confirmation wait are
|
|
# inherently async, so ALL of the work lives in the ``_call_service_prep``
|
|
# async pre-step and ``_do_call_service`` is a pure response formatter.
|
|
(_call_service_schema(), _do_call_service, _call_service_prep),
|
|
# The BATCH write command (D5a): the same async seam — all guards, the
|
|
# register-before-fire pass, the dispatches, and the bounded wait live in
|
|
# ``_bulk_call_service_prep``; ``_do_bulk_call_service`` is a pure formatter.
|
|
(
|
|
_bulk_call_service_schema(),
|
|
_do_bulk_call_service,
|
|
_bulk_call_service_prep,
|
|
),
|
|
]
|
|
|
|
|
|
def _build_handler(schema: dict[Any, Any], do_fn: Any, prep: Any = None) -> Any:
|
|
"""Wrap a pure ``_do_*`` function as an admin-gated WS command handler.
|
|
|
|
An optional ``prep`` async pre-step runs first (off-loop I/O such as the
|
|
``secrets.yaml`` read); the keyword args it returns are passed to ``do_fn``.
|
|
"""
|
|
|
|
@websocket_api.websocket_command(schema)
|
|
@websocket_api.require_admin
|
|
@websocket_api.async_response
|
|
async def _handler(
|
|
hass: HomeAssistant, connection: Any, msg: dict[str, Any]
|
|
) -> None:
|
|
extra = await prep(hass, msg) if prep is not None else {}
|
|
connection.send_result(msg["id"], do_fn(hass, msg, **extra))
|
|
|
|
return _handler
|
|
|
|
|
|
def _info_schema() -> dict[Any, Any]:
|
|
return {vol.Required("type"): WS_INFO}
|
|
|
|
|
|
# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type
|
|
# (seven id/name lists, two bool flags). Kept in lockstep with the server's
|
|
# ``to_wire`` and :func:`_visibility_hidden_set`; a new dimension is a new component
|
|
# capability, added on BOTH sides (see :func:`_visibility_param_schema`). The union
|
|
# is pinned equal to the server resolver's key set by the cross-seam contract test.
|
|
_VISIBILITY_LIST_KEYS = (
|
|
"exclude_categories",
|
|
"deny_entity_ids",
|
|
"exclude_areas",
|
|
"exclude_labels",
|
|
"allow_entity_ids",
|
|
"allow_areas",
|
|
"allow_labels",
|
|
)
|
|
_VISIBILITY_BOOL_KEYS = ("exclude_hidden", "respect_assist_exposure")
|
|
|
|
|
|
def _visibility_param_schema() -> Any:
|
|
"""Voluptuous schema for the ``search`` ``visibility`` dict — exactly the nine keys.
|
|
|
|
Enumerating the known dimensions (PREVENT_EXTRA is voluptuous' default for a
|
|
nested ``Schema``) makes an unknown key a loud ``invalid_format`` command error
|
|
rather than a silent drop. If a newer server emits a tenth dimension to this
|
|
1.2.0 component, the server's error taxonomy converts that into a legacy fallback
|
|
with the filter STILL correctly applied — structural fail-closed for free —
|
|
instead of partial, unwarned filtering. Built at call time so it honors the
|
|
monkeypatched ``vol`` in the unit suite.
|
|
"""
|
|
schema: dict[Any, Any] = {vol.Optional(key): [str] for key in _VISIBILITY_LIST_KEYS}
|
|
schema.update({vol.Optional(key): bool for key in _VISIBILITY_BOOL_KEYS})
|
|
return vol.Schema(schema)
|
|
|
|
|
|
def _search_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_SEARCH,
|
|
vol.Optional("query"): vol.Any(str, None),
|
|
vol.Optional("search_types"): [vol.In(ALL_SEARCH_TYPES)],
|
|
vol.Optional("domain_filter"): str,
|
|
vol.Optional("area_filter"): str,
|
|
vol.Optional("state_filter"): str,
|
|
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("offset", default=0): vol.All(int, vol.Range(min=0)),
|
|
# Opt-in entity-visibility filter (``search_visibility`` capability): the
|
|
# server's raw VisibilityConfig hide dimensions. When present and
|
|
# non-empty, hidden entities are excluded from the entity results before
|
|
# counts/pagination, mirroring the legacy ``load_hidden_set`` hard-exclude
|
|
# so ``ha_search`` can drop its "filter active -> legacy only" gate. Gated
|
|
# by the CAPABILITIES flag, so an old component never receives it. The nine
|
|
# known keys are enumerated so an unknown dimension fails loudly (the server
|
|
# then falls back to legacy with the filter applied) — see
|
|
# :func:`_visibility_param_schema`.
|
|
vol.Optional("visibility"): _visibility_param_schema(),
|
|
}
|
|
|
|
|
|
def _overview_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_OVERVIEW,
|
|
vol.Optional("include_notifications", default=True): bool,
|
|
vol.Optional("include_repairs", default=True): bool,
|
|
}
|
|
|
|
|
|
def _helpers_list_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_HELPERS_LIST,
|
|
vol.Optional("helper_types"): [str],
|
|
vol.Optional("include_flow_helpers", default=True): bool,
|
|
}
|
|
|
|
|
|
def _states_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_STATES,
|
|
vol.Required("entity_ids"): [str],
|
|
}
|
|
|
|
|
|
def _blueprint_get_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_BLUEPRINT_GET,
|
|
vol.Required("domain"): vol.In(BLUEPRINT_DOMAINS),
|
|
vol.Required("path"): str,
|
|
}
|
|
|
|
|
|
def _device_get_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_DEVICE_GET,
|
|
vol.Required("device_id"): str,
|
|
vol.Optional("include_entities", default=False): bool,
|
|
}
|
|
|
|
|
|
def _device_list_schema() -> dict[Any, Any]:
|
|
return {vol.Required("type"): WS_DEVICE_LIST}
|
|
|
|
|
|
def _entity_enrich_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_ENTITY_ENRICH,
|
|
vol.Required("entity_ids"): [str],
|
|
}
|
|
|
|
|
|
def _exposure_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_EXPOSURE,
|
|
vol.Optional("entity_id"): vol.Any(str, None),
|
|
}
|
|
|
|
|
|
def _config_entries_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_CONFIG_ENTRIES,
|
|
vol.Optional("entry_id"): vol.Any(str, None),
|
|
vol.Optional("domain"): vol.Any(str, None),
|
|
}
|
|
|
|
|
|
def _registry_lookup_schema() -> dict[Any, Any]:
|
|
# Exactly one of entity_ids / config_entry_id is meaningful; ``vol.Exclusive``
|
|
# rejects a request carrying BOTH (the two share the ``target`` group).
|
|
# Voluptuous has no clean way to express "at least one of" in a flat schema,
|
|
# so a request with NEITHER present is caught in ``_do_registry_lookup``
|
|
# instead (raises ``HomeAssistantError`` rather than a silent empty result).
|
|
return {
|
|
vol.Required("type"): WS_REGISTRY_LOOKUP,
|
|
vol.Exclusive("entity_ids", "target"): [str],
|
|
vol.Exclusive("config_entry_id", "target"): str,
|
|
}
|
|
|
|
|
|
def _system_snapshot_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_SYSTEM_SNAPSHOT,
|
|
vol.Optional("include_states", default=True): bool,
|
|
vol.Optional("include_entities", default=True): bool,
|
|
vol.Optional("include_issues", default=True): bool,
|
|
vol.Optional("include_config_entries", default=True): bool,
|
|
}
|
|
|
|
|
|
def _entity_lookup_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_ENTITY_LOOKUP,
|
|
vol.Required("unique_id"): str,
|
|
vol.Optional("domain"): vol.Any(str, None),
|
|
vol.Optional("platform"): vol.Any(str, None),
|
|
}
|
|
|
|
|
|
def _backup_prep_schema() -> dict[Any, Any]:
|
|
return {vol.Required("type"): WS_BACKUP_PREP}
|
|
|
|
|
|
def _registries_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_REGISTRIES,
|
|
vol.Required("registries"): [vol.In(REGISTRY_KINDS)],
|
|
vol.Optional("category_scopes"): [str],
|
|
}
|
|
|
|
|
|
def _dashboards_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_DASHBOARDS,
|
|
vol.Optional("mode", default="list"): vol.In(("list", "get", "search")),
|
|
# ``None``/absent url_path = the default dashboard (``get`` mode).
|
|
vol.Optional("url_path"): vol.Any(str, None),
|
|
vol.Optional("query"): vol.Any(str, None),
|
|
}
|
|
|
|
|
|
def _services_list_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_SERVICES_LIST,
|
|
vol.Optional("domain"): vol.Any(str, None),
|
|
vol.Optional("language", default="en"): str,
|
|
}
|
|
|
|
|
|
def _reference_data_schema() -> dict[Any, Any]:
|
|
return {
|
|
vol.Required("type"): WS_REFERENCE_DATA,
|
|
vol.Optional("include_states", default=True): bool,
|
|
}
|
|
|
|
|
|
def _server_entry_schema() -> dict[Any, Any]:
|
|
return {vol.Required("type"): WS_SERVER_ENTRY}
|
|
|
|
|
|
def _single_line_pip_spec(value: str) -> str:
|
|
"""Reject a multi-line / control-char ``pip_spec`` (defence-in-depth over D6)."""
|
|
if any(ord(c) < 32 for c in value):
|
|
raise vol.Invalid("pip_spec must be a single-line string")
|
|
return value
|
|
|
|
|
|
def _server_entry_update_schema() -> dict[Any, Any]:
|
|
# Both fields are optional at the schema level (voluptuous cannot cleanly express
|
|
# "at least one of"); ``_server_entry_update_prep`` raises when NEITHER is present.
|
|
# ``channel`` is gated to the known set and ``pip_spec`` gets the length +
|
|
# single-line cap — schema-level defence-in-depth over (and symmetric with) the
|
|
# server's own channel validation (D6) and pip-spec normalization.
|
|
return {
|
|
vol.Required("type"): WS_SERVER_ENTRY_UPDATE,
|
|
vol.Optional("channel"): vol.In((CHANNEL_STABLE, CHANNEL_DEV)),
|
|
vol.Optional("pip_spec"): vol.All(
|
|
str,
|
|
vol.Length(max=SERVER_ENTRY_UPDATE_MAX_PIP_SPEC),
|
|
_single_line_pip_spec,
|
|
),
|
|
}
|
|
|
|
|
|
def _call_service_schema() -> dict[Any, Any]:
|
|
# ``entity_ids`` is the set of targets to CONFIRM (the pre/post transition is
|
|
# built for these), not the service target itself — a caller may pass an empty
|
|
# list for a non-entity service (``automation.trigger`` etc.) and still get the
|
|
# dispatch result. ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a
|
|
# single write frame cannot park the connection. Mutable defaults use the
|
|
# callable form so each validation produces a fresh ``{}`` / ``[]``.
|
|
return {
|
|
vol.Required("type"): WS_CALL_SERVICE,
|
|
vol.Required("domain"): str,
|
|
vol.Required("service"): str,
|
|
vol.Optional("service_data", default=dict): dict,
|
|
vol.Optional("entity_ids", default=list): [str],
|
|
vol.Optional("wait", default=True): bool,
|
|
vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All(
|
|
vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT)
|
|
),
|
|
vol.Optional("return_response", default=False): bool,
|
|
# Optional confirmation HINT (the server's ``_SERVICE_TO_STATE.get(service)``,
|
|
# or None): the expected primary state the waiter confirms on REACHING —
|
|
# skipping a multi-phase service's intermediate states and attribute-only
|
|
# noise — and immediate-matches for an idempotent no-op. Optional/default-None
|
|
# so a server that does not send it (or a non-mapped service) keeps today's
|
|
# any-first-event confirmation. It governs confirmation TIMING only; the
|
|
# returned transition is always the REAL observed one.
|
|
vol.Optional("expected_state"): vol.Any(str, None),
|
|
}
|
|
|
|
|
|
def _bulk_call_service_schema() -> dict[Any, Any]:
|
|
# A batch of fully-resolved operations, each the single ``call_service`` row
|
|
# minus its own ``wait`` / ``timeout`` / ``return_response`` (those are batch
|
|
# scoped: one ``wait`` flag, one shared ``timeout`` deadline, and no per-op
|
|
# ``return_response`` — bulk stays simple, the single call covers that need).
|
|
# ``operations`` must be non-empty (an empty batch is a caller error, not a
|
|
# no-op). ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a single
|
|
# batch frame cannot park the connection past that bound. Mutable per-op
|
|
# defaults use the callable form so each validation yields a fresh ``{}`` /
|
|
# ``[]``.
|
|
operation = {
|
|
vol.Required("domain"): str,
|
|
vol.Required("service"): str,
|
|
vol.Optional("service_data", default=dict): dict,
|
|
vol.Optional("entity_ids", default=list): [str],
|
|
# Per-op confirmation HINT (see ``_call_service_schema``): optional/default-
|
|
# None so an older server (or a non-mapped service) keeps any-first-event
|
|
# confirmation for that op.
|
|
vol.Optional("expected_state"): vol.Any(str, None),
|
|
}
|
|
return {
|
|
vol.Required("type"): WS_BULK_CALL_SERVICE,
|
|
vol.Required("operations"): vol.All([operation], vol.Length(min=1)),
|
|
vol.Optional("parallel", default=True): bool,
|
|
vol.Optional("wait", default=True): bool,
|
|
vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All(
|
|
vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT)
|
|
),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/info
|
|
# =============================================================================
|
|
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
|
|
``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``.
|
|
"""
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"component_version": COMPONENT_VERSION,
|
|
"capabilities": list(CAPABILITIES),
|
|
"limits": dict(LIMITS),
|
|
"timezone": _config_time_zone(hass),
|
|
}
|
|
|
|
|
|
def _config_time_zone(hass: HomeAssistant | None) -> str | None:
|
|
"""``hass.config.time_zone`` as a string, guarded against a hass-less call."""
|
|
config = getattr(hass, "config", None)
|
|
tz = getattr(config, "time_zone", None)
|
|
return tz if isinstance(tz, str) and tz else None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/search
|
|
# =============================================================================
|
|
@dataclass
|
|
class _RegistryView:
|
|
"""Bundle of the five HA registries (any may be ``None`` if unavailable)."""
|
|
|
|
entity: Any = None
|
|
area: Any = None
|
|
floor: Any = None
|
|
label: Any = None
|
|
device: Any = None
|
|
|
|
|
|
def _resolve_registries(hass: HomeAssistant) -> _RegistryView:
|
|
"""Snapshot the five registries. Test seam — monkeypatched in unit tests."""
|
|
return _RegistryView(
|
|
entity=_safe(er.async_get, hass),
|
|
area=_safe(ar.async_get, hass),
|
|
floor=_safe(fr.async_get, hass),
|
|
label=_safe(lr.async_get, hass),
|
|
device=_safe(dr.async_get, hass),
|
|
)
|
|
|
|
|
|
def _safe(fn: Any, hass: HomeAssistant) -> Any:
|
|
try:
|
|
return fn(hass)
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return None
|
|
|
|
|
|
def _substrate_unavailable(name: str) -> Exception:
|
|
"""Build a ``HomeAssistantError`` for a drifted / unavailable core substrate.
|
|
|
|
A core registry / service / state accessor that RAISED or was renamed comes
|
|
back as ``None`` (registries, via ``_safe``) or a non-``Mapping``
|
|
(services / descriptions) from the guarded readers. For the reads whose WHOLE
|
|
answer is that substrate — ``entity_lookup``, ``registries``,
|
|
``reference_data``, ``services_list`` — returning a well-formed EMPTY would let
|
|
the server trust an authoritative-negative it should instead fall back to legacy
|
|
for (mistaking core DRIFT for "no such entry" / "empty catalog"). Raising routes
|
|
the server's command-error path to its legacy WS/REST read, mirroring
|
|
:func:`_backup_unavailable` / :func:`_registries_missing_category_scopes`. A
|
|
genuinely-EMPTY-but-present substrate (a real empty registry) is NOT drift and
|
|
keeps returning its empty result — the guards below key off unavailability
|
|
(``None`` / non-``Mapping``), never off an empty-but-valid collection.
|
|
"""
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
err: Exception = HomeAssistantError(
|
|
f"ha_mcp_tools: the {name} is unavailable (core drift); the server should "
|
|
"fall back to its legacy read"
|
|
)
|
|
return err
|
|
|
|
|
|
def _do_search(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> dict[str, Any]:
|
|
"""Unified in-process search. Pure over ``hass`` — the WS wrapper is thin.
|
|
|
|
Joins live registries + states, scores per the server's tiers, paginates
|
|
per surface, and returns the ``ha_search``-shaped envelope.
|
|
|
|
``secret_values`` is the resolved-``!secret`` scrub set, loaded off the event
|
|
loop by :func:`_search_prep` and passed in (default empty — the loader is
|
|
skipped for an entity-only search, and direct callers/tests supply it
|
|
explicitly). It keeps this function a pure, synchronous in-memory read.
|
|
"""
|
|
query_lower = (params.get("query") or "").strip().lower()
|
|
match_all = not query_lower
|
|
exact = params.get("exact", True)
|
|
include_hidden = params.get("include_hidden", True)
|
|
include_config = params.get("include_config", False)
|
|
limit = params.get("limit", DEFAULT_LIMIT)
|
|
offset = params.get("offset", 0)
|
|
search_types = params.get("search_types") or ALL_SEARCH_TYPES
|
|
domain_filter = params.get("domain_filter")
|
|
area_filter = params.get("area_filter")
|
|
state_filter = params.get("state_filter")
|
|
# 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")
|
|
|
|
view = _resolve_registries(hass)
|
|
diagnostics: dict[str, int] = {}
|
|
partial_reasons: list[str] = []
|
|
# Visibility degradation warnings (unknown category / empty-registry allowlist /
|
|
# Assist unavailable), collected in the entity block below when a visibility
|
|
# 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] = []
|
|
|
|
# ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
|
|
# plaintext from the config-body match corpus: a YAML-loaded automation/script/
|
|
# scene body (or a flow-helper's options) can carry a secret resolved to
|
|
# plaintext, and matching inside it would make ha_search a probe oracle (query
|
|
# a suspected secret, confirm via match_in_config). See _load_secret_values.
|
|
|
|
# --- Entities ------------------------------------------------------------
|
|
entities: list[dict[str, Any]] = []
|
|
entity_total = 0
|
|
entity_has_more = False
|
|
if SEARCH_TYPE_ENTITY in search_types:
|
|
scored_entities = _search_entities(
|
|
hass,
|
|
view,
|
|
query_lower,
|
|
match_all=match_all,
|
|
exact=exact,
|
|
include_hidden=include_hidden,
|
|
domain_filter=domain_filter,
|
|
area_filter=area_filter,
|
|
state_filter=state_filter,
|
|
)
|
|
# Opt-in visibility filter: a hard exclude applied BEFORE counts/pagination,
|
|
# exactly where the legacy path drops ``visibility_hidden`` entities at the
|
|
# top of ``_match_exact_search_entity`` (independent of ``include_hidden``,
|
|
# which the ``_search_entities`` join already applied). See
|
|
# :func:`_visibility_hidden_set`.
|
|
if isinstance(visibility, Mapping) and visibility:
|
|
states_list = _iter_states(hass)
|
|
# Probe Assist availability once (only when the config asks for it) so a
|
|
# requested-but-unavailable Assist dimension both skips its hiding and
|
|
# surfaces the resolver-parity degradation warning.
|
|
assist_available = (
|
|
_assist_exposure_available(hass)
|
|
if visibility.get("respect_assist_exposure")
|
|
else True
|
|
)
|
|
hidden = _visibility_hidden_set(
|
|
view,
|
|
states_list,
|
|
visibility,
|
|
lambda eid: _assist_should_expose(hass, eid),
|
|
assist_available=assist_available,
|
|
)
|
|
visibility_warnings = _visibility_warnings(
|
|
view, states_list, visibility, assist_available=assist_available
|
|
)
|
|
if hidden:
|
|
scored_entities = [
|
|
r for r in scored_entities if r["entity_id"] not in hidden
|
|
]
|
|
scored_entities.sort(key=lambda r: (-r["score"], r["entity_id"]))
|
|
entity_total = len(scored_entities)
|
|
page = scored_entities[offset : offset + limit]
|
|
entity_has_more = offset + len(page) < entity_total
|
|
entities = [_project_entity(r) for r in page]
|
|
|
|
# --- Config surfaces (automations + scripts + scenes + helpers) ----------
|
|
# One combined pagination window, mirroring the server's config branch.
|
|
combined: list[tuple[str, dict[str, Any]]] = []
|
|
for domain in CONFIG_SEARCH_TYPES:
|
|
if domain in search_types:
|
|
combined.extend(
|
|
(domain, rec)
|
|
for rec in _search_config_surface(
|
|
hass,
|
|
view,
|
|
domain,
|
|
query_lower,
|
|
match_all=match_all,
|
|
exact=exact,
|
|
include_config=include_config,
|
|
partial_reasons=partial_reasons,
|
|
diagnostics=diagnostics,
|
|
secret_values=secret_values,
|
|
)
|
|
)
|
|
if SEARCH_TYPE_HELPER in search_types:
|
|
combined.extend(
|
|
("helper", rec)
|
|
for rec in _search_helpers(
|
|
hass,
|
|
query_lower,
|
|
match_all=match_all,
|
|
exact=exact,
|
|
include_config=include_config,
|
|
secret_values=secret_values,
|
|
)
|
|
)
|
|
|
|
combined.sort(key=lambda item: (-item[1]["score"], _sort_key(item[1])))
|
|
config_total = len(combined)
|
|
config_page = combined[offset : offset + limit]
|
|
config_has_more = offset + len(config_page) < config_total
|
|
|
|
buckets: dict[str, list[dict[str, Any]]] = {
|
|
"automations": [],
|
|
"scripts": [],
|
|
"scenes": [],
|
|
"helpers": [],
|
|
}
|
|
bucket_of = {
|
|
SEARCH_TYPE_AUTOMATION: "automations",
|
|
SEARCH_TYPE_SCRIPT: "scripts",
|
|
SEARCH_TYPE_SCENE: "scenes",
|
|
"helper": "helpers",
|
|
}
|
|
for surface, rec in config_page:
|
|
buckets[bucket_of[surface]].append(rec)
|
|
|
|
result: dict[str, Any] = {
|
|
"entities": entities,
|
|
"entity_total_matches": entity_total,
|
|
"entity_has_more": entity_has_more,
|
|
"automations": buckets["automations"],
|
|
"scripts": buckets["scripts"],
|
|
"scenes": buckets["scenes"],
|
|
"helpers": buckets["helpers"],
|
|
"config_total_matches": config_total,
|
|
"config_has_more": config_has_more,
|
|
"partial": bool(partial_reasons),
|
|
"partial_reason": " ; ".join(partial_reasons) if partial_reasons else None,
|
|
}
|
|
if diagnostics:
|
|
result["diagnostics"] = diagnostics
|
|
# Additive (present only when non-empty, no schema_version bump): the server's
|
|
# ha_search consumer merges these into the response's top-level warnings.
|
|
if visibility_warnings:
|
|
result["visibility_warnings"] = visibility_warnings
|
|
return result
|
|
|
|
|
|
def _sort_key(rec: dict[str, Any]) -> str:
|
|
"""Stable tiebreak for combined config sorting."""
|
|
return str(rec.get("entity_id") or rec.get("id") or rec.get("name") or "")
|
|
|
|
|
|
async def _search_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
|
|
"""Async pre-step for ``search``: load the secret-scrub set off the loop.
|
|
|
|
The scrub only applies to config/helper surfaces, so an entity-only search
|
|
skips the ``secrets.yaml`` read entirely (perf gate). When a scrubbed surface
|
|
is requested, the blocking ``open()`` + ``yaml.safe_load`` runs in the
|
|
executor via :meth:`hass.async_add_executor_job` so the WS handler never
|
|
blocks the event loop. The loaded set is handed to :func:`_do_search`.
|
|
"""
|
|
search_types = msg.get("search_types") or ALL_SEARCH_TYPES
|
|
scrub_surfaces = (*CONFIG_SEARCH_TYPES, SEARCH_TYPE_HELPER)
|
|
if not any(st in search_types for st in scrub_surfaces):
|
|
return {"secret_values": frozenset()}
|
|
values = await hass.async_add_executor_job(_load_secret_values, hass)
|
|
return {"secret_values": values}
|
|
|
|
|
|
def _load_secret_scrub(hass: HomeAssistant) -> tuple[frozenset[str], bool]:
|
|
"""Load the ``secrets.yaml`` scrub values, plus a ``degraded`` flag.
|
|
|
|
Returns ``(values, degraded)``. ``values`` are the plaintext string forms of the
|
|
instance's ``secrets.yaml`` scalars; they scrub resolved ``!secret`` plaintext
|
|
out of two surfaces: the config-body match corpus (so ``ha_search`` cannot be a
|
|
probe oracle — a query equal to a suspected secret confirmed via
|
|
``match_in_config``) and the ``options`` emitted by ``config_entries`` /
|
|
``helpers_list`` (so a resolved secret never leaves the component).
|
|
|
|
Both string AND numeric scalars are collected as their ``str()`` form: an
|
|
unquoted ``alarm_code: 1234`` is a YAML int, and a config-entry option can carry
|
|
that secret back as an int leaf, so the scrub must be able to match it whether it
|
|
arrives as ``1234`` or ``"1234"``. bool scalars are excluded ("True"/"False" are
|
|
never credentials and would over-redact).
|
|
|
|
``degraded`` is True ONLY when a ``secrets.yaml`` is PRESENT but could not be
|
|
read/parsed: the scrub then silently turns OFF, and a caller emitting options can
|
|
surface ``degraded`` so an unredacted response is not mistaken for a cleanly
|
|
scrubbed one. An ABSENT ``secrets.yaml`` (the common case) is NOT degraded —
|
|
there is simply nothing to scrub.
|
|
|
|
Defensive by design — never raises into the WS handler. Loaded off the event loop
|
|
by the async preps once per call, never cached, so an edited ``secrets.yaml``
|
|
applies on the next call. ``secrets.yaml`` is a flat ``key: value`` mapping with
|
|
no custom tags, so the plain ``yaml.safe_load`` (not HA's ``!secret``/
|
|
``!include`` loader) reads it correctly.
|
|
"""
|
|
config = getattr(hass, "config", None)
|
|
path_fn = getattr(config, "path", None)
|
|
if not callable(path_fn):
|
|
return frozenset(), False
|
|
try:
|
|
path = path_fn("secrets.yaml")
|
|
if not path:
|
|
return frozenset(), False
|
|
with open(path, encoding="utf-8") as handle:
|
|
raw = yaml.safe_load(handle)
|
|
except FileNotFoundError:
|
|
# Expected: many instances have no secrets.yaml — nothing to scrub.
|
|
return frozenset(), False
|
|
except Exception:
|
|
# Present-but-unreadable / malformed / permission error: unexpected, so warn
|
|
# once (this runs once per call) AND report degraded so the emission callers
|
|
# can signal that options were NOT redacted, rather than raising into the WS
|
|
# handler.
|
|
_LOGGER.warning(
|
|
"Could not read secrets.yaml for the secret-scrub; continuing WITHOUT "
|
|
"redaction (emitted options may be unredacted)",
|
|
exc_info=True,
|
|
)
|
|
return frozenset(), True
|
|
if not isinstance(raw, dict):
|
|
return frozenset(), False
|
|
return _collect_secret_strings(raw), False
|
|
|
|
|
|
def _collect_secret_strings(raw: dict[Any, Any]) -> frozenset[str]:
|
|
"""Plaintext ``str()`` forms of ``secrets.yaml`` scalars (str/int/float).
|
|
|
|
bool scalars are excluded ("True"/"False" are never credentials and would
|
|
over-redact); empty strings are dropped. See :func:`_load_secret_scrub`.
|
|
"""
|
|
values: set[str] = set()
|
|
for v in raw.values():
|
|
if isinstance(v, bool):
|
|
continue
|
|
if isinstance(v, str):
|
|
if v:
|
|
values.add(v)
|
|
elif isinstance(v, (int, float)):
|
|
values.add(str(v))
|
|
return frozenset(values)
|
|
|
|
|
|
def _load_secret_values(hass: HomeAssistant) -> frozenset[str]:
|
|
"""The ``secrets.yaml`` scrub set (see :func:`_load_secret_scrub`); degraded dropped.
|
|
|
|
The ``search`` corpus scrub is best-effort and does not surface the degraded
|
|
signal (its filtering degrading open is the pre-PR behaviour); the
|
|
``config_entries`` / ``helpers_list`` emission preps call
|
|
:func:`_load_secret_scrub` directly so they can surface it.
|
|
"""
|
|
values, _degraded = _load_secret_scrub(hass)
|
|
return values
|
|
|
|
|
|
# --- Entity join + scoring ---------------------------------------------------
|
|
def _search_entities(
|
|
hass: HomeAssistant,
|
|
view: _RegistryView,
|
|
query_lower: str,
|
|
*,
|
|
match_all: bool,
|
|
exact: bool,
|
|
include_hidden: bool,
|
|
domain_filter: str | None,
|
|
area_filter: str | None,
|
|
state_filter: str | None,
|
|
) -> 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
|
|
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:
|
|
continue
|
|
if area_filter_lower is not None and not _entity_matches_area(
|
|
rec, area_filter_lower
|
|
):
|
|
continue
|
|
|
|
if match_all:
|
|
score: int | None = _apply_hidden_penalty(100, rec["_hidden"])
|
|
match_type = "match_all"
|
|
else:
|
|
tier = _text_tier(query_lower, rec["_match_texts"], fuzzy=not exact)
|
|
if tier is None:
|
|
continue
|
|
score = _apply_hidden_penalty(tier, rec["_hidden"])
|
|
match_type = _entity_match_type(
|
|
query_lower,
|
|
rec["entity_id"],
|
|
rec["friendly_name"],
|
|
rec["domain"],
|
|
rec["aliases"],
|
|
exact=exact,
|
|
)
|
|
rec["score"] = score
|
|
rec["match_type"] = match_type
|
|
results.append(rec)
|
|
return results
|
|
|
|
|
|
def _entity_match_type(
|
|
query_lower: str,
|
|
entity_id: str,
|
|
friendly: str,
|
|
domain: str,
|
|
aliases: list[str],
|
|
*,
|
|
exact: bool,
|
|
) -> str:
|
|
"""Classify an entity hit into the server's match_type taxonomy.
|
|
|
|
The server labels matches two ways and the component must be
|
|
indistinguishable from it:
|
|
|
|
- **exact mode** — the server's ``_match_exact_search_entity`` stamps a flat
|
|
``"exact_match"`` on every hit, so mirror that constant.
|
|
- **fuzzy mode** — the server's ``FuzzySearchEngine`` emits a richer set that
|
|
agents key on. ``"alias_match"`` wins when the hit is driven by an alias
|
|
token the id/name don't already carry (the engine's ``alias_hit`` tracking
|
|
— closes #1166); otherwise the ``_get_match_type`` tiers: ``exact_id`` /
|
|
``exact_name`` / ``exact_domain`` / ``partial_id`` / ``partial_name``,
|
|
falling to ``fuzzy_match``.
|
|
"""
|
|
if exact:
|
|
return "exact_match"
|
|
if _is_alias_driven(query_lower, entity_id, friendly, aliases):
|
|
return "alias_match"
|
|
return _get_match_type_tier(query_lower, entity_id, friendly, domain)
|
|
|
|
|
|
def _is_alias_driven(
|
|
query_lower: str, entity_id: str, friendly: str, aliases: list[str]
|
|
) -> bool:
|
|
"""Whether a query token lands only on an alias, mirroring the engine's alias_hit.
|
|
|
|
Collects the alias tokens (and each alias's separator-stripped concat form)
|
|
that are NOT already present in the id/name token set; a query token in that
|
|
set means the friendly_name / id alone would not have surfaced this entity.
|
|
"""
|
|
id_tail = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
|
|
id_name_tokens = set(_tokenize(entity_id)) | set(_tokenize(str(friendly)))
|
|
id_name_tokens.add(_SPLIT_RE.sub("", id_tail.lower()))
|
|
id_name_tokens.add(_SPLIT_RE.sub("", str(friendly).lower()))
|
|
alias_only: set[str] = set()
|
|
for alias in aliases:
|
|
a_lower = str(alias).lower()
|
|
for tok in _tokenize(a_lower):
|
|
if tok not in id_name_tokens:
|
|
alias_only.add(tok)
|
|
a_concat = _SPLIT_RE.sub("", a_lower)
|
|
if a_concat and a_concat not in id_name_tokens:
|
|
alias_only.add(a_concat)
|
|
return bool(set(_tokenize(query_lower)) & alias_only)
|
|
|
|
|
|
def _get_match_type_tier(
|
|
query_lower: str, entity_id: str, friendly: str, domain: str
|
|
) -> str:
|
|
"""The server's ``_get_match_type`` id/name/domain tiers (non-alias hits)."""
|
|
eid = entity_id.lower()
|
|
fname = str(friendly).lower()
|
|
if query_lower == eid:
|
|
return "exact_id"
|
|
if query_lower == fname:
|
|
return "exact_name"
|
|
if query_lower == domain.lower():
|
|
return "exact_domain"
|
|
if query_lower in eid:
|
|
return "partial_id"
|
|
if query_lower in fname:
|
|
return "partial_name"
|
|
return "fuzzy_match"
|
|
|
|
|
|
def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
|
|
"""Join one entity_id with the entity/device/area/floor/label registries.
|
|
|
|
The shared registry read behind BOTH the search record (:func:`_entity_record`)
|
|
and the ``entity_enrich`` / ``exposure`` commands, so the area/floor/label-name
|
|
resolution lives in exactly one place. Resolves the entity's aliases plus its
|
|
area / floor / label NAMES (device-inherited when the entity itself carries
|
|
none), keyed off the entity registry — no ``State`` object required, so a
|
|
registry-only (stateless) entity is enriched too. Returns the public
|
|
enrichment fields (``area`` / ``floor`` / ``labels`` / ``aliases``) alongside
|
|
the internal ``_area_id`` / ``_hidden`` / ``_dev_texts`` the scorer consumes.
|
|
"""
|
|
reg = _reg_entity(view, entity_id)
|
|
# String entries only: HA core's aliases can carry the COMPUTED_NAME
|
|
# sentinel (entity_registry.ComputedNameType._singleton, "the computed
|
|
# entity name is an alias"). Blind str() published it as a literal
|
|
# "ComputedNameType._singleton" alias on every carrying entity — fake data
|
|
# in results AND a scored match_text. The name it stands for is already
|
|
# matched via ``friendly``, so dropping the sentinel loses nothing.
|
|
aliases = (
|
|
sorted(a for a in (getattr(reg, "aliases", None) or []) if isinstance(a, str))
|
|
if reg
|
|
else []
|
|
)
|
|
area_id = getattr(reg, "area_id", None) if reg else None
|
|
device_id = getattr(reg, "device_id", None) if reg else None
|
|
labels = set(getattr(reg, "labels", None) or []) if reg else set()
|
|
hidden = bool(getattr(reg, "hidden_by", None)) if reg else False
|
|
|
|
dev = _device(view, device_id) if device_id else None
|
|
dev_texts: list[str] = []
|
|
if dev is not None:
|
|
if area_id is None:
|
|
area_id = getattr(dev, "area_id", None)
|
|
labels |= set(getattr(dev, "labels", None) or [])
|
|
for attr in ("name_by_user", "name", "manufacturer", "model"):
|
|
val = getattr(dev, attr, None)
|
|
if val:
|
|
dev_texts.append(str(val))
|
|
|
|
return {
|
|
"area": _area_name(view, area_id),
|
|
"floor": _floor_name_for_area(view, area_id),
|
|
"labels": _label_names(view, labels),
|
|
"aliases": aliases,
|
|
"_area_id": area_id,
|
|
"_hidden": hidden,
|
|
"_dev_texts": dev_texts,
|
|
}
|
|
|
|
|
|
def _entity_record(state: Any, view: _RegistryView) -> 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)
|
|
|
|
join = _registry_enrichment(view, entity_id)
|
|
area_name = join["area"]
|
|
floor_name = join["floor"]
|
|
label_names = join["labels"]
|
|
aliases = join["aliases"]
|
|
|
|
# Scored texts extend the server's id + friendly-name pair with the specific
|
|
# joined identifiers (alias / area / floor / label / device). The bare domain
|
|
# is deliberately excluded: matching it would score every entity of a domain
|
|
# at the exact tier (a "light" query flooding all lights), which the server
|
|
# does not do — domain is a filter dimension, not a scored text.
|
|
match_texts = [entity_id, friendly, *aliases, *label_names, *join["_dev_texts"]]
|
|
if area_name:
|
|
match_texts.append(area_name)
|
|
if floor_name:
|
|
match_texts.append(floor_name)
|
|
|
|
return {
|
|
"entity_id": entity_id,
|
|
"friendly_name": friendly,
|
|
"domain": domain,
|
|
"state": getattr(state, "state", "unknown"),
|
|
"area": area_name,
|
|
"floor": floor_name,
|
|
"labels": label_names,
|
|
"aliases": aliases,
|
|
"_hidden": join["_hidden"],
|
|
"_area_id": join["_area_id"],
|
|
"_match_texts": match_texts,
|
|
}
|
|
|
|
|
|
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]) -> dict[str, Any]:
|
|
"""Strip internal ``_``-prefixed keys for the wire response."""
|
|
return {
|
|
"entity_id": rec["entity_id"],
|
|
"friendly_name": rec["friendly_name"],
|
|
"domain": rec["domain"],
|
|
"state": rec["state"],
|
|
"area": rec["area"],
|
|
"floor": rec["floor"],
|
|
"labels": rec["labels"],
|
|
"aliases": rec["aliases"],
|
|
"score": rec["score"],
|
|
"match_type": rec["match_type"],
|
|
}
|
|
|
|
|
|
# --- Config surfaces (automation/script/scene) -------------------------------
|
|
def _search_config_surface(
|
|
hass: HomeAssistant,
|
|
view: _RegistryView,
|
|
domain: str,
|
|
query_lower: str,
|
|
*,
|
|
match_all: bool,
|
|
exact: bool,
|
|
include_config: bool,
|
|
partial_reasons: list[str],
|
|
diagnostics: dict[str, int],
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> list[dict[str, Any]]:
|
|
"""Score one config domain's loaded entities (raw_config indexed, not emitted for YAML)."""
|
|
component = hass.data.get(domain) if getattr(hass, "data", None) else None
|
|
entities = getattr(component, "entities", None)
|
|
if entities is None:
|
|
diagnostics["config_components_inaccessible"] = (
|
|
diagnostics.get("config_components_inaccessible", 0) + 1
|
|
)
|
|
return []
|
|
|
|
results: list[dict[str, Any]] = []
|
|
for entity in entities:
|
|
entity_id = getattr(entity, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
name, item_id, config_dict = _extract_config(domain, entity)
|
|
source = _classify_source(item_id)
|
|
|
|
if match_all:
|
|
score: int | None = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
entity_id,
|
|
name,
|
|
config_dict,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
|
|
# Scenes never emit a component-served body: a HomeAssistantScene holds no
|
|
# raw storage dict (its states are runtime State objects), so config stays
|
|
# None and config_dict is used only as the faithful match corpus.
|
|
config_out: dict[str, Any] | None = None
|
|
if (
|
|
domain != SEARCH_TYPE_SCENE
|
|
and include_config
|
|
and source == "storage"
|
|
and config_dict is not None
|
|
):
|
|
if _too_large(config_dict):
|
|
partial_reasons.append(f"{domain} {entity_id} body omitted (too large)")
|
|
else:
|
|
config_out = config_dict
|
|
|
|
rec: dict[str, Any] = {
|
|
"id": item_id,
|
|
"entity_id": entity_id,
|
|
"source": source,
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
"config": config_out,
|
|
}
|
|
# Scenes carry a "name"; automations/scripts carry an "alias".
|
|
if domain == SEARCH_TYPE_SCENE:
|
|
rec["name"] = name
|
|
else:
|
|
rec["alias"] = name
|
|
results.append(rec)
|
|
return results
|
|
|
|
|
|
def _extract_config(
|
|
domain: str, entity: Any
|
|
) -> tuple[str, str | None, dict[str, Any] | None]:
|
|
"""Return (display_name, item_id, config_dict) for a config entity.
|
|
|
|
Uses defensive getattr because the exact accessor can drift across core
|
|
versions: automation/script expose ``raw_config``; scenes expose
|
|
``scene_config`` (name/icon/id/states) rather than ``raw_config``. For a
|
|
scene the returned ``config_dict`` is the faithful MATCH corpus only (see
|
|
:func:`_scene_match_corpus`), never an emittable body.
|
|
"""
|
|
entity_id = getattr(entity, "entity_id", "") or ""
|
|
name = getattr(entity, "name", None) or entity_id
|
|
unique_id = getattr(entity, "unique_id", None)
|
|
|
|
if domain == SEARCH_TYPE_SCENE:
|
|
scene_config = getattr(entity, "scene_config", None)
|
|
config_dict = _scene_match_corpus(scene_config)
|
|
item_id = unique_id
|
|
if item_id is None and config_dict is not None:
|
|
item_id = config_dict.get("id")
|
|
if config_dict is not None:
|
|
cfg_name = config_dict.get("name")
|
|
if cfg_name:
|
|
name = str(cfg_name)
|
|
return str(name), (str(item_id) if item_id is not None else None), config_dict
|
|
|
|
raw = getattr(entity, "raw_config", None)
|
|
config_dict = dict(raw) if isinstance(raw, dict) else None
|
|
item_id = unique_id
|
|
if item_id is None and config_dict is not None:
|
|
item_id = config_dict.get("id")
|
|
return str(name), (str(item_id) if item_id is not None else None), config_dict
|
|
|
|
|
|
def _scene_match_corpus(scene_config: Any) -> dict[str, Any] | None:
|
|
"""Faithful, minimal MATCH corpus for a scene — never an emittable body.
|
|
|
|
A ``HomeAssistantScene`` holds no raw storage dict: ``scene_config.states`` is
|
|
a ``{entity_id: State}`` map of RUNTIME ``State`` objects. Scoring/emitting
|
|
those (each stringifying to ``<state light.x=on; ...>``) was garbage and
|
|
diverged the component's scoring from any real body. Index only the faithful,
|
|
non-runtime facts instead: ``id`` / ``name`` / ``icon`` plus the entity-id
|
|
KEYS of ``states`` (so "which scenes touch ``light.x``" still matches) — no
|
|
State values, no timestamps or contexts. Used for MATCHING only; the scene
|
|
record never emits a ``config`` body (see :func:`_search_config_surface`).
|
|
"""
|
|
if scene_config is None:
|
|
return None
|
|
if isinstance(scene_config, Mapping):
|
|
src: Mapping[str, Any] = scene_config
|
|
else:
|
|
collected: dict[str, Any] = {}
|
|
for attr in ("id", "name", "icon", "states", "entities"):
|
|
val = getattr(scene_config, attr, None)
|
|
if val is not None:
|
|
collected[attr] = val
|
|
src = collected
|
|
out: dict[str, Any] = {}
|
|
for key in ("id", "name", "icon"):
|
|
val = src.get(key)
|
|
if val is not None:
|
|
out[key] = str(val)
|
|
entity_ids: set[str] = set()
|
|
for key in ("states", "entities"):
|
|
mapping = src.get(key)
|
|
if isinstance(mapping, Mapping):
|
|
entity_ids.update(str(k) for k in mapping)
|
|
if entity_ids:
|
|
out["entities"] = sorted(entity_ids)
|
|
return out or None
|
|
|
|
|
|
def _plainify(value: Any) -> Any:
|
|
"""Best-effort conversion of registry/state objects to plain JSON-able data.
|
|
|
|
Recurses on any ``Mapping`` (not just ``dict``): a nested ``MappingProxyType``
|
|
(common in ``ConfigEntry.options``) must be walked into a plain dict, NOT
|
|
stringified — otherwise a secret buried inside it survives embedded in the repr
|
|
string, past the equality scrub that runs on the result.
|
|
"""
|
|
if isinstance(value, Mapping):
|
|
return {str(k): _plainify(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [_plainify(v) for v in value]
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def _classify_source(item_id: str | None) -> str:
|
|
"""Classify an automation/script/scene as storage- or YAML-backed.
|
|
|
|
HA addresses editor-managed items by their ``id`` (the entity's
|
|
``unique_id``); the config editor's ``/config/<domain>/config/<id>`` REST
|
|
path — and its edit link — key off exactly that id, and 404 for items with
|
|
no id. So an id-bearing item is treated as ``storage`` (body emittable under
|
|
``include_config``); an id-less item is ``yaml`` and its body is never
|
|
emitted from search (its ``raw_config`` may carry resolved ``!secret``
|
|
plaintext). This is the conservative rule: the safe error is toward
|
|
withholding a body, not leaking one.
|
|
"""
|
|
return "storage" if item_id else "yaml"
|
|
|
|
|
|
def _too_large(config_dict: dict[str, Any]) -> bool:
|
|
"""Rough guard so a huge body never balloons a single WS frame."""
|
|
try:
|
|
return len(repr(config_dict)) > MAX_BODY_BYTES
|
|
except Exception: # pragma: no cover - defensive
|
|
return False
|
|
|
|
|
|
# --- Helpers surface ---------------------------------------------------------
|
|
def _collection_storage_index(
|
|
hass: HomeAssistant, domains: frozenset[str]
|
|
) -> dict[str, tuple[dict[str, Any], str | None]]:
|
|
"""Map collection-helper ``entity_id`` -> ``(storage body, storage id)``.
|
|
|
|
Collection helpers keep their full storage config on the ``CollectionEntity``
|
|
as ``_config`` — a schedule's weekday blocks, an input_datetime's
|
|
``has_date``/``has_time``, an input_boolean's ``initial`` — fields the live
|
|
state attributes do NOT carry. The ``StorageCollection`` that loaded them is a
|
|
setup-local (``helpers/collection.py`` writes nothing to ``hass.data``), so
|
|
the reachable in-process source is the domain's ``EntityComponent``
|
|
(``hass.data['entity_components'][domain]``, or ``hass.data[domain]`` for the
|
|
automation/script/scene pattern) and each entity's ``_config``.
|
|
|
|
Domains that decompose config into ``_attr_*`` instead of keeping ``_config``
|
|
(input_number / input_text / input_select) have no entry here; the caller
|
|
falls back to the state-attributes body for them (and for any YAML-defined
|
|
helper whose entity is absent). All access is getattr-guarded against drift.
|
|
"""
|
|
index: dict[str, tuple[dict[str, Any], str | None]] = {}
|
|
data = getattr(hass, "data", None)
|
|
if not isinstance(data, Mapping):
|
|
return index
|
|
instances = data.get(ENTITY_COMPONENTS_KEY)
|
|
for domain in domains:
|
|
component = (
|
|
instances.get(domain) if isinstance(instances, Mapping) else None
|
|
) or data.get(domain)
|
|
entities = getattr(component, "entities", None)
|
|
if entities is None:
|
|
continue
|
|
try:
|
|
entity_list = list(entities)
|
|
except Exception: # pragma: no cover - defensive
|
|
continue
|
|
for entity in entity_list:
|
|
entity_id = getattr(entity, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
raw = getattr(entity, "_config", None)
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
storage_id = getattr(entity, "unique_id", None) or raw.get("id")
|
|
index[entity_id] = (
|
|
dict(raw),
|
|
str(storage_id) if storage_id is not None else None,
|
|
)
|
|
return index
|
|
|
|
|
|
def _search_helpers(
|
|
hass: HomeAssistant,
|
|
query_lower: str,
|
|
*,
|
|
match_all: bool,
|
|
exact: bool,
|
|
include_config: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> list[dict[str, Any]]:
|
|
"""Index collection helpers (states) + flow helpers (config-entry options)."""
|
|
results: list[dict[str, Any]] = []
|
|
|
|
# Collection helpers: entities in the state machine, matched on entity_id /
|
|
# friendly_name AND the searchable config body. That body is the entity's real
|
|
# storage ``_config`` (a schedule's weekday blocks, an input_select's
|
|
# ``options`` + ``initial``, …) when reachable, falling back to the live state
|
|
# attributes otherwise (see _collection_storage_index). The name still comes
|
|
# from the CURRENT friendly_name, not the creation-time storage name.
|
|
storage = _collection_storage_index(hass, COLLECTION_HELPER_DOMAINS)
|
|
for state in _iter_states(hass):
|
|
entity_id = getattr(state, "entity_id", "") or ""
|
|
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
if domain not in COLLECTION_HELPER_DOMAINS:
|
|
continue
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
name = attrs.get("friendly_name", entity_id)
|
|
object_id = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
|
|
stored = storage.get(entity_id)
|
|
if stored is not None:
|
|
body = _plainify(stored[0])
|
|
else:
|
|
body = dict(attrs) if isinstance(attrs, Mapping) else {}
|
|
if match_all:
|
|
score: int | None = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
entity_id,
|
|
name,
|
|
body,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
results.append(
|
|
{
|
|
"entity_id": entity_id,
|
|
"helper_type": domain,
|
|
"object_id": object_id,
|
|
"name": name,
|
|
"kind": "collection",
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
"config": body if include_config else None,
|
|
}
|
|
)
|
|
|
|
# Flow helpers: config entries — options + title ONLY, never data.
|
|
for entry in _iter_config_entries(hass):
|
|
domain = getattr(entry, "domain", None)
|
|
if domain not in FLOW_HELPER_DOMAINS:
|
|
continue
|
|
title = getattr(entry, "title", None) or ""
|
|
# ``ConfigEntry.options`` is a ``MappingProxyType`` in live HA, not a
|
|
# ``dict``; the old ``isinstance(..., dict)`` guard silently dropped it to
|
|
# ``{}``, so a flow helper's body (a template's ``state``, a group's
|
|
# members, …) was never indexed and ``match_in_config`` could never fire.
|
|
# Accept any ``Mapping`` so the persisted options are searchable and
|
|
# emittable under ``include_config``.
|
|
raw_options = getattr(entry, "options", None)
|
|
options = dict(raw_options) if isinstance(raw_options, Mapping) else {}
|
|
entry_id = getattr(entry, "entry_id", None)
|
|
if match_all:
|
|
score = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
title,
|
|
title,
|
|
options,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
results.append(
|
|
{
|
|
"entity_id": None,
|
|
"helper_type": domain,
|
|
"entry_id": entry_id,
|
|
"name": title,
|
|
"kind": "flow",
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
# Data minimization: options only, never entry.data.
|
|
"options": options if include_config else None,
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
# =============================================================================
|
|
# Scoring — mirrors the server's tiers (guarded by the golden parity test)
|
|
# =============================================================================
|
|
def _apply_hidden_penalty(score: int, hidden: bool) -> int:
|
|
"""Reduce ``score`` by :data:`HIDDEN_SCORE_PENALTY` for hidden entities.
|
|
|
|
Mirrors ``utils.fuzzy_search.apply_hidden_penalty`` so the two rankings
|
|
stay consistent.
|
|
"""
|
|
s = int(score)
|
|
return max(0, s - HIDDEN_SCORE_PENALTY) if hidden else s
|
|
|
|
|
|
def _calc_ratio(a: str, b: str) -> int:
|
|
"""SequenceMatcher ratio (0-100). Mirrors ``fuzzy_search.calculate_ratio``."""
|
|
return int(SequenceMatcher(None, a, b, autojunk=False).ratio() * 100)
|
|
|
|
|
|
def _tokenize(text: str) -> list[str]:
|
|
"""Split on ``.``/``_``/``-``/whitespace, lowercase, drop empties.
|
|
|
|
Mirrors ``utils.fuzzy_search.tokenize``.
|
|
"""
|
|
return [t for t in _SPLIT_RE.split(text.lower()) if t]
|
|
|
|
|
|
def _sep_normalized(text: str) -> str:
|
|
"""Collapse ``.``/``_``/``-``/whitespace runs to single spaces.
|
|
|
|
The server's fuzzy engine (BM25) tokenizes query and documents with the
|
|
same splitter, making ``input_boolean`` and ``input boolean`` equivalent
|
|
queries (pinned by the e2e underscore/space-equivalence test). Comparing
|
|
separator-normalized strings replicates that equivalence for the
|
|
component's tier scorer.
|
|
"""
|
|
return " ".join(_tokenize(text))
|
|
|
|
|
|
def _text_tier(query_lower: str, texts: Any, *, fuzzy: bool) -> int | None:
|
|
"""Entity tier: 100 (exact), 80 (substring), fuzzy ratio (>=threshold), or None.
|
|
|
|
Mirrors the server's ``_match_exact_search_entity`` (100/80) over the entity
|
|
id + friendly name, extended to the joined alias/area/floor/label/domain/
|
|
device texts. In fuzzy mode comparisons run on BOTH the raw strings and
|
|
their separator-normalized forms (unified tokenization — ``_``/space
|
|
equivalence), with a whole-string ``calculate_ratio`` fallback surfacing
|
|
typos above :data:`FUZZY_THRESHOLD`. Exact mode stays raw-only for
|
|
byte-parity with the server's exact path.
|
|
"""
|
|
query_norm = _sep_normalized(query_lower) if fuzzy else ""
|
|
best_substring: int | None = None
|
|
best_ratio = 0
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
tier, ratio = _tier_one_text(query_lower, query_norm, str(text).lower(), fuzzy)
|
|
if tier == 100:
|
|
return 100
|
|
if tier == 80:
|
|
best_substring = 80
|
|
elif ratio > best_ratio:
|
|
best_ratio = ratio
|
|
if best_substring is not None:
|
|
return best_substring
|
|
if fuzzy and best_ratio >= FUZZY_THRESHOLD:
|
|
return best_ratio
|
|
return None
|
|
|
|
|
|
def _tier_one_text(
|
|
query_lower: str, query_norm: str, text_lower: str, fuzzy: bool
|
|
) -> tuple[int | None, int]:
|
|
"""Score one candidate text: ``(tier, ratio)``.
|
|
|
|
Tier 100 = exact (raw, or separator-normalized in fuzzy mode); tier 80 =
|
|
substring (same two forms); otherwise ``ratio`` carries the fuzzy
|
|
whole-string fallback (0 when not in fuzzy mode).
|
|
"""
|
|
if query_lower == text_lower:
|
|
return 100, 0
|
|
text_norm = _sep_normalized(text_lower) if fuzzy and query_norm else ""
|
|
if text_norm and query_norm == text_norm:
|
|
return 100, 0
|
|
if query_lower in text_lower:
|
|
return 80, 0
|
|
if text_norm and query_norm in text_norm:
|
|
return 80, 0
|
|
if fuzzy:
|
|
return None, _calc_ratio(query_lower, text_lower)
|
|
return None, 0
|
|
|
|
|
|
def _name_tier(query_lower: str, texts: Any, *, exact: bool) -> int | None:
|
|
"""Config-name tier: substring => 100 (not 80), else fuzzy ratio or None.
|
|
|
|
Config name matches are binary 100/0 in the server's exact path
|
|
(``_score_deep_match``: ``name_exact = 100 if query in id/name else 0``),
|
|
unlike entity matches which have the 80 substring tier.
|
|
"""
|
|
query_norm = "" if exact else _sep_normalized(query_lower)
|
|
best_ratio = 0
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
text_lower = str(text).lower()
|
|
if query_lower in text_lower:
|
|
return 100
|
|
if not exact:
|
|
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
|
|
if not exact and best_ratio >= FUZZY_THRESHOLD:
|
|
return best_ratio
|
|
return None
|
|
|
|
|
|
def _config_score(
|
|
query_lower: str,
|
|
entity_id: str,
|
|
name: str,
|
|
config_dict: dict[str, Any] | None,
|
|
*,
|
|
exact: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> tuple[int, bool, bool] | None:
|
|
"""Score a config surface: (total, match_in_name, match_in_config) or None.
|
|
|
|
Exact mode is binary 100/0 with a threshold of 100 (server parity); fuzzy
|
|
mode floors at :data:`FUZZY_THRESHOLD`. ``secret_values`` scrubs the body
|
|
match corpus (see :func:`_search_in_dict_exact`).
|
|
"""
|
|
name_score = _name_tier(query_lower, [entity_id, name], exact=exact) or 0
|
|
config_score = _config_body_score(
|
|
query_lower, config_dict, exact=exact, secret_values=secret_values
|
|
)
|
|
threshold = 100 if exact else FUZZY_THRESHOLD
|
|
total = max(name_score, config_score)
|
|
if total < threshold:
|
|
return None
|
|
return total, name_score >= threshold, config_score >= threshold
|
|
|
|
|
|
def _config_body_score(
|
|
query_lower: str,
|
|
config_dict: dict[str, Any] | None,
|
|
*,
|
|
exact: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> int:
|
|
"""Match the query against a config body's keys/values.
|
|
|
|
Exact => 100/0 substring (``_search_in_dict_exact`` parity). Fuzzy adds a
|
|
token-vs-token ``calculate_ratio`` fallback (the server's tier-3 path).
|
|
``secret_values`` scrubs resolved-``!secret`` leaves from the corpus.
|
|
"""
|
|
if config_dict is None:
|
|
return 0
|
|
if _search_in_dict_exact(config_dict, query_lower, secret_values) >= 100:
|
|
return 100
|
|
if exact:
|
|
return 0
|
|
leaves: list[str] = []
|
|
_collect_string_leaves(config_dict, leaves, secret_values)
|
|
query_tokens = _tokenize(query_lower)
|
|
if not query_tokens:
|
|
return 0
|
|
doc_tokens = {tok for leaf in leaves for tok in _tokenize(leaf)}
|
|
best = 0
|
|
for qt in query_tokens:
|
|
for dt in doc_tokens:
|
|
best = max(best, _calc_ratio(qt, dt))
|
|
return best if best >= FUZZY_THRESHOLD else 0
|
|
|
|
|
|
def _search_in_dict_exact(
|
|
data: Any, query_lower: str, secret_values: frozenset[str] = frozenset()
|
|
) -> int:
|
|
"""Exact substring search in nested structures (100 or 0).
|
|
|
|
Mirrors ``smart_search._scoring.ScoringMixin._search_in_dict_exact``, plus a
|
|
secret scrub: a string leaf that exactly equals a known secret value never
|
|
contributes a match (see :func:`_load_secret_values`), so a query equal to a
|
|
resolved ``!secret`` cannot be confirmed via ``match_in_config``. Keys and
|
|
non-string scalars are never secrets, so they are matched as before.
|
|
"""
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if query_lower in str(key).lower():
|
|
return 100
|
|
if _search_in_dict_exact(value, query_lower, secret_values) >= 100:
|
|
return 100
|
|
return 0
|
|
if isinstance(data, (list, tuple)):
|
|
for item in data:
|
|
if _search_in_dict_exact(item, query_lower, secret_values) >= 100:
|
|
return 100
|
|
return 0
|
|
return _leaf_exact_score(data, query_lower, secret_values)
|
|
|
|
|
|
def _leaf_exact_score(
|
|
data: Any, query_lower: str, secret_values: frozenset[str]
|
|
) -> int:
|
|
"""Exact substring score for a scalar leaf (100 or 0).
|
|
|
|
A string leaf that exactly equals a known secret value scores 0 — the scrub
|
|
that keeps a resolved ``!secret`` out of the match corpus.
|
|
"""
|
|
if isinstance(data, str):
|
|
if data in secret_values:
|
|
return 0
|
|
return 100 if query_lower in data.lower() else 0
|
|
if data is not None:
|
|
return 100 if query_lower in str(data).lower() else 0
|
|
return 0
|
|
|
|
|
|
def _collect_string_leaves(
|
|
data: Any, out: list[str], secret_values: frozenset[str] = frozenset()
|
|
) -> None:
|
|
"""Recursively collect string representations. Mirrors the server helper.
|
|
|
|
A string leaf that exactly equals a known secret value is dropped so it
|
|
never reaches the fuzzy token corpus (the scrub in :func:`_search_in_dict_exact`
|
|
covers the exact path).
|
|
"""
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
out.append(str(key))
|
|
_collect_string_leaves(value, out, secret_values)
|
|
elif isinstance(data, (list, tuple)):
|
|
for item in data:
|
|
_collect_string_leaves(item, out, secret_values)
|
|
elif isinstance(data, str):
|
|
if data not in secret_values:
|
|
out.append(data)
|
|
elif data is not None:
|
|
out.append(str(data))
|
|
|
|
|
|
# =============================================================================
|
|
# Registry accessors (all getattr-guarded against core drift)
|
|
# =============================================================================
|
|
def _iter_states(hass: HomeAssistant) -> list[Any]:
|
|
states = getattr(hass, "states", None)
|
|
getter = getattr(states, "async_all", None) if states is not None else None
|
|
if getter is None:
|
|
return []
|
|
try:
|
|
return list(getter())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _iter_config_entries(hass: HomeAssistant) -> list[Any]:
|
|
config_entries = getattr(hass, "config_entries", None)
|
|
getter = (
|
|
getattr(config_entries, "async_entries", None)
|
|
if config_entries is not None
|
|
else None
|
|
)
|
|
if getter is None:
|
|
return []
|
|
try:
|
|
return list(getter())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _reg_entity(view: _RegistryView, entity_id: str) -> Any:
|
|
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)
|
|
|
|
|
|
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)
|
|
name = getattr(area, "name", None) if area is not None else None
|
|
return str(name) if name else 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)
|
|
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)
|
|
name = getattr(floor, "name", None) if floor is not None else None
|
|
return str(name) if name else 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)
|
|
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:
|
|
if registry is None:
|
|
return None
|
|
getter = getattr(registry, method, None)
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
return getter(key)
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _call_no_arg(obj: Any, method: str) -> Any:
|
|
"""Call a no-argument accessor (e.g. ``async_services``), guarded."""
|
|
if obj is None:
|
|
return None
|
|
fn = getattr(obj, method, None)
|
|
if not callable(fn):
|
|
return None
|
|
try:
|
|
return fn()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _iso(value: Any) -> Any:
|
|
"""Serialize a datetime-ish value to an ISO string; pass through otherwise.
|
|
|
|
HA registry/state timestamps are ``datetime`` objects. The WS layer can
|
|
encode them, but the REST shapes the overview consumer mirrors carry ISO
|
|
strings, so normalize here for a stable wire contract.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
iso = getattr(value, "isoformat", None)
|
|
if callable(iso):
|
|
try:
|
|
return iso()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
return value if isinstance(value, (str, int, float, bool)) else str(value)
|
|
|
|
|
|
def _enum_value(value: Any) -> Any:
|
|
"""Unwrap a StrEnum-ish registry field (``entity_category``/``hidden_by``/…).
|
|
|
|
HA stores these as enums whose ``.value`` is the wire string; a plain string
|
|
(or None) passes through unchanged. Also unwraps ``ConfigEntryState`` (a plain
|
|
``Enum`` whose ``.value`` is the wire string, e.g. ``"loaded"``) — core's
|
|
``config_entries/get`` serializes it as ``entry.state.value``.
|
|
"""
|
|
if value is None or isinstance(value, str):
|
|
return value
|
|
return getattr(value, "value", str(value))
|
|
|
|
|
|
def _timestamp(value: Any) -> float | None:
|
|
"""Serialize a datetime-ish registry timestamp as a float, like core.
|
|
|
|
core's registry WS list responses emit ``created_at`` / ``modified_at`` via
|
|
``entry.created_at.timestamp()`` (a float, seconds since epoch), so mirror
|
|
that. A value that is already numeric passes through; anything else (or a
|
|
``.timestamp()`` that raises) degrades to ``None``.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
ts = getattr(value, "timestamp", None)
|
|
if callable(ts):
|
|
try:
|
|
return float(ts())
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
return float(value) if isinstance(value, (int, float)) else None
|
|
|
|
|
|
def _reg_name(reg: Any) -> str | None:
|
|
"""Current display name from a registry entry: user override, else original."""
|
|
if reg is None:
|
|
return None
|
|
name = getattr(reg, "name", None) or getattr(reg, "original_name", None)
|
|
return str(name) if name else None
|
|
|
|
|
|
def _current_friendly_name(
|
|
hass: HomeAssistant, entity_id: str | None, fallback: str | None
|
|
) -> str | None:
|
|
"""Current friendly_name from the state machine, falling back to the config name."""
|
|
if entity_id:
|
|
for state in _iter_states(hass):
|
|
if getattr(state, "entity_id", None) != entity_id:
|
|
continue
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
friendly = (
|
|
attrs.get("friendly_name") if isinstance(attrs, Mapping) else None
|
|
)
|
|
if friendly:
|
|
return str(friendly)
|
|
break
|
|
if fallback:
|
|
return str(fallback)
|
|
return entity_id
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/helpers_list
|
|
# =============================================================================
|
|
def _do_helpers_list(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
secret_scrub_degraded: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""List collection helpers (live state bodies) + flow helpers (config-entry options).
|
|
|
|
Flow-helper ``options`` come straight from ``ConfigEntry.options`` — no
|
|
OptionsFlow start/abort dance, and NEVER ``entry.data`` (integration
|
|
credentials). Every record carries the CURRENT entity_id + display name from
|
|
the entity registry so a renamed helper shows current values (issue #1794),
|
|
not the stale storage-collection name.
|
|
|
|
Flow-helper ``options`` share the credential-bearing exposure class of
|
|
``config_entries``'s ``options`` (a flow helper IS a config entry), so they pass
|
|
through the SAME best-effort resolved-``!secret`` scrub for uniformity — a
|
|
present-but-unreadable ``secrets.yaml`` degrades the scrub to a no-op and sets
|
|
``secret_scrub_degraded: true`` (present ONLY when degraded). Collection-helper
|
|
bodies (storage collection / live state attributes) are not scrubbed — they carry
|
|
no YAML-resolved secret.
|
|
|
|
``covered_types`` names exactly the helper_type values this command can
|
|
enumerate (the state-machine collection domains + the flow domains, minus the
|
|
flow set when ``include_flow_helpers`` is false). It is the anti-silent-wrong
|
|
signal: for a requested helper_type NOT in ``covered_types`` (e.g. ``tag``,
|
|
which has no state entity), an empty ``helpers`` list means "cannot
|
|
enumerate", NOT "none exist" — the server must fall back to its legacy
|
|
``<type>/list`` path rather than trust the emptiness.
|
|
"""
|
|
requested = params.get("helper_types")
|
|
type_filter = frozenset(requested) if requested else None
|
|
include_flow = params.get("include_flow_helpers", True)
|
|
|
|
view = _resolve_registries(hass)
|
|
helpers = _collection_helpers_list(hass, view, type_filter)
|
|
covered = set(HELPERS_LIST_COLLECTION_DOMAINS)
|
|
if include_flow:
|
|
helpers.extend(_flow_helpers_list(hass, view, type_filter, secret_values))
|
|
covered |= FLOW_HELPER_DOMAINS
|
|
result: dict[str, Any] = {
|
|
"helpers": helpers,
|
|
"count": len(helpers),
|
|
"covered_types": sorted(covered),
|
|
}
|
|
if include_flow and secret_scrub_degraded:
|
|
result["secret_scrub_degraded"] = True
|
|
return result
|
|
|
|
|
|
async def _helpers_list_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Async pre-step for ``helpers_list``: load the secret-scrub set off the loop.
|
|
|
|
Only the flow-helper ``options`` are scrubbed, so the blocking ``secrets.yaml``
|
|
read is skipped entirely when ``include_flow_helpers`` is false (perf gate,
|
|
mirroring ``search``'s entity-only skip). The read runs in the executor via
|
|
:meth:`hass.async_add_executor_job`, keeping :func:`_do_helpers_list` a pure
|
|
in-memory read. See :func:`_load_secret_scrub`.
|
|
"""
|
|
if not msg.get("include_flow_helpers", True):
|
|
return {"secret_values": frozenset(), "secret_scrub_degraded": False}
|
|
values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass)
|
|
return {"secret_values": values, "secret_scrub_degraded": degraded}
|
|
|
|
|
|
def _collection_helpers_list(
|
|
hass: HomeAssistant, view: _RegistryView, type_filter: frozenset[str] | None
|
|
) -> list[dict[str, Any]]:
|
|
"""Collection helpers from the state machine (input_*, counter, timer, zone, …).
|
|
|
|
The record's ``config`` is the entity's real storage ``_config`` body when
|
|
reachable — so a schedule surfaces its weekday blocks, which the live state
|
|
attributes omit — falling back to the state attributes otherwise (see
|
|
:func:`_collection_storage_index`). ``name`` stays the CURRENT display name
|
|
(a rename updates the registry, not the storage body — issue #1794).
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
storage = _collection_storage_index(hass, HELPERS_LIST_COLLECTION_DOMAINS)
|
|
for state in _iter_states(hass):
|
|
entity_id = getattr(state, "entity_id", "") or ""
|
|
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
if domain not in HELPERS_LIST_COLLECTION_DOMAINS:
|
|
continue
|
|
if type_filter is not None and domain not in type_filter:
|
|
continue
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
object_id = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
|
|
reg = _reg_entity(view, entity_id)
|
|
# Current display name: state friendly_name reflects a registry rename;
|
|
# fall back to the registry name, then the object_id.
|
|
current = attrs.get("friendly_name") if isinstance(attrs, Mapping) else None
|
|
name = current or _reg_name(reg) or object_id
|
|
# Prefer the real storage body + id; the state attributes omit fields like
|
|
# a schedule's weekday blocks.
|
|
stored = storage.get(entity_id)
|
|
if stored is not None:
|
|
body, storage_id = stored
|
|
else:
|
|
body = dict(attrs) if isinstance(attrs, Mapping) else {}
|
|
storage_id = getattr(reg, "unique_id", None) or object_id
|
|
out.append(
|
|
{
|
|
"helper_type": domain,
|
|
"kind": "collection",
|
|
"entity_id": entity_id,
|
|
"object_id": object_id,
|
|
"name": str(name),
|
|
"storage_id": storage_id,
|
|
"config": _plainify(body),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _flow_helpers_list(
|
|
hass: HomeAssistant,
|
|
view: _RegistryView,
|
|
type_filter: frozenset[str] | None,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> list[dict[str, Any]]:
|
|
"""Flow (config-entry-backed) helpers — options + title + entry_id, never data.
|
|
|
|
``options`` is passed through the same resolved-``!secret`` scrub
|
|
``config_entries`` applies (a flow helper is a config entry, so its ``options``
|
|
share the same exposure class); an empty ``secret_values`` is a no-op.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
entity_by_entry = _entities_by_config_entry(view)
|
|
for entry in _iter_config_entries(hass):
|
|
domain = getattr(entry, "domain", None)
|
|
if domain not in FLOW_HELPER_DOMAINS:
|
|
continue
|
|
if type_filter is not None and domain not in type_filter:
|
|
continue
|
|
entry_id = getattr(entry, "entry_id", None)
|
|
title = getattr(entry, "title", None) or ""
|
|
raw_options = getattr(entry, "options", None)
|
|
options = (
|
|
_scrub_secret_values(_plainify(dict(raw_options)), secret_values)
|
|
if isinstance(raw_options, Mapping)
|
|
else {}
|
|
)
|
|
reg = entity_by_entry.get(entry_id)
|
|
entity_id = getattr(reg, "entity_id", None) if reg is not None else None
|
|
name = _reg_name(reg) or _current_friendly_name(hass, entity_id, title)
|
|
out.append(
|
|
{
|
|
"helper_type": domain,
|
|
"kind": "flow",
|
|
"entry_id": entry_id,
|
|
"entity_id": entity_id,
|
|
"name": str(name) if name else title,
|
|
"storage_id": entry_id,
|
|
# Data minimization: options only, never entry.data.
|
|
"options": options,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _entities_by_config_entry(view: _RegistryView) -> dict[Any, Any]:
|
|
"""Index the first registry entity bound to each config entry (flow helpers)."""
|
|
index: dict[Any, Any] = {}
|
|
for entry in _all_entity_entries(view):
|
|
config_entry_id = getattr(entry, "config_entry_id", None)
|
|
if config_entry_id and config_entry_id not in index:
|
|
index[config_entry_id] = entry
|
|
return index
|
|
|
|
|
|
def _all_entity_entries(view: _RegistryView) -> list[Any]:
|
|
"""All entity-registry entries (``registry.entities`` is a mapping in HA)."""
|
|
reg = view.entity
|
|
entities = getattr(reg, "entities", None) if reg is not None else None
|
|
if entities is None:
|
|
return []
|
|
try:
|
|
return list(entities.values())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/overview
|
|
# =============================================================================
|
|
def _do_overview(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the raw in-process reads the server's overview path consumes.
|
|
|
|
NOT the assembled overview envelope — the RAW slices the server's
|
|
``get_system_overview`` + ``ha_get_overview`` wrapper fetch today (states,
|
|
services, entity/device/area registries, ``hass.config``, persistent
|
|
notifications, repairs issues). The server runs its existing overview logic
|
|
over these, so detail_level / domains / pagination stay server-side and no
|
|
logic is duplicated (or drifts) in the component. Registries are BARE lists
|
|
(not the ``{success, result}`` WS wrapper); the server adapts. Collapses the
|
|
~8 round-trips to one in-process call.
|
|
|
|
``slice_errors`` names any slice whose accessor RAISED (empty list when
|
|
clean). A missing/None registry degrades to an empty slice WITHOUT an entry —
|
|
that is "nothing here", not "failed". A genuine raise is caught per slice,
|
|
logged, and named here so the server can tell "empty" from "failed" and fall
|
|
back to its legacy REST read for just that slice instead of trusting the
|
|
empty value.
|
|
"""
|
|
include_notifications = params.get("include_notifications", True)
|
|
include_repairs = params.get("include_repairs", True)
|
|
|
|
view = _resolve_registries(hass)
|
|
slice_errors: list[str] = []
|
|
|
|
def _slice(name: str, fn: Any, default: Any) -> Any:
|
|
try:
|
|
return fn()
|
|
except Exception:
|
|
_LOGGER.warning("overview slice %r degraded", name, exc_info=True)
|
|
slice_errors.append(name)
|
|
return default
|
|
|
|
result: dict[str, Any] = {
|
|
"states": _slice("states", lambda: _overview_states(hass), []),
|
|
"services": _slice("services", lambda: _overview_services(hass), []),
|
|
"entity_registry": _slice(
|
|
"entity_registry", lambda: _overview_entity_registry(view), []
|
|
),
|
|
"device_registry": _slice(
|
|
"device_registry", lambda: _overview_device_registry(view), []
|
|
),
|
|
"area_registry": _slice(
|
|
"area_registry", lambda: _overview_area_registry(view), []
|
|
),
|
|
"config": _slice("config", lambda: _overview_config(hass), {}),
|
|
"notifications": _slice(
|
|
"notifications", lambda: _overview_notifications(hass), []
|
|
)
|
|
if include_notifications
|
|
else [],
|
|
"repairs": _slice("repairs", lambda: _overview_repairs(hass), [])
|
|
if include_repairs
|
|
else [],
|
|
}
|
|
result["slice_errors"] = slice_errors
|
|
return result
|
|
|
|
|
|
def _overview_states(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|
"""States in the ``client.get_states()`` shape the overview consumer reads."""
|
|
out: list[dict[str, Any]] = []
|
|
for state in _iter_states(hass):
|
|
entity_id = getattr(state, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
out.append(
|
|
{
|
|
"entity_id": entity_id,
|
|
"state": getattr(state, "state", "unknown"),
|
|
"attributes": _plainify(dict(attrs))
|
|
if isinstance(attrs, Mapping)
|
|
else {},
|
|
"last_changed": _iso(getattr(state, "last_changed", None)),
|
|
"last_updated": _iso(getattr(state, "last_updated", None)),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _overview_services(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|
"""Service catalog in the ``client.get_services()`` list shape.
|
|
|
|
The consumer's ``_build_service_stats`` reads only the per-domain service
|
|
*names*, so each service maps to an empty dict — keeps the frame small while
|
|
preserving the ``{domain, services: {name: {...}}}`` structure.
|
|
"""
|
|
services = _call_no_arg(getattr(hass, "services", None), "async_services")
|
|
if not isinstance(services, Mapping):
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
for domain, svcs in services.items():
|
|
names = list(svcs.keys()) if isinstance(svcs, Mapping) else []
|
|
out.append({"domain": domain, "services": {name: {} for name in names}})
|
|
return out
|
|
|
|
|
|
def _overview_entity_registry(view: _RegistryView) -> list[dict[str, Any]]:
|
|
"""Entity registry as a bare list, with the fields the overview + visibility
|
|
consumers read (area/device/labels/entity_category/hidden_by/options/…)."""
|
|
out: list[dict[str, Any]] = []
|
|
for entry in _all_entity_entries(view):
|
|
entity_id = getattr(entry, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
out.append(
|
|
{
|
|
"entity_id": entity_id,
|
|
"area_id": getattr(entry, "area_id", None),
|
|
"device_id": getattr(entry, "device_id", None),
|
|
"labels": sorted(
|
|
str(x) for x in (getattr(entry, "labels", None) or [])
|
|
),
|
|
"entity_category": _enum_value(getattr(entry, "entity_category", None)),
|
|
"hidden_by": _enum_value(getattr(entry, "hidden_by", None)),
|
|
"categories": _plainify(getattr(entry, "categories", None) or {}),
|
|
"options": _plainify(getattr(entry, "options", None) or {}),
|
|
"name": getattr(entry, "name", None),
|
|
"original_name": getattr(entry, "original_name", None),
|
|
"platform": getattr(entry, "platform", None),
|
|
"unique_id": getattr(entry, "unique_id", None),
|
|
"disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _overview_device_registry(view: _RegistryView) -> list[dict[str, Any]]:
|
|
"""Device registry as a bare list (id + area + labels + name/manufacturer/model)."""
|
|
out: list[dict[str, Any]] = []
|
|
reg = view.device
|
|
devices = getattr(reg, "devices", None) if reg is not None else None
|
|
values = _mapping_values(devices)
|
|
for dev in values:
|
|
dev_id = getattr(dev, "id", None)
|
|
if not dev_id:
|
|
continue
|
|
out.append(
|
|
{
|
|
"id": dev_id,
|
|
"area_id": getattr(dev, "area_id", None),
|
|
"labels": sorted(str(x) for x in (getattr(dev, "labels", None) or [])),
|
|
"name": getattr(dev, "name", None),
|
|
"name_by_user": getattr(dev, "name_by_user", None),
|
|
"manufacturer": getattr(dev, "manufacturer", None),
|
|
"model": getattr(dev, "model", None),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _overview_area_registry(view: _RegistryView) -> list[dict[str, Any]]:
|
|
"""Area registry as a bare list (area_id + name + floor_id)."""
|
|
out: list[dict[str, Any]] = []
|
|
for area in _all_area_entries(view):
|
|
area_id = getattr(area, "id", None) or getattr(area, "area_id", None)
|
|
if not area_id:
|
|
continue
|
|
out.append(
|
|
{
|
|
"area_id": area_id,
|
|
"name": getattr(area, "name", None),
|
|
"floor_id": getattr(area, "floor_id", None),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _all_area_entries(view: _RegistryView) -> list[Any]:
|
|
"""All area-registry entries via ``async_list_areas()`` or the ``areas`` mapping."""
|
|
reg = view.area
|
|
if reg is None:
|
|
return []
|
|
listed = _call_no_arg(reg, "async_list_areas")
|
|
if listed is not None:
|
|
try:
|
|
return list(listed)
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
return _mapping_values(getattr(reg, "areas", None))
|
|
|
|
|
|
def _mapping_values(mapping: Any) -> list[Any]:
|
|
"""``list(mapping.values())`` guarded against a non-mapping / drift."""
|
|
if mapping is None:
|
|
return []
|
|
try:
|
|
return list(mapping.values())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _overview_config(hass: HomeAssistant) -> dict[str, Any]:
|
|
"""The ``hass.config`` fields the wrapper's ``_fetch_system_info`` reads.
|
|
|
|
``base_url`` is intentionally omitted — the server supplies it from its own
|
|
client; only HA-core config values are the component's to provide.
|
|
"""
|
|
config = getattr(hass, "config", None)
|
|
raw = _call_no_arg(config, "as_dict")
|
|
if not isinstance(raw, Mapping):
|
|
return {}
|
|
keys = (
|
|
"version",
|
|
"location_name",
|
|
"time_zone",
|
|
"language",
|
|
"state",
|
|
"country",
|
|
"currency",
|
|
"unit_system",
|
|
"latitude",
|
|
"longitude",
|
|
"elevation",
|
|
"components",
|
|
"safe_mode",
|
|
"internal_url",
|
|
"external_url",
|
|
"allowlist_external_dirs",
|
|
)
|
|
return {k: _plainify(raw[k]) for k in keys if k in raw}
|
|
|
|
|
|
def _overview_notifications(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|
"""Active persistent notifications (``persistent_notification/get`` shape)."""
|
|
store = getattr(hass, "data", None)
|
|
data = store.get("persistent_notification") if isinstance(store, Mapping) else None
|
|
return [
|
|
{
|
|
"notification_id": _field(note, "notification_id"),
|
|
"title": _field(note, "title"),
|
|
"message": _field(note, "message"),
|
|
"created_at": _iso(_field(note, "created_at")),
|
|
}
|
|
for note in _notification_values(data)
|
|
]
|
|
|
|
|
|
def _field(obj: Any, key: str) -> Any:
|
|
"""Read ``key`` from a mapping (``.get``) or an object (``getattr``)."""
|
|
if isinstance(obj, Mapping):
|
|
return obj.get(key)
|
|
return getattr(obj, key, None)
|
|
|
|
|
|
def _notification_values(data: Any) -> list[Any]:
|
|
"""Notification records: ``{id: note}`` mapping values, or a bare list."""
|
|
if isinstance(data, Mapping):
|
|
return list(data.values())
|
|
if isinstance(data, list):
|
|
return list(data)
|
|
return []
|
|
|
|
|
|
def _overview_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|
"""Raw issue-registry entries (the server filters/projects them itself).
|
|
|
|
``ignored`` is derived from ``dismissed_version`` so the server's
|
|
``filter_active_repairs`` (which keys off ``ignored``) works unchanged.
|
|
|
|
Inactive entries are skipped to match HA core's ``ws_list_issues``: on
|
|
restart the registry reloads each stored non-persistent issue as an
|
|
inactive stub (``active=False``), and it stays inactive until the
|
|
integration re-raises or deletes it.
|
|
"""
|
|
registry = _safe(ir.async_get, hass)
|
|
issues = getattr(registry, "issues", None) if registry is not None else None
|
|
out: list[dict[str, Any]] = []
|
|
for issue in _mapping_values(issues):
|
|
if getattr(issue, "active", True) is False:
|
|
continue
|
|
dismissed = getattr(issue, "dismissed_version", None)
|
|
out.append(
|
|
{
|
|
"issue_id": getattr(issue, "issue_id", None),
|
|
"domain": getattr(issue, "domain", None),
|
|
"severity": _enum_value(getattr(issue, "severity", None)),
|
|
"translation_key": getattr(issue, "translation_key", None),
|
|
"translation_placeholders": _plainify(
|
|
getattr(issue, "translation_placeholders", None) or {}
|
|
),
|
|
"ignored": dismissed is not None,
|
|
"dismissed_version": dismissed,
|
|
"is_fixable": getattr(issue, "is_fixable", None),
|
|
"breaks_in_ha_version": getattr(issue, "breaks_in_ha_version", None),
|
|
"created": _iso(getattr(issue, "created", None)),
|
|
"issue_domain": getattr(issue, "issue_domain", None),
|
|
"learn_more_url": getattr(issue, "learn_more_url", None),
|
|
"active": getattr(issue, "active", None),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/states
|
|
# =============================================================================
|
|
def _do_states(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return ``State.as_dict()`` for each requested entity_id + a ``missing`` list.
|
|
|
|
``hass.states.get(id)`` is a pure O(1) in-memory dict read, and core's
|
|
``State.as_dict()`` is exactly the serialization the REST ``/api/states/<id>``
|
|
endpoint emits — so a component-served record is byte-identical to the legacy
|
|
per-id REST fetch by construction (the WS transport JSON-encodes the same
|
|
datetimes to the same ISO strings the REST layer does). The body is returned
|
|
UNMODIFIED — never ``_plainify``'d — precisely so that byte-parity holds:
|
|
``_plainify``'s ``str()`` would render a datetime with a space separator where
|
|
both REST and WS use ``isoformat``'s ``T``. No freshness or secrets concern:
|
|
state bodies are always live and carry no ``!secret`` plaintext. The server
|
|
enforces its own ``MAX_ENTITIES`` cap before calling, so no per-frame guard is
|
|
needed here (100 full states is well within one frame — ``overview`` already
|
|
returns every state in one call).
|
|
"""
|
|
entity_ids = params.get("entity_ids") or []
|
|
states: dict[str, Any] = {}
|
|
missing: list[str] = []
|
|
for entity_id in entity_ids:
|
|
state = _state_get(hass, entity_id)
|
|
if state is None:
|
|
missing.append(entity_id)
|
|
continue
|
|
as_dict = _state_as_dict(state)
|
|
if as_dict is None:
|
|
# A live state that could not be serialized (core drift) goes to
|
|
# ``missing`` rather than emitting a null state indistinguishable from
|
|
# a real value — the server maps ``missing`` onto its per-id contract.
|
|
missing.append(entity_id)
|
|
continue
|
|
states[entity_id] = as_dict
|
|
return {"states": states, "missing": missing}
|
|
|
|
|
|
def _state_get(hass: HomeAssistant, entity_id: str) -> Any:
|
|
"""``hass.states.get(entity_id)`` guarded against core drift (``None`` if absent)."""
|
|
states = getattr(hass, "states", None)
|
|
getter = getattr(states, "get", None) if states is not None else None
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
return getter(entity_id)
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _state_as_dict(state: Any) -> Any:
|
|
"""core ``State.as_dict()`` verbatim — the REST ``/api/states/<id>`` shape.
|
|
|
|
Returned unmodified so the WS transport encodes its datetimes with the same
|
|
``isoformat`` the REST layer uses (byte-parity — see :func:`_do_states`).
|
|
"""
|
|
as_dict = getattr(state, "as_dict", None)
|
|
if callable(as_dict):
|
|
try:
|
|
return as_dict()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/blueprint_get
|
|
# =============================================================================
|
|
def _do_blueprint_get(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
body: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return one installed blueprint's full body as ``{metadata, config}``.
|
|
|
|
core's ``blueprint/list`` returns only ``{metadata}`` (no triggers /
|
|
conditions / actions / sequence), so the server can otherwise serve metadata
|
|
only. This reads the on-disk blueprint file and returns the parsed body:
|
|
``config`` is the full file (the server merges it additively over the
|
|
``blueprint/list`` metadata) and ``metadata`` is its ``blueprint:`` section.
|
|
When the file is missing, unparseable, or the requested path escapes the jail,
|
|
both come back ``None`` (the server keeps metadata-only) — see
|
|
:func:`_read_blueprint_file`.
|
|
|
|
Pure: the blocking jail-resolve + file read + YAML parse run in the executor
|
|
via :func:`_blueprint_get_prep`, which passes the parsed ``body`` in.
|
|
"""
|
|
if not isinstance(body, dict):
|
|
return {"metadata": None, "config": None}
|
|
metadata = body.get("blueprint")
|
|
return {
|
|
"metadata": _plainify(metadata) if isinstance(metadata, dict) else None,
|
|
"config": _plainify(body),
|
|
}
|
|
|
|
|
|
async def _blueprint_get_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Async pre-step for ``blueprint_get``: jail + read + parse off the loop.
|
|
|
|
The path jail (symlink-safe ``Path.resolve`` containment), the ``open()`` and
|
|
the YAML parse are all blocking filesystem work, so they run in the executor
|
|
via :meth:`hass.async_add_executor_job` — keeping :func:`_do_blueprint_get` a
|
|
pure assembler over the parsed ``body`` this returns (``None`` on any failure).
|
|
"""
|
|
domain = msg["domain"]
|
|
path = msg["path"]
|
|
body = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
|
|
return {"body": body}
|
|
|
|
|
|
def _read_blueprint_file(
|
|
hass: HomeAssistant, domain: str, path: str
|
|
) -> dict[str, Any] | None:
|
|
"""Resolve + jail + read + parse one blueprint YAML file. ``None`` on failure.
|
|
|
|
Blueprint files live under ``<config>/blueprints/<domain>/``. The requested
|
|
``path`` is joined under that root and resolved symlink-safe (mirrors the
|
|
file-tool jail's ``_resolves_within`` — resolve the RAW input, following
|
|
symlinks, THEN check containment, so ``<root>/<symlink>/..`` cannot escape). A
|
|
path escaping the root — via ``..``, an absolute path, or a symlink — yields
|
|
``None`` (rejected, never opened). A missing file, a non-file target, a read
|
|
error, or a YAML parse error also yields ``None``. Only a valid, contained,
|
|
parseable blueprint returns its full parsed body.
|
|
|
|
Parsed with :class:`_BlueprintLoader`: ``!input`` markers are preserved and
|
|
every other custom tag (``!secret`` / ``!include`` / …) is neutralized to
|
|
``None``, so no resolved secret plaintext can ever enter the returned body
|
|
(defense in depth — blueprints use ``!input``, not ``!secret``).
|
|
"""
|
|
config = getattr(hass, "config", None)
|
|
path_fn = getattr(config, "path", None)
|
|
if not callable(path_fn):
|
|
return None
|
|
try:
|
|
base = Path(path_fn("blueprints", domain))
|
|
candidate = Path(path) if path.startswith("/") else base / path
|
|
real = candidate.resolve()
|
|
base_real = base.resolve()
|
|
except (OSError, ValueError):
|
|
return None
|
|
if not (real == base_real or real.is_relative_to(base_real)):
|
|
return None
|
|
try:
|
|
with open(real, encoding="utf-8") as handle:
|
|
# Instance form (not yaml.load) mirrors the component's existing
|
|
# _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
|
|
# so no !!python/object can construct arbitrary types.
|
|
loader = _BlueprintLoader(handle)
|
|
try:
|
|
parsed = loader.get_single_data()
|
|
finally:
|
|
loader.dispose()
|
|
except (OSError, ValueError, yaml.YAMLError):
|
|
return None
|
|
return parsed if isinstance(parsed, dict) else None
|
|
|
|
|
|
def _construct_blueprint_input(loader: Any, node: Any) -> dict[str, str]:
|
|
"""Represent ``!input <name>`` as ``{"__input__": <name>}``.
|
|
|
|
A JSON-safe, unambiguous marker of a blueprint input substitution point (the
|
|
body is a display artifact, not a runnable config), so a consumer can see
|
|
which fields an input fills without the tag crashing a plain safe-load.
|
|
"""
|
|
return {"__input__": str(getattr(node, "value", ""))}
|
|
|
|
|
|
def _drop_blueprint_tag(loader: Any, tag_suffix: Any, node: Any) -> None:
|
|
"""Neutralize every non-``!input`` custom tag to ``None`` (never resolve it).
|
|
|
|
``!secret`` must never resolve to plaintext; ``!include`` / ``!env_var`` /
|
|
unknown tags are irrelevant to a read-only body view. Mirrors the component's
|
|
``_ignore_unknown_tag`` pattern in ``__init__.py``.
|
|
"""
|
|
return None
|
|
|
|
|
|
class _BlueprintLoader(yaml.SafeLoader):
|
|
"""SafeLoader for blueprint files: keep ``!input``, neutralize all other tags."""
|
|
|
|
|
|
_BlueprintLoader.add_constructor("!input", _construct_blueprint_input)
|
|
_BlueprintLoader.add_multi_constructor("!", _drop_blueprint_tag)
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/device_get + ha_mcp_tools/device_list
|
|
# =============================================================================
|
|
def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return one device registry entry by id, optionally with its entities.
|
|
|
|
``{device: <DeviceEntry.dict_repr> | None}`` — ``registry.async_get(device_id)``
|
|
is a pure O(1) in-memory dict read, and the emitted body is core's
|
|
``DeviceEntry.dict_repr`` returned UNMODIFIED — exactly the shape
|
|
``config/device_registry/list`` serializes (it sends
|
|
``json_bytes(entry.dict_repr)``), so a component-served record is byte-identical
|
|
to one legacy list element by construction (the WS transport JSON-encodes the
|
|
same dict with the same encoder). The body is never ``_plainify``'d: that would
|
|
``str()`` the ``disabled_by`` / ``entry_type`` enums to their repr instead of the
|
|
wire value core's encoder emits, breaking parity. ``device`` is ``None`` when no
|
|
such device exists — the server maps that onto its own not-found contract.
|
|
|
|
When ``include_entities`` is set, a SIBLING ``entities`` key carries the device's
|
|
entity-registry rows (``[<RegistryEntry.as_partial_dict>, ...]`` — the same shape
|
|
and serialization ``config/entity_registry/list`` emits), so a single-device
|
|
lookup no longer pulls the WHOLE entity registry to list one device's entities.
|
|
``er.async_entries_for_device`` is called with ``include_disabled_entities=True``
|
|
to match what ``config/entity_registry/list`` returns (it lists disabled entities
|
|
too). The DeviceEntry dict itself stays exactly the raw shape — the join is a
|
|
sibling, so consumers keep their own transforms. The ``entities`` key is present
|
|
only when requested.
|
|
"""
|
|
device_id = params.get("device_id")
|
|
include_entities = params.get("include_entities", False)
|
|
view = _resolve_registries(hass)
|
|
entry = _device(view, device_id) if device_id else None
|
|
result: dict[str, Any] = {
|
|
"device": _device_dict_repr(entry) if entry is not None else None
|
|
}
|
|
if include_entities:
|
|
result["entities"] = _device_entities(view, device_id) if device_id else []
|
|
return result
|
|
|
|
|
|
def _do_device_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return every device registry entry as ``{devices: [dict_repr, ...]}``.
|
|
|
|
The in-process equivalent of ``config/device_registry/list``: each element is
|
|
core's ``DeviceEntry.dict_repr`` returned VERBATIM (same byte-parity rationale
|
|
as :func:`_do_device_get`), so the server's existing device transforms consume
|
|
it unchanged. An entry whose ``dict_repr`` is unavailable is skipped rather
|
|
than emitted as a partial record.
|
|
"""
|
|
view = _resolve_registries(hass)
|
|
reg = view.device
|
|
devices = getattr(reg, "devices", None) if reg is not None else None
|
|
out: list[dict[str, Any]] = []
|
|
for dev in _mapping_values(devices):
|
|
repr_dict = _device_dict_repr(dev)
|
|
if repr_dict is not None:
|
|
out.append(repr_dict)
|
|
else:
|
|
_LOGGER.warning(
|
|
"device_list: skipping device %r with unavailable dict_repr",
|
|
getattr(dev, "id", None),
|
|
)
|
|
return {"devices": out}
|
|
|
|
|
|
def _device_dict_repr(entry: Any) -> dict[str, Any] | None:
|
|
"""core ``DeviceEntry.dict_repr`` verbatim — the ``config/device_registry/list`` shape.
|
|
|
|
Returned UNMODIFIED so the WS transport encodes it with the same JSON
|
|
serializer ``config/device_registry/list`` uses (byte-parity — see
|
|
:func:`_do_device_get`). Guarded against core drift: a missing/raising
|
|
``dict_repr`` yields ``None`` rather than propagating.
|
|
"""
|
|
try:
|
|
repr_dict = entry.dict_repr
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return None
|
|
return repr_dict if isinstance(repr_dict, dict) else None
|
|
|
|
|
|
def _device_entities(view: _RegistryView, device_id: str) -> list[dict[str, Any]]:
|
|
"""The device's entity-registry rows as ``config/entity_registry/list`` elements.
|
|
|
|
Each row is core's ``RegistryEntry.as_partial_dict`` returned VERBATIM (the same
|
|
shape + serialization ``config/entity_registry/list`` emits — it sends
|
|
``json_bytes(entry.partial_json_repr)`` over ``as_partial_dict``), so the
|
|
server's device<->entity map builds identically off the join or the legacy list.
|
|
A row whose ``as_partial_dict`` is unavailable is skipped.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
for entry in _entries_for_device(view, device_id):
|
|
partial = _entity_partial_dict(entry)
|
|
if partial is not None:
|
|
out.append(partial)
|
|
return out
|
|
|
|
|
|
def _entries_for_device(view: _RegistryView, device_id: str) -> list[Any]:
|
|
"""Entity-registry entries bound to ``device_id``, disabled ones INCLUDED.
|
|
|
|
Delegates to core's ``er.async_entries_for_device`` (its device_id index) with
|
|
``include_disabled_entities=True`` so the result matches what
|
|
``config/entity_registry/list`` returns — that command lists disabled entities
|
|
too, and dropping them would diverge the join from the legacy shape. Guarded
|
|
against a missing registry / core drift (returns ``[]``).
|
|
"""
|
|
reg = view.entity
|
|
if reg is None or not device_id:
|
|
return []
|
|
try:
|
|
entries = er.async_entries_for_device(
|
|
reg, device_id, include_disabled_entities=True
|
|
)
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return []
|
|
return list(entries)
|
|
|
|
|
|
def _entity_partial_dict(entry: Any) -> dict[str, Any] | None:
|
|
"""core ``RegistryEntry.as_partial_dict`` verbatim — the ``config/entity_registry/list`` shape.
|
|
|
|
Returned UNMODIFIED so the WS transport encodes it with the same serializer
|
|
``config/entity_registry/list`` uses (byte-parity, mirroring
|
|
:func:`_device_dict_repr`). Guarded against core drift.
|
|
"""
|
|
try:
|
|
partial = entry.as_partial_dict
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return None
|
|
return partial if isinstance(partial, dict) else None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/entity_enrich
|
|
# =============================================================================
|
|
def _do_entity_enrich(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the area/floor/labels/aliases join for each requested entity_id.
|
|
|
|
``{entities: {id: {area, floor, labels, aliases}}}`` — each id runs through the
|
|
SAME :func:`_registry_enrichment` join the search path uses (device-inherited
|
|
area/labels, resolved NAMES), so ``ha_get_entity`` adds the resolved-name
|
|
fields the raw registry entry lacks without the caller fanning out its own
|
|
area/floor/label registry reads. Pure O(id) in-memory registry lookups; a
|
|
registry-only (stateless) entity is enriched too (the join keys off the
|
|
registry, not the state machine). An id with no registry entry yields empty /
|
|
``None`` fields rather than being dropped, so the caller can pair the result
|
|
back to its request by key.
|
|
"""
|
|
entity_ids = params.get("entity_ids") or []
|
|
view = _resolve_registries(hass)
|
|
entities: dict[str, Any] = {}
|
|
for entity_id in entity_ids:
|
|
join = _registry_enrichment(view, entity_id)
|
|
entities[entity_id] = {
|
|
"area": join["area"],
|
|
"floor": join["floor"],
|
|
"labels": join["labels"],
|
|
"aliases": join["aliases"],
|
|
}
|
|
return {"entities": entities}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/exposure
|
|
# =============================================================================
|
|
def _do_exposure(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return voice-assistant exposure with names/areas attached.
|
|
|
|
``{exposed_entities: {id: {assistant: True}}, entity_info: {id: {...}}}``.
|
|
``exposed_entities`` is byte-identical to core's ``ws_list_exposed_entities``
|
|
result (``homeassistant/expose_entity/list``): only ``should_expose``-true
|
|
assistants appear, and an entity with none is omitted from the map — so the
|
|
server's existing exposure shaping consumes it unchanged. ``entity_info`` is
|
|
the additive half: each relevant id enriched through :func:`_registry_enrichment`
|
|
(friendly_name/domain/area/floor/labels), closing the "one call gives a bare
|
|
``{id: {assistant: bool}}`` map with no names/areas" gap.
|
|
|
|
Modes:
|
|
|
|
* single-entity (``entity_id`` set) — reads core's module-level
|
|
``async_get_entity_settings`` for that id and enriches it (whether exposed or
|
|
not — the caller asked about that specific entity).
|
|
* list (``entity_id`` omitted) — mirrors ``ws_list_exposed_entities``: walks
|
|
the exposed-entities store ids + the entity registry, keeps the exposed ones,
|
|
and enriches each.
|
|
|
|
Parity guardrails (mirroring the legacy shape, pinned in tests):
|
|
|
|
1. only ``should_expose``-true assistants are reported (the raw helper returns
|
|
every assistant that has *any* stored option, not just exposed ones);
|
|
2. core's ``HomeAssistantError("Unknown entity")`` on a junk id is caught and
|
|
degrades to the not-exposed default (the legacy ``expose_entity/list`` never
|
|
raises on a junk id);
|
|
3. a missing ``hass.states.get(id)`` omits the live-state fields
|
|
(friendly_name / state) from ``entity_info`` rather than crashing.
|
|
"""
|
|
entity_id = params.get("entity_id")
|
|
view = _resolve_registries(hass)
|
|
|
|
if entity_id:
|
|
exposed_to = _entity_exposed_to(hass, entity_id)
|
|
return {
|
|
"exposed_entities": {entity_id: exposed_to} if exposed_to else {},
|
|
"entity_info": {entity_id: _exposure_enrichment(hass, view, entity_id)},
|
|
}
|
|
|
|
exposed_entities: dict[str, Any] = {}
|
|
entity_info: dict[str, Any] = {}
|
|
for eid in _all_exposable_entity_ids(hass, view):
|
|
exposed_to = _entity_exposed_to(hass, eid)
|
|
if not exposed_to:
|
|
continue
|
|
exposed_entities[eid] = exposed_to
|
|
entity_info[eid] = _exposure_enrichment(hass, view, eid)
|
|
return {"exposed_entities": exposed_entities, "entity_info": entity_info}
|
|
|
|
|
|
def _entity_exposed_to(hass: HomeAssistant, entity_id: str) -> dict[str, bool]:
|
|
"""``{assistant: True}`` for the entity's ``should_expose``-true assistants.
|
|
|
|
Reads core's ``async_get_entity_settings`` (via the local
|
|
:func:`_async_get_entity_settings` test-seam wrapper) and keeps only assistants
|
|
whose settings carry a truthy ``should_expose`` (guardrail 1 — the raw helper is
|
|
not pre-filtered like ``ws_list_exposed_entities``). A junk id whose helper raises
|
|
``HomeAssistantError("Unknown entity")`` degrades to ``{}`` (guardrail 2), the
|
|
same not-exposed default the legacy path returns for an id it never listed.
|
|
"""
|
|
try:
|
|
settings = _async_get_entity_settings(hass, entity_id)
|
|
except Exception as exc:
|
|
if _is_unknown_entity_error(exc):
|
|
return {}
|
|
raise
|
|
out: dict[str, bool] = {}
|
|
if isinstance(settings, Mapping):
|
|
for assistant, opts in settings.items():
|
|
if isinstance(opts, Mapping) and opts.get("should_expose"):
|
|
out[str(assistant)] = True
|
|
return out
|
|
|
|
|
|
def _exposure_enrichment(
|
|
hass: HomeAssistant, view: _RegistryView, entity_id: str
|
|
) -> dict[str, Any]:
|
|
"""area/floor/labels + domain for an id, plus live-state fields when present.
|
|
|
|
Runs the id through :func:`_registry_enrichment` for area/floor/labels
|
|
(device-inherited names). ``domain`` comes from the id itself (no state
|
|
needed). ``friendly_name`` and ``state`` are LIVE-STATE fields: included only
|
|
when ``hass.states.get(id)`` exists, omitted otherwise (guardrail 3 — a
|
|
disabled / legacy-only entity has no state, so those keys are simply absent
|
|
rather than crashing the join).
|
|
"""
|
|
join = _registry_enrichment(view, entity_id)
|
|
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
info: dict[str, Any] = {
|
|
"domain": domain,
|
|
"area": join["area"],
|
|
"floor": join["floor"],
|
|
"labels": join["labels"],
|
|
}
|
|
state = _state_get(hass, entity_id)
|
|
if state is not None:
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
friendly = (
|
|
attrs.get("friendly_name", entity_id)
|
|
if isinstance(attrs, Mapping)
|
|
else entity_id
|
|
)
|
|
info["friendly_name"] = str(friendly)
|
|
info["state"] = getattr(state, "state", "unknown")
|
|
return info
|
|
|
|
|
|
def _all_exposable_entity_ids(hass: HomeAssistant, view: _RegistryView) -> list[str]:
|
|
"""Every id ``ws_list_exposed_entities`` walks: store ids plus registry ids.
|
|
|
|
Core iterates ``chain(exposed_entities.entities, entity_registry.entities)`` —
|
|
the legacy store (entities WITHOUT a unique_id, exposed manually) plus every
|
|
registry entity. This reproduces that union, de-duplicated with store-first
|
|
order, so an exposed YAML entity that lives only in the store is not missed.
|
|
"""
|
|
ordered: list[str] = []
|
|
seen: set[str] = set()
|
|
for eid in _legacy_exposed_entity_ids(hass):
|
|
if eid and eid not in seen:
|
|
seen.add(eid)
|
|
ordered.append(eid)
|
|
for entry in _all_entity_entries(view):
|
|
entry_eid = getattr(entry, "entity_id", None)
|
|
if entry_eid and entry_eid not in seen:
|
|
seen.add(entry_eid)
|
|
ordered.append(entry_eid)
|
|
return ordered
|
|
|
|
|
|
def _async_get_entity_settings(hass: HomeAssistant, entity_id: str) -> Any:
|
|
"""core's ``async_get_entity_settings(hass, entity_id)``; test seam.
|
|
|
|
Imported lazily so the fake-hass unit suite (which MagicMock-stubs
|
|
``homeassistant.*`` at import time) can monkeypatch this whole function rather
|
|
than the deep core module. Returns ``{assistant: settings_mapping}`` and raises
|
|
``HomeAssistantError("Unknown entity")`` for an id in neither the registry nor
|
|
the exposed-entities store — caught by :func:`_entity_exposed_to`.
|
|
"""
|
|
from homeassistant.components.homeassistant.exposed_entities import (
|
|
async_get_entity_settings,
|
|
)
|
|
|
|
return async_get_entity_settings(hass, entity_id)
|
|
|
|
|
|
def _legacy_exposed_entity_ids(hass: HomeAssistant) -> list[str]:
|
|
"""Entity ids in the exposed-entities store (entities without a unique_id).
|
|
|
|
The ``exposed_entities.entities`` half of core's ``ws_list_exposed_entities``
|
|
iteration. Imported lazily (test seam); a missing store / core drift yields
|
|
``[]`` so list mode still enumerates the registry half.
|
|
"""
|
|
try:
|
|
from homeassistant.components.homeassistant.const import (
|
|
DATA_EXPOSED_ENTITIES,
|
|
)
|
|
|
|
data = getattr(hass, "data", None)
|
|
store = data.get(DATA_EXPOSED_ENTITIES) if isinstance(data, Mapping) else None
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return []
|
|
entities = getattr(store, "entities", None)
|
|
if isinstance(entities, Mapping):
|
|
return [str(eid) for eid in entities]
|
|
return []
|
|
|
|
|
|
def _is_unknown_entity_error(exc: Exception) -> bool:
|
|
"""True for core's ``HomeAssistantError('Unknown entity')`` from the settings helper.
|
|
|
|
Keyed off the exception type NAME (not an ``isinstance`` against the imported
|
|
class) so the fake-hass suite — which stubs ``homeassistant.exceptions`` — can
|
|
raise a stand-in ``HomeAssistantError`` without importing the real class. The
|
|
type name alone is too wide: core raises a plain ``HomeAssistantError`` for
|
|
other faults too, so the message is also required to carry ``unknown entity``
|
|
(case-insensitive). A store-read failure that raises a bare
|
|
``HomeAssistantError`` therefore propagates instead of being silently reported
|
|
as not-exposed; the audit guardrail (junk id → not-exposed default) still
|
|
matches because that raise carries the ``Unknown entity`` message.
|
|
"""
|
|
return (
|
|
type(exc).__name__ == "HomeAssistantError"
|
|
and "unknown entity" in str(exc).lower()
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/config_entries
|
|
# =============================================================================
|
|
def _do_config_entries(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
secret_scrub_degraded: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Return config entries in the ``config_entries/get`` WS shape.
|
|
|
|
``{entries: [{created_at, modified_at, entry_id, domain, 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,
|
|
error_reason_translation_placeholders, num_subentries, options, subentries}]}``.
|
|
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
|
|
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
|
|
``entry_id`` key (``None``) selects list mode; an empty-string ``entry_id`` is
|
|
a single-entry lookup for a nonexistent id, so it returns ``entries: []``
|
|
(mirroring ``async_get_entry("")``) rather than falling through to list mode
|
|
and returning the first entry. ``state`` is serialized as
|
|
``ConfigEntryState.value`` (mirroring
|
|
how core's ``config_entries/get`` emits it via ``as_json_fragment``, whose
|
|
``json_repr`` in ``homeassistant/config_entries.py`` is this row's source
|
|
of truth for every field above except ``options``/``subentries``, which
|
|
this integration re-derives since ``as_json_fragment`` never carries
|
|
credential data).
|
|
|
|
Data minimization: ``entry.data`` (integration credentials) is NEVER read.
|
|
``options`` is the only credential-bearing surface emitted, so it is passed
|
|
through a BEST-EFFORT resolved-``!secret`` scrub — an options leaf (dict value,
|
|
list item, or scalar whose ``str()`` form) that exactly equals a ``secrets.yaml``
|
|
value becomes ``"**redacted**"`` (``secret_values`` loaded off the loop by
|
|
:func:`_config_entries_prep`). ``subentries`` carries identity fields only
|
|
(``subentry_id`` / ``subentry_type`` / ``title`` / ``unique_id``) — never a
|
|
subentry's ``data``; a core version without subentries degrades to ``[]``.
|
|
|
|
The scrub is BEST-EFFORT: a present-but-unreadable ``secrets.yaml`` degrades it
|
|
to a no-op (options emitted unredacted). That degradation is signalled to the
|
|
caller as ``secret_scrub_degraded: true`` (present ONLY when degraded), so an
|
|
agent echoing ``options`` onward can tell an unscrubbed response from a clean
|
|
one rather than trusting redaction that did not run.
|
|
|
|
Pure over ``hass``: the blocking ``secrets.yaml`` read is offloaded by the
|
|
async prep, so this stays a synchronous in-memory read.
|
|
"""
|
|
entry_id = params.get("entry_id")
|
|
domain = params.get("domain")
|
|
if entry_id is not None:
|
|
# Single-entry mode. An empty string is a valid (nonexistent) id — it
|
|
# must NOT fall through to list mode, where a truthiness check would
|
|
# return the first entry for a bogus id.
|
|
entry = _config_entry_by_id(hass, entry_id)
|
|
entries: list[Any] = [entry] if entry is not None else []
|
|
else:
|
|
entries = _iter_config_entries(hass)
|
|
if domain:
|
|
entries = [e for e in entries if getattr(e, "domain", None) == domain]
|
|
result: dict[str, Any] = {
|
|
"entries": [_config_entry_row(e, secret_values) for e in entries]
|
|
}
|
|
if secret_scrub_degraded:
|
|
result["secret_scrub_degraded"] = True
|
|
return result
|
|
|
|
|
|
async def _config_entries_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Async pre-step for ``config_entries``: load the secret-scrub set off the loop.
|
|
|
|
Unlike ``search`` (which skips the read for an entity-only query),
|
|
``config_entries`` ALWAYS emits ``options``, so the ``secrets.yaml`` read is
|
|
unconditional. The blocking ``open()`` + ``yaml.safe_load`` runs in the
|
|
executor via :meth:`hass.async_add_executor_job`, keeping
|
|
:func:`_do_config_entries` a pure in-memory read. The ``degraded`` flag (a
|
|
present-but-unreadable ``secrets.yaml``) rides through so the response can signal
|
|
that ``options`` may be unredacted. See :func:`_load_secret_scrub`.
|
|
"""
|
|
values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass)
|
|
return {"secret_values": values, "secret_scrub_degraded": degraded}
|
|
|
|
|
|
def _config_entry_by_id(hass: HomeAssistant, entry_id: str) -> Any:
|
|
"""``hass.config_entries.async_get_entry(entry_id)`` guarded (``None`` if absent)."""
|
|
config_entries = getattr(hass, "config_entries", None)
|
|
getter = (
|
|
getattr(config_entries, "async_get_entry", None)
|
|
if config_entries is not None
|
|
else None
|
|
)
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
return getter(entry_id)
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _config_entry_row(entry: Any, secret_values: frozenset[str]) -> dict[str, Any]:
|
|
"""One config entry as the ``config_entries/get`` row (options scrubbed)."""
|
|
raw_options = getattr(entry, "options", None)
|
|
options = _plainify(dict(raw_options)) if isinstance(raw_options, Mapping) else {}
|
|
options = _scrub_secret_values(options, secret_values)
|
|
return {
|
|
# Timestamps as floats via ``.timestamp()``, mirroring core's
|
|
# as_json_fragment (``self.created_at.timestamp()``). Absent on a core old
|
|
# enough to predate them -> None (this row is read-only, never restored, so a
|
|
# None key is harmless here — unlike the area-registry rows).
|
|
"created_at": _timestamp(getattr(entry, "created_at", None)),
|
|
"modified_at": _timestamp(getattr(entry, "modified_at", None)),
|
|
"entry_id": getattr(entry, "entry_id", None),
|
|
"domain": getattr(entry, "domain", None),
|
|
"title": getattr(entry, "title", None),
|
|
"state": _enum_value(getattr(entry, "state", None)),
|
|
"source": getattr(entry, "source", None),
|
|
# supports_* are computed properties (they touch the flow handler) — read
|
|
# through _safe_prop so a domain whose handler is unavailable degrades
|
|
# instead of raising. ``or False`` mirrors core's as_json_fragment, which
|
|
# coerces the optional bools to False.
|
|
"supports_options": bool(_safe_prop(entry, "supports_options")),
|
|
"supports_remove_device": _safe_prop(entry, "supports_remove_device") or False,
|
|
"supports_unload": _safe_prop(entry, "supports_unload") or False,
|
|
"supports_reconfigure": bool(_safe_prop(entry, "supports_reconfigure")),
|
|
# supported_subentry_types is a computed property (it touches the flow
|
|
# handler, same hazard class as supports_*) — _safe_prop-guarded, defaulting
|
|
# to {} like core's ``self._supported_subentry_types or {}``.
|
|
"supported_subentry_types": _safe_prop(entry, "supported_subentry_types", {})
|
|
or {},
|
|
"pref_disable_new_entities": bool(
|
|
getattr(entry, "pref_disable_new_entities", False)
|
|
),
|
|
"pref_disable_polling": bool(getattr(entry, "pref_disable_polling", False)),
|
|
"disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
|
|
"reason": getattr(entry, "reason", None),
|
|
"error_reason_translation_key": getattr(
|
|
entry, "error_reason_translation_key", None
|
|
),
|
|
"error_reason_translation_placeholders": getattr(
|
|
entry, "error_reason_translation_placeholders", None
|
|
),
|
|
"num_subentries": len(_mapping_values(getattr(entry, "subentries", None))),
|
|
"options": options,
|
|
"subentries": _config_subentries(entry),
|
|
}
|
|
|
|
|
|
def _config_subentries(entry: Any) -> list[dict[str, Any]]:
|
|
"""Identity fields of each config subentry — NEVER the subentry ``data``.
|
|
|
|
``entry.subentries`` is a ``MappingProxyType`` keyed by subentry_id in modern
|
|
core; a version without it (``getattr`` -> ``None``) degrades to ``[]``.
|
|
"""
|
|
return [
|
|
{
|
|
"subentry_id": getattr(sub, "subentry_id", None),
|
|
"subentry_type": getattr(sub, "subentry_type", None),
|
|
"title": getattr(sub, "title", None),
|
|
"unique_id": getattr(sub, "unique_id", None),
|
|
}
|
|
for sub in _mapping_values(getattr(entry, "subentries", None))
|
|
]
|
|
|
|
|
|
def _scrub_secret_values(value: Any, secret_values: frozenset[str]) -> Any:
|
|
"""Recursively replace any leaf equal to a known secret with ``"**redacted**"``.
|
|
|
|
Walks ``Mapping`` values, list/tuple items, and scalar leaves of the (already
|
|
``_plainify``'d) options structure. A scalar leaf whose plaintext form
|
|
(``str(leaf)``) exactly equals a ``secrets.yaml`` value is redacted, so a
|
|
resolved ``!secret`` never leaves the component whether an integration persists
|
|
it as a string OR as the original scalar (an int ``alarm_code`` leaves as an int
|
|
leaf). ``bool`` leaves are never secrets and pass through unchanged (their
|
|
``"True"``/``"False"`` form would over-redact). An empty ``secret_values`` is a
|
|
no-op (fast path). Handles ``Mapping``/``tuple`` directly so it is correct even
|
|
if applied to a structure that skipped :func:`_plainify`.
|
|
"""
|
|
if not secret_values:
|
|
return value
|
|
if isinstance(value, Mapping):
|
|
return {k: _scrub_secret_values(v, secret_values) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_scrub_secret_values(v, secret_values) for v in value]
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (str, int, float)) and str(value) in secret_values:
|
|
return "**redacted**"
|
|
return value
|
|
|
|
|
|
def _safe_prop(obj: Any, name: str, default: Any = None) -> Any:
|
|
"""Read a possibly-computed property, degrading to ``default`` if it raises.
|
|
|
|
``getattr`` alone only defaults on ``AttributeError``; a ConfigEntry's
|
|
``supports_options`` / ``supports_reconfigure`` are computed off the flow
|
|
handler and can raise other errors when it is unavailable, so catch broadly.
|
|
"""
|
|
try:
|
|
return getattr(obj, name, default)
|
|
except Exception: # pragma: no cover - defensive; core drift
|
|
return default
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/registry_lookup
|
|
# =============================================================================
|
|
def _do_registry_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return entity-registry rows for a set of ids or one config entry.
|
|
|
|
Rows are core's ``RegistryEntry.as_partial_dict`` VERBATIM (the
|
|
``config/entity_registry/list`` shape, disabled entities included), so the
|
|
consumers that parse that exact shape today read the component-served rows
|
|
unchanged.
|
|
|
|
* ``config_entry_id`` — scans the whole entity registry and returns EVERY
|
|
entity bound to that entry (``{entities: [...]}``). It deliberately does
|
|
NOT reuse :func:`_entities_by_config_entry` (single-valued — first entity
|
|
only), so a multi-entity flow helper (a utility_meter and its tariff
|
|
sub-entities) does not silently lose members.
|
|
* ``entity_ids`` — looks each up (``{entities: [...], missing: [...]}``); an
|
|
id with no registry entry lands in ``missing`` rather than being dropped.
|
|
|
|
Pure O(n)/O(id) in-memory registry reads. Exactly one of the two params is
|
|
meaningful — the schema rejects both being present; neither present (or only an
|
|
empty-string ``config_entry_id`` / empty ``entity_ids`` list — no usable target)
|
|
raises ``HomeAssistantError`` (see :func:`_registry_lookup_missing_target`)
|
|
rather than silently returning an empty result the caller could mistake for "no
|
|
matches".
|
|
"""
|
|
config_entry_id = params.get("config_entry_id")
|
|
entity_ids = params.get("entity_ids") or []
|
|
if not config_entry_id and not entity_ids:
|
|
raise _registry_lookup_missing_target()
|
|
|
|
view = _resolve_registries(hass)
|
|
if config_entry_id:
|
|
rows = [
|
|
_entity_partial_dict(entry)
|
|
for entry in _all_entity_entries(view)
|
|
if getattr(entry, "config_entry_id", None) == config_entry_id
|
|
]
|
|
return {"entities": [row for row in rows if row is not None]}
|
|
|
|
found: list[dict[str, Any]] = []
|
|
missing: list[str] = []
|
|
for entity_id in entity_ids:
|
|
entry = _reg_entity(view, entity_id)
|
|
row = _entity_partial_dict(entry) if entry is not None else None
|
|
if row is None:
|
|
missing.append(entity_id)
|
|
else:
|
|
found.append(row)
|
|
return {"entities": found, "missing": missing}
|
|
|
|
|
|
def _registry_lookup_missing_target() -> Exception:
|
|
"""Build a ``HomeAssistantError`` for a target-less ``registry_lookup`` request.
|
|
|
|
Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable)
|
|
so a request with no usable target — neither ``entity_ids`` nor
|
|
``config_entry_id``, OR only an empty-string / empty-list one — raises instead of
|
|
returning ``{entities: [], missing: []}``, a shape indistinguishable from a
|
|
genuine "nothing matched" result, letting the server's command-error fallback
|
|
fire for the degenerate case. (This is a deliberate divergence from
|
|
``config_entries``, whose empty-string ``entry_id`` is an authoritative empty
|
|
result mirroring ``async_get_entry("")`` — a different, intentional semantic.)
|
|
"""
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
err: Exception = HomeAssistantError(
|
|
"ha_mcp_tools/registry_lookup requires a non-empty entity_ids or "
|
|
"config_entry_id"
|
|
)
|
|
return err
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/system_snapshot
|
|
# =============================================================================
|
|
def _do_system_snapshot(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""One consistent synchronous pass over the live objects the health path reads.
|
|
|
|
``{config_entries: [...], issues: [...], entities: [...], states: [...]}`` —
|
|
every section read from the live in-memory objects in a SINGLE synchronous
|
|
handler call, which is the point: it collapses the server's former 3x
|
|
``config_entries/get`` fetches (a TOCTOU where entries changed mid-read) into
|
|
one coherent snapshot. ``include_*`` flags gate each section; a disabled
|
|
section is an empty list (present, so the consumer need not special-case a
|
|
missing key).
|
|
|
|
* ``config_entries`` — identity fields only (``entry_id`` / ``domain`` /
|
|
``title`` / ``state`` / ``source`` / ``disabled_by``); the health view does
|
|
not need ``options`` / ``subentries`` (and skipping them avoids the secret
|
|
scrub here).
|
|
* ``issues`` — the ``_overview_repairs`` slice, reused verbatim.
|
|
* ``entities`` — the ``registry_lookup`` row shape (``as_partial_dict``).
|
|
* ``states`` — ``State.as_dict()`` per state (REST-parity bodies).
|
|
"""
|
|
view = _resolve_registries(hass)
|
|
result: dict[str, Any] = {}
|
|
result["config_entries"] = (
|
|
[_config_entry_identity_row(e) for e in _iter_config_entries(hass)]
|
|
if params.get("include_config_entries", True)
|
|
else []
|
|
)
|
|
result["issues"] = (
|
|
_overview_repairs(hass) if params.get("include_issues", True) else []
|
|
)
|
|
result["entities"] = (
|
|
[
|
|
row
|
|
for row in (_entity_partial_dict(e) for e in _all_entity_entries(view))
|
|
if row is not None
|
|
]
|
|
if params.get("include_entities", True)
|
|
else []
|
|
)
|
|
result["states"] = (
|
|
_snapshot_states(hass) if params.get("include_states", True) else []
|
|
)
|
|
return result
|
|
|
|
|
|
def _config_entry_identity_row(entry: Any) -> dict[str, Any]:
|
|
"""Identity-only config-entry row for the health snapshot (no options/subentries)."""
|
|
return {
|
|
"entry_id": getattr(entry, "entry_id", None),
|
|
"domain": getattr(entry, "domain", None),
|
|
"title": getattr(entry, "title", None),
|
|
"state": _enum_value(getattr(entry, "state", None)),
|
|
"source": getattr(entry, "source", None),
|
|
"disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
|
|
}
|
|
|
|
|
|
def _snapshot_states(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|
"""Every live state as ``State.as_dict()`` (REST-parity body, unmodified)."""
|
|
out: list[dict[str, Any]] = []
|
|
for state in _iter_states(hass):
|
|
if not getattr(state, "entity_id", None):
|
|
continue
|
|
as_dict = _state_as_dict(state)
|
|
if as_dict is not None:
|
|
out.append(as_dict)
|
|
return out
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/entity_lookup
|
|
# =============================================================================
|
|
def _do_entity_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return registry entries whose ``unique_id`` matches (domain/platform narrow).
|
|
|
|
``{matches: [{entity_id, unique_id, platform, domain, config_entry_id,
|
|
categories, disabled_by, hidden_by}]}``. Scans the entity registry for every
|
|
entry whose ``unique_id`` equals the requested one, optionally narrowed by
|
|
``domain`` (the entity's own domain, from its entity_id) and ``platform``
|
|
(the owning integration). Multiple matches across platforms are all returned
|
|
— the server picks. ``categories`` mirrors ``as_partial_dict``'s
|
|
``dict(entry.categories)``; ``disabled_by`` / ``hidden_by`` are unwrapped to
|
|
their wire strings. In-process, so the read is authoritative immediately (no
|
|
registry-write settle retry).
|
|
|
|
A drifted entity registry (``er.async_get`` raised / renamed → ``None``) RAISES
|
|
``HomeAssistantError`` (→ server command-error fallback to the legacy scan)
|
|
rather than returning ``{matches: []}`` — a well-formed empty the server can't
|
|
tell from a genuine "no entry with that unique_id". A present-but-empty registry
|
|
still returns ``{matches: []}`` (correct: no match).
|
|
"""
|
|
unique_id = params.get("unique_id")
|
|
domain = params.get("domain")
|
|
platform = params.get("platform")
|
|
view = _resolve_registries(hass)
|
|
if view.entity is None:
|
|
raise _substrate_unavailable("entity registry")
|
|
matches: list[dict[str, Any]] = []
|
|
for entry in _all_entity_entries(view):
|
|
if getattr(entry, "unique_id", None) != unique_id:
|
|
continue
|
|
entity_id = getattr(entry, "entity_id", "") or ""
|
|
ent_domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
if domain and ent_domain != domain:
|
|
continue
|
|
if platform and getattr(entry, "platform", None) != platform:
|
|
continue
|
|
matches.append(
|
|
{
|
|
"entity_id": entity_id,
|
|
"unique_id": getattr(entry, "unique_id", None),
|
|
"platform": getattr(entry, "platform", None),
|
|
"domain": ent_domain,
|
|
"config_entry_id": getattr(entry, "config_entry_id", None),
|
|
"categories": _plainify(getattr(entry, "categories", None) or {}),
|
|
"disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
|
|
"hidden_by": _enum_value(getattr(entry, "hidden_by", None)),
|
|
}
|
|
)
|
|
return {"matches": matches}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/backup_prep
|
|
# =============================================================================
|
|
def _do_backup_prep(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the backup identity the server needs before a create.
|
|
|
|
``{agent_ids: [...], local_agent_id: <str|None>, default_password:
|
|
<str|None>}`` read from the backup integration's in-process manager
|
|
(``hass.data[DATA_MANAGER]``). ``local_agent_id`` uses the SAME preference the
|
|
server's ``_get_local_backup_agent_id`` does — an agent whose ``name`` is
|
|
``"local"``, preferring ``hassio.local`` (Supervised) over ``backup.local``
|
|
(Core). ``default_password`` is getattr-chained off
|
|
``manager.config.data.create_backup.password``.
|
|
|
|
A missing backup integration (``ImportError``) or an uninitialized manager
|
|
raises ``HomeAssistantError`` so the server's command-error path falls back to
|
|
its legacy WS reads — NOT a silent empty result the server could mistake for
|
|
"no agents". The password is sensitive, but the legacy ``backup/config/info``
|
|
already serves it to the same admin connection (parity, not new exposure).
|
|
|
|
STRUCTURAL core drift raises for the same reason, rather than degrading to a
|
|
well-formed authoritative negative: a non-Mapping ``manager.backup_agents``
|
|
(below) or a broken config→data→create_backup chain
|
|
(:func:`_backup_default_password`) would otherwise produce a "no agents" / "no
|
|
password" answer the server trusts and hard-fails on (or, for the password,
|
|
silently drops the restore safety backup) with no fallback. Value-level reads
|
|
(an id string, a genuinely-``None`` password on an intact chain) stay
|
|
getattr-guarded and pass through.
|
|
"""
|
|
try:
|
|
from homeassistant.components.backup import DATA_MANAGER
|
|
except ImportError as exc:
|
|
raise _backup_unavailable("backup integration is not available") from exc
|
|
manager = _hass_data_get(hass, DATA_MANAGER)
|
|
if manager is None:
|
|
raise _backup_unavailable("backup manager is not initialized")
|
|
agents = getattr(manager, "backup_agents", None)
|
|
if not isinstance(agents, Mapping):
|
|
raise _backup_unavailable("backup manager exposes no agent mapping")
|
|
return {
|
|
"agent_ids": [str(a) for a in agents],
|
|
"local_agent_id": _preferred_local_agent_id(agents),
|
|
"default_password": _backup_default_password(manager),
|
|
}
|
|
|
|
|
|
def _backup_unavailable(message: str) -> Exception:
|
|
"""Build a ``HomeAssistantError`` (imported function-locally — test-stubbable)."""
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
err: Exception = HomeAssistantError(message)
|
|
return err
|
|
|
|
|
|
def _hass_data_get(hass: HomeAssistant, key: Any) -> Any:
|
|
"""``hass.data.get(key)`` guarded against a non-mapping / drift."""
|
|
data = getattr(hass, "data", None)
|
|
if not isinstance(data, Mapping):
|
|
return None
|
|
try:
|
|
return data.get(key)
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _preferred_local_agent_id(agents: Any) -> str | None:
|
|
"""The local backup agent id, mirroring the server's hassio-over-core preference.
|
|
|
|
Collects agent ids whose agent ``name`` is exactly ``"local"``
|
|
(``hassio.local`` on Supervised, ``backup.local`` on Core both use that
|
|
name), prefers ``hassio.local`` then ``backup.local``, else the first local
|
|
agent, else ``None``.
|
|
"""
|
|
if not isinstance(agents, Mapping):
|
|
return None
|
|
local_ids: list[str] = [
|
|
str(agent_id)
|
|
for agent_id, agent in agents.items()
|
|
if getattr(agent, "name", None) == "local"
|
|
]
|
|
for preferred in ("hassio.local", "backup.local"):
|
|
if preferred in local_ids:
|
|
return preferred
|
|
return local_ids[0] if local_ids else None
|
|
|
|
|
|
def _backup_default_password(manager: Any) -> str | None:
|
|
"""``manager.config.data.create_backup.password`` (getattr-chained; ``str``/None).
|
|
|
|
A STRUCTURALLY broken chain (a missing ``config`` / ``data`` / ``create_backup``
|
|
link — core drift) RAISES ``HomeAssistantError`` so the server falls back to its
|
|
legacy ``backup/config/info`` read, rather than returning ``None`` the server
|
|
reads as "no default password configured" — which on restore silently drops the
|
|
safety backup while telling the user the password is unset. Only a genuine
|
|
``None`` ``password`` on an INTACT chain is the authoritative "not configured".
|
|
"""
|
|
config = getattr(manager, "config", None)
|
|
data = getattr(config, "data", None)
|
|
create_backup = getattr(data, "create_backup", None)
|
|
if config is None or data is None or create_backup is None:
|
|
raise _backup_unavailable("backup manager config chain is unavailable")
|
|
password = getattr(create_backup, "password", None)
|
|
return password if isinstance(password, str) else None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/registries
|
|
# =============================================================================
|
|
def _do_registries(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the requested registries as the FULL-FIELD ``config/<x>_registry/list`` shapes.
|
|
|
|
``{areas: [...], floors: [...], labels: [...], categories: {scope: [...]}}`` —
|
|
only the requested ``registries`` keys are present. Each row is byte-compatible
|
|
with the legacy WS list response the consumers parse (verified against core's
|
|
registry serializers): area = aliases / area_id / floor_id / icon / labels /
|
|
name / picture / created_at / modified_at, plus humidity_entity_id /
|
|
temperature_entity_id ONLY on core >= 2024.12 (emitted conditionally so an older
|
|
core's restore does not get None-valued keys it rejects — see
|
|
:func:`_area_row`); floor = aliases / created_at / floor_id / icon /
|
|
level / name / modified_at (floor rows carry NO ``labels`` — core omits it);
|
|
label = color / created_at / description / icon / label_id / name /
|
|
modified_at; category = category_id / created_at / icon / modified_at / name.
|
|
Timestamps are floats (``.timestamp()``), matching core.
|
|
|
|
``category`` is scoped: ``category_scopes`` names which scopes to list (a
|
|
``{scope: [rows]}`` map) and is REQUIRED when ``category`` is requested — a
|
|
scope-less category request raises ``HomeAssistantError`` (see
|
|
:func:`_registries_missing_category_scopes`) rather than silently serving
|
|
``{}``, a shape indistinguishable from "every requested scope is empty". The
|
|
category registry is imported function-locally (not needed at module top).
|
|
Pure in-memory reads over the resolved registries.
|
|
"""
|
|
requested = params.get("registries") or []
|
|
category_scopes = params.get("category_scopes") or []
|
|
if "category" in requested and not category_scopes:
|
|
raise _registries_missing_category_scopes()
|
|
|
|
view = _resolve_registries(hass)
|
|
result: dict[str, Any] = {}
|
|
# A requested registry whose accessor drifted (raised / renamed → ``None`` via
|
|
# ``_safe``) RAISES → server command-error fallback to the legacy WS list,
|
|
# rather than serving a well-formed empty list the capture pipeline would read
|
|
# as "this entity does not exist" and silently skip. A present-but-empty
|
|
# registry serves its empty list (correct).
|
|
if "area" in requested:
|
|
if view.area is None:
|
|
raise _substrate_unavailable("area registry")
|
|
result["areas"] = [_area_row(a) for a in _all_area_entries(view)]
|
|
if "floor" in requested:
|
|
if view.floor is None:
|
|
raise _substrate_unavailable("floor registry")
|
|
result["floors"] = [_floor_row(f) for f in _all_floor_entries(view)]
|
|
if "label" in requested:
|
|
if view.label is None:
|
|
raise _substrate_unavailable("label registry")
|
|
result["labels"] = [_label_row(x) for x in _all_label_entries(view)]
|
|
if "category" in requested:
|
|
result["categories"] = _category_rows(hass, category_scopes)
|
|
return result
|
|
|
|
|
|
def _registries_missing_category_scopes() -> Exception:
|
|
"""Build a ``HomeAssistantError`` for a scope-less ``category`` request.
|
|
|
|
Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable)
|
|
so a ``registries`` request naming ``category`` without a non-empty
|
|
``category_scopes`` raises instead of silently returning ``{}`` (a caller
|
|
could otherwise mistake that for "no categories in any scope").
|
|
"""
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
err: Exception = HomeAssistantError(
|
|
"ha_mcp_tools/registries: category_scopes is required when "
|
|
"'category' is requested"
|
|
)
|
|
return err
|
|
|
|
|
|
def _area_row(area: Any) -> dict[str, Any]:
|
|
"""One area as core's ``AreaEntry.json_fragment`` shape (id renamed to area_id)."""
|
|
row: dict[str, Any] = {
|
|
"aliases": sorted(str(a) for a in (getattr(area, "aliases", None) or [])),
|
|
"area_id": getattr(area, "id", None) or getattr(area, "area_id", None),
|
|
"floor_id": getattr(area, "floor_id", None),
|
|
"icon": getattr(area, "icon", None),
|
|
"labels": sorted(str(x) for x in (getattr(area, "labels", None) or [])),
|
|
"name": getattr(area, "name", None),
|
|
"picture": getattr(area, "picture", None),
|
|
"created_at": _timestamp(getattr(area, "created_at", None)),
|
|
"modified_at": _timestamp(getattr(area, "modified_at", None)),
|
|
}
|
|
# humidity_entity_id / temperature_entity_id were added to AreaEntry in core
|
|
# 2024.12. Emit each ONLY when the running core's AreaEntry actually has the
|
|
# attribute — a pre-2024.12 core would otherwise get a None-valued key injected
|
|
# here that the restore path's config/area_registry/update schema rejects
|
|
# ("extra keys not allowed"). ``hasattr`` (not ``getattr(..., None)``)
|
|
# distinguishes "core has the field, value is None" from "core has no field".
|
|
for attr in ("humidity_entity_id", "temperature_entity_id"):
|
|
if hasattr(area, attr):
|
|
row[attr] = getattr(area, attr, None)
|
|
return row
|
|
|
|
|
|
def _floor_row(floor: Any) -> dict[str, Any]:
|
|
"""One floor as core's ``config/floor_registry/list`` shape (NO ``labels`` field)."""
|
|
return {
|
|
"aliases": sorted(str(a) for a in (getattr(floor, "aliases", None) or [])),
|
|
"created_at": _timestamp(getattr(floor, "created_at", None)),
|
|
"floor_id": getattr(floor, "floor_id", None),
|
|
"icon": getattr(floor, "icon", None),
|
|
"level": getattr(floor, "level", None),
|
|
"name": getattr(floor, "name", None),
|
|
"modified_at": _timestamp(getattr(floor, "modified_at", None)),
|
|
}
|
|
|
|
|
|
def _label_row(label: Any) -> dict[str, Any]:
|
|
"""One label as core's ``config/label_registry/list`` shape."""
|
|
return {
|
|
"color": getattr(label, "color", None),
|
|
"created_at": _timestamp(getattr(label, "created_at", None)),
|
|
"description": getattr(label, "description", None),
|
|
"icon": getattr(label, "icon", None),
|
|
"label_id": getattr(label, "label_id", None),
|
|
"name": getattr(label, "name", None),
|
|
"modified_at": _timestamp(getattr(label, "modified_at", None)),
|
|
}
|
|
|
|
|
|
def _category_rows(hass: HomeAssistant, scopes: list[str]) -> dict[str, Any]:
|
|
"""``{scope: [category rows]}`` for each requested scope (categories are scoped).
|
|
|
|
A drifted / absent category registry (``None`` — ``cr.async_get`` raised /
|
|
renamed, or the module is missing on an old core) RAISES rather than serving
|
|
``{scope: []}`` for every scope, so the server falls back to the legacy
|
|
``config/category_registry/list`` instead of trusting an empty map.
|
|
"""
|
|
registry = _category_registry(hass)
|
|
if registry is None:
|
|
raise _substrate_unavailable("category registry")
|
|
return {
|
|
scope: [_category_row(c) for c in _list_categories(registry, scope)]
|
|
for scope in scopes
|
|
}
|
|
|
|
|
|
def _category_row(category: Any) -> dict[str, Any]:
|
|
"""One category as core's ``config/category_registry/list`` shape."""
|
|
return {
|
|
"category_id": getattr(category, "category_id", None),
|
|
"created_at": _timestamp(getattr(category, "created_at", None)),
|
|
"icon": getattr(category, "icon", None),
|
|
"modified_at": _timestamp(getattr(category, "modified_at", None)),
|
|
"name": getattr(category, "name", None),
|
|
}
|
|
|
|
|
|
def _category_registry(hass: HomeAssistant) -> Any:
|
|
"""The category registry (imported function-locally). Test seam. ``None`` on drift."""
|
|
try:
|
|
from homeassistant.helpers import category_registry as cr
|
|
except ImportError: # pragma: no cover - defensive; core drift
|
|
return None
|
|
return _safe(cr.async_get, hass)
|
|
|
|
|
|
def _list_categories(registry: Any, scope: str) -> list[Any]:
|
|
"""``registry.async_list_categories(scope=...)`` guarded (scope is keyword-only)."""
|
|
if registry is None:
|
|
return []
|
|
lister = getattr(registry, "async_list_categories", None)
|
|
if not callable(lister):
|
|
return []
|
|
try:
|
|
return list(lister(scope=scope))
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _all_floor_entries(view: _RegistryView) -> list[Any]:
|
|
"""All floor-registry entries via ``async_list_floors()`` or the ``floors`` mapping."""
|
|
reg = view.floor
|
|
if reg is None:
|
|
return []
|
|
listed = _call_no_arg(reg, "async_list_floors")
|
|
if listed is not None:
|
|
try:
|
|
return list(listed)
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
return _mapping_values(getattr(reg, "floors", None))
|
|
|
|
|
|
def _all_label_entries(view: _RegistryView) -> list[Any]:
|
|
"""All label-registry entries via ``async_list_labels()`` or the ``labels`` mapping."""
|
|
reg = view.label
|
|
if reg is None:
|
|
return []
|
|
listed = _call_no_arg(reg, "async_list_labels")
|
|
if listed is not None:
|
|
try:
|
|
return list(listed)
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
return _mapping_values(getattr(reg, "labels", None))
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/dashboards
|
|
# =============================================================================
|
|
# LovelaceConfig.mode returns these wire strings (MODE_STORAGE / MODE_YAML in
|
|
# core's lovelace const). Compared as strings so a MagicMock-stubbed core in the
|
|
# unit suite (no real constants) still exercises the branches.
|
|
_LOVELACE_MODE_STORAGE = "storage"
|
|
_LOVELACE_MODE_YAML = "yaml"
|
|
|
|
# Keys of a stored dashboard collection item (core's STORAGE_DASHBOARD_*_FIELDS),
|
|
# echoed by ``lovelace/dashboards/list`` — the row shape ``list`` mode mirrors.
|
|
_DASHBOARD_ROW_KEYS = (
|
|
"id",
|
|
"url_path",
|
|
"title",
|
|
"icon",
|
|
"show_in_sidebar",
|
|
"require_admin",
|
|
)
|
|
|
|
# Cap on ``search``-mode matches per call so one WS frame stays bounded.
|
|
_DASHBOARD_MATCH_CAP = 200
|
|
|
|
# Structural keys walked as containers (not scored as leaf strings) in a card.
|
|
_DASHBOARD_STRUCTURAL_KEYS = frozenset({"cards", "sections"})
|
|
|
|
|
|
def _do_dashboards(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
prepped: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return Lovelace dashboards read in-process (``list`` / ``get`` / ``search``).
|
|
|
|
Pure assembler over the plain dicts :func:`_dashboards_prep` loads off the
|
|
event loop — every Store load (``async_load``) happens in the prep, so this
|
|
function only shapes / walks already-materialized config. ``available`` is
|
|
``False`` when the lovelace integration is not set up (no ``LOVELACE_DATA``);
|
|
the server falls back to its legacy ``lovelace/*`` path in that case.
|
|
|
|
YAML-mode dashboard bodies are NEVER emitted (``get`` returns a ``yaml_excluded``
|
|
status; ``search`` skips them) — their config may carry resolved ``!secret``
|
|
plaintext, so body emission for YAML belongs to a future file-based tool.
|
|
"""
|
|
mode = params.get("mode", "list")
|
|
prepped = prepped or {}
|
|
if not prepped.get("available"):
|
|
result: dict[str, Any] = {"mode": mode, "available": False}
|
|
if mode == "list":
|
|
result["dashboards"] = []
|
|
elif mode == "search":
|
|
result["matches"] = []
|
|
result["truncated"] = False
|
|
return result
|
|
|
|
if mode == "get":
|
|
return {
|
|
"mode": "get",
|
|
"available": True,
|
|
"status": prepped.get("status"),
|
|
"url_path": prepped.get("url_path"),
|
|
"config": prepped.get("config"),
|
|
}
|
|
if mode == "search":
|
|
query_lower = (params.get("query") or "").strip().lower()
|
|
matches, truncated = _search_dashboard_docs(
|
|
prepped.get("docs") or [], query_lower
|
|
)
|
|
return {
|
|
"mode": "search",
|
|
"available": True,
|
|
"matches": matches,
|
|
"truncated": truncated,
|
|
}
|
|
return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []}
|
|
|
|
|
|
async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
|
|
"""Async pre-step for ``dashboards``: do ALL the Store loading off the loop.
|
|
|
|
Reaching ``hass.data[LOVELACE_DATA].dashboards`` is a pure in-memory read, but
|
|
loading a dashboard's config (``LovelaceConfig.async_load``) awaits a Store
|
|
read, so every mode's loading lives here and :func:`_do_dashboards` gets plain
|
|
dicts. Returns ``{"prepped": {...}}`` with ``available=False`` when lovelace is
|
|
not set up (the server falls back to legacy).
|
|
"""
|
|
mode = msg.get("mode", "list")
|
|
dashboards_map = _lovelace_dashboards_map(hass)
|
|
if dashboards_map is None:
|
|
return {"prepped": {"available": False}}
|
|
|
|
prepped: dict[str, Any] = {"available": True}
|
|
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)
|
|
else:
|
|
prepped["rows"] = _dashboard_list_rows(dashboards_map)
|
|
return {"prepped": prepped}
|
|
|
|
|
|
def _lovelace_dashboards_map(hass: HomeAssistant) -> Mapping[Any, Any] | None:
|
|
"""The ``{url_path|None: LovelaceConfig}`` map, or ``None`` if lovelace is absent.
|
|
|
|
Reads ``hass.data[LOVELACE_DATA].dashboards`` via a function-local import of
|
|
core's key (older cores keyed ``hass.data["lovelace"]``, so both are tried),
|
|
guarded so a missing key / core drift degrades to ``None`` (the server keeps
|
|
its legacy path) rather than raising.
|
|
"""
|
|
try:
|
|
from homeassistant.components.lovelace import LOVELACE_DATA
|
|
|
|
key: Any = LOVELACE_DATA
|
|
except Exception: # pragma: no cover - defensive; core drift / older core
|
|
key = "lovelace"
|
|
data = getattr(hass, "data", None)
|
|
if not isinstance(data, Mapping):
|
|
return None
|
|
container = data.get(key)
|
|
if container is None and key != "lovelace":
|
|
container = data.get("lovelace")
|
|
if container is None:
|
|
return None
|
|
dashboards = getattr(container, "dashboards", None)
|
|
if dashboards is None and isinstance(container, Mapping):
|
|
dashboards = container.get("dashboards")
|
|
return dashboards if isinstance(dashboards, Mapping) else None
|
|
|
|
|
|
def _dashboard_list_rows(dashboards_map: Mapping[Any, Any]) -> list[dict[str, Any]]:
|
|
"""One metadata row per non-default dashboard, tagged with ``mode``.
|
|
|
|
Mirrors the ``lovelace/dashboards/list`` row shape (the stored collection item
|
|
for storage dashboards, read from ``LovelaceConfig.config``) plus an additive
|
|
``mode`` so the server can exclude YAML dashboards. The default dashboard
|
|
(``url_path`` key ``None``) is omitted — the legacy list omits it too and the
|
|
server special-cases the built-in dashboard as always-existing; ``get`` mode
|
|
resolves ``None`` to that default instead.
|
|
"""
|
|
rows: list[dict[str, Any]] = []
|
|
for url_path, dash in dashboards_map.items():
|
|
if url_path is None:
|
|
continue
|
|
config = getattr(dash, "config", None)
|
|
meta = config if isinstance(config, Mapping) else {}
|
|
row: dict[str, Any] = {key: meta.get(key) for key in _DASHBOARD_ROW_KEYS}
|
|
# The dict key is the authoritative url_path (the metadata may lack it).
|
|
row["url_path"] = url_path
|
|
row["mode"] = _dashboard_mode(dash)
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
async def _dashboard_get_config(
|
|
dashboards_map: Mapping[Any, Any], url_path: Any
|
|
) -> dict[str, Any]:
|
|
"""Load one dashboard's config body; ``status`` names the outcome.
|
|
|
|
``url_path`` ``None``/absent resolves to the default dashboard. A YAML-mode
|
|
dashboard returns ``status="yaml_excluded"`` with no body (its config may
|
|
carry resolved ``!secret`` plaintext — storage-only emission). A missing
|
|
dashboard or a load error returns ``status="not_found"``. Storage freshness is
|
|
safe: ``LovelaceStorage.async_save`` mutates the in-memory object
|
|
synchronously, so this read never lags a save (audit-verified).
|
|
"""
|
|
dash = dashboards_map.get(url_path)
|
|
resolved = getattr(dash, "url_path", None) if dash is not None else url_path
|
|
if dash is None:
|
|
return {"status": "not_found", "url_path": url_path, "config": None}
|
|
if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
|
|
return {"status": "yaml_excluded", "url_path": resolved, "config": None}
|
|
loader = getattr(dash, "async_load", None)
|
|
if not callable(loader):
|
|
return {"status": "not_found", "url_path": resolved, "config": None}
|
|
try:
|
|
config = await loader(False)
|
|
except Exception: # any load failure degrades to not_found (fail-soft)
|
|
return {"status": "not_found", "url_path": resolved, "config": None}
|
|
if not isinstance(config, dict):
|
|
return {"status": "not_found", "url_path": resolved, "config": None}
|
|
return {"status": "ok", "url_path": resolved, "config": _plainify(config)}
|
|
|
|
|
|
async def _dashboard_search_docs(
|
|
dashboards_map: Mapping[Any, Any],
|
|
) -> list[dict[str, Any]]:
|
|
"""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.
|
|
"""
|
|
docs: list[dict[str, Any]] = []
|
|
for url_path, dash in dashboards_map.items():
|
|
if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE:
|
|
continue
|
|
loader = getattr(dash, "async_load", None)
|
|
if not callable(loader):
|
|
continue
|
|
try:
|
|
config = await loader(False)
|
|
except Exception: # skip an unreadable dashboard, keep going (fail-soft)
|
|
continue
|
|
if not isinstance(config, dict):
|
|
continue
|
|
title = config.get("title")
|
|
docs.append(
|
|
{
|
|
"url_path": url_path,
|
|
"title": str(title) if title is not None else None,
|
|
"config": config,
|
|
}
|
|
)
|
|
return docs
|
|
|
|
|
|
def _dashboard_mode(dash: Any) -> str | None:
|
|
"""A dashboard's ``mode`` (``storage``/``yaml``), guarded against core drift."""
|
|
mode = getattr(dash, "mode", None)
|
|
return str(mode) if isinstance(mode, str) else None
|
|
|
|
|
|
def _search_dashboard_docs(
|
|
docs: list[dict[str, Any]], query_lower: str
|
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
"""Walk each dashboard config for ``query_lower``; return ``(matches, truncated)``.
|
|
|
|
An empty query matches nothing (a bare substring would match every string).
|
|
Matches are capped at :data:`_DASHBOARD_MATCH_CAP` with a ``truncated`` flag.
|
|
"""
|
|
if not query_lower:
|
|
return [], False
|
|
matches: list[dict[str, Any]] = []
|
|
for doc in docs:
|
|
_collect_dashboard_matches(doc, query_lower, matches)
|
|
truncated = len(matches) > _DASHBOARD_MATCH_CAP
|
|
return matches[:_DASHBOARD_MATCH_CAP], truncated
|
|
|
|
|
|
def _collect_dashboard_matches(
|
|
doc: dict[str, Any], query_lower: str, matches: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Append every ``query_lower`` hit in one dashboard config to ``matches``.
|
|
|
|
Walks each view's card containers (``cards`` + sections-view ``sections.cards``,
|
|
nested cards recursed), plus the two view-level containers the card walk never
|
|
visits: ``badges`` and a sections-view ``header.card``. This matches what the
|
|
single-dashboard (MODE 2) search covers, so a query answered "no match" here is
|
|
a real absence, not a blind spot for entities referenced only as a badge or in a
|
|
header card.
|
|
"""
|
|
config = doc.get("config")
|
|
if not isinstance(config, dict):
|
|
return
|
|
views = config.get("views")
|
|
if not isinstance(views, list):
|
|
return
|
|
url_path = doc.get("url_path")
|
|
dash_title = doc.get("title")
|
|
for view_index, view in enumerate(views):
|
|
if not isinstance(view, dict):
|
|
continue
|
|
view_title = view.get("title")
|
|
for cards, base_path in _view_card_containers(view, view_index):
|
|
_collect_card_matches(
|
|
cards,
|
|
base_path,
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
query_lower,
|
|
matches,
|
|
)
|
|
_collect_badge_matches(
|
|
view, view_index, url_path, dash_title, view_title, query_lower, matches
|
|
)
|
|
_collect_header_card_matches(
|
|
view, view_index, url_path, dash_title, view_title, query_lower, matches
|
|
)
|
|
|
|
|
|
def _view_card_containers(
|
|
view: dict[str, Any], view_index: int
|
|
) -> list[tuple[Any, str]]:
|
|
"""The card lists in a view: top-level ``cards`` plus each section's ``cards``."""
|
|
containers: list[tuple[Any, str]] = []
|
|
if isinstance(view.get("cards"), list):
|
|
containers.append((view["cards"], f"views[{view_index}].cards"))
|
|
sections = view.get("sections")
|
|
if isinstance(sections, list):
|
|
for si, section in enumerate(sections):
|
|
if isinstance(section, dict) and isinstance(section.get("cards"), list):
|
|
containers.append(
|
|
(section["cards"], f"views[{view_index}].sections[{si}].cards")
|
|
)
|
|
return containers
|
|
|
|
|
|
def _dashboard_match(
|
|
url_path: Any,
|
|
dash_title: Any,
|
|
view_index: int,
|
|
view_title: Any,
|
|
card_path: str,
|
|
card_type: Any,
|
|
matched_field: str,
|
|
matched_value: str,
|
|
) -> dict[str, Any]:
|
|
"""One MODE 4 cross-dashboard search match record (shared, fixed shape).
|
|
|
|
Every match site — cards, badges, header cards — builds its record here so the
|
|
wire shape stays identical (the server-side legacy walk mirrors it for parity).
|
|
"""
|
|
return {
|
|
"url_path": url_path,
|
|
"title": dash_title,
|
|
"view_index": view_index,
|
|
"view_title": view_title,
|
|
"card_path": card_path,
|
|
"card_type": card_type,
|
|
"matched_field": matched_field,
|
|
"matched_value": matched_value,
|
|
}
|
|
|
|
|
|
def _collect_card_matches(
|
|
cards: Any,
|
|
base_path: str,
|
|
url_path: Any,
|
|
dash_title: Any,
|
|
view_index: int,
|
|
view_title: Any,
|
|
query_lower: str,
|
|
matches: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Recurse a card list, recording one match per string leaf containing the query.
|
|
|
|
``matched_field`` is the leaf's immediate key (``entity`` / ``entities`` /
|
|
``camera_image`` / any plain-string field); nested ``cards`` are walked as
|
|
their own cards (their strings are attributed to the nested card, not the
|
|
parent), so ``card_path`` / ``card_type`` always name the card the string
|
|
actually lives on.
|
|
"""
|
|
if not isinstance(cards, list):
|
|
return
|
|
for card_index, card in enumerate(cards):
|
|
if not isinstance(card, dict):
|
|
continue
|
|
_collect_one_card_matches(
|
|
card,
|
|
f"{base_path}[{card_index}]",
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
query_lower,
|
|
matches,
|
|
)
|
|
|
|
|
|
def _collect_one_card_matches(
|
|
card: dict[str, Any],
|
|
card_path: str,
|
|
url_path: Any,
|
|
dash_title: Any,
|
|
view_index: int,
|
|
view_title: Any,
|
|
query_lower: str,
|
|
matches: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Record matches for a SINGLE card at ``card_path`` and recurse its nested cards.
|
|
|
|
Shared by :func:`_collect_card_matches` (list-indexed cards) and
|
|
:func:`_collect_header_card_matches` (a header card is a single card, not
|
|
list-indexed).
|
|
"""
|
|
card_type = card.get("type")
|
|
for field, value in _card_string_leaves(card):
|
|
if query_lower in value.lower():
|
|
matches.append(
|
|
_dashboard_match(
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
card_path,
|
|
card_type,
|
|
field,
|
|
value,
|
|
)
|
|
)
|
|
nested = card.get("cards")
|
|
if isinstance(nested, list):
|
|
_collect_card_matches(
|
|
nested,
|
|
f"{card_path}.cards",
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
query_lower,
|
|
matches,
|
|
)
|
|
|
|
|
|
def _collect_badge_matches(
|
|
view: dict[str, Any],
|
|
view_index: int,
|
|
url_path: Any,
|
|
dash_title: Any,
|
|
view_title: Any,
|
|
query_lower: str,
|
|
matches: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Record query hits in a view's ``badges`` — entity refs the card walk misses.
|
|
|
|
View-level badges are entity references by construction: a bare string
|
|
(``sensor.x``) or a dict (``{type: entity, entity: sensor.x}``). A bare-string
|
|
badge is recorded as a ``badges`` leaf; a dict badge's string leaves are walked
|
|
like a card's. Mirrors the single-dashboard (MODE 2) badge coverage.
|
|
"""
|
|
badges = view.get("badges")
|
|
if not isinstance(badges, list):
|
|
return
|
|
for badge_index, badge in enumerate(badges):
|
|
badge_path = f"views[{view_index}].badges[{badge_index}]"
|
|
if isinstance(badge, str):
|
|
if badge and query_lower in badge.lower():
|
|
matches.append(
|
|
_dashboard_match(
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
badge_path,
|
|
"badge",
|
|
"badges",
|
|
badge,
|
|
)
|
|
)
|
|
elif isinstance(badge, dict):
|
|
badge_type = badge.get("type") or "badge"
|
|
for field, value in _card_string_leaves(badge):
|
|
if query_lower in value.lower():
|
|
matches.append(
|
|
_dashboard_match(
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
badge_path,
|
|
badge_type,
|
|
field,
|
|
value,
|
|
)
|
|
)
|
|
|
|
|
|
def _collect_header_card_matches(
|
|
view: dict[str, Any],
|
|
view_index: int,
|
|
url_path: Any,
|
|
dash_title: Any,
|
|
view_title: Any,
|
|
query_lower: str,
|
|
matches: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Record query hits in a sections-view header card (``views[n].header.card``).
|
|
|
|
The header accepts a card (typically Markdown) that can carry entity refs; the
|
|
card walk never visits it. Mirrors the single-dashboard (MODE 2) header-card
|
|
coverage.
|
|
"""
|
|
header = view.get("header")
|
|
if not isinstance(header, dict):
|
|
return
|
|
header_card = header.get("card")
|
|
if not isinstance(header_card, dict):
|
|
return
|
|
_collect_one_card_matches(
|
|
header_card,
|
|
f"views[{view_index}].header.card",
|
|
url_path,
|
|
dash_title,
|
|
view_index,
|
|
view_title,
|
|
query_lower,
|
|
matches,
|
|
)
|
|
|
|
|
|
def _card_string_leaves(card: dict[str, Any]) -> list[tuple[str, str]]:
|
|
"""``(immediate_key, string)`` for every string leaf of a card.
|
|
|
|
Descends into nested dicts/lists but NOT the structural ``cards``/``sections``
|
|
keys (those are walked as their own cards). The key attributed to a leaf is
|
|
the nearest dict key, so ``entities: [{entity: light.a}]`` yields
|
|
``("entity", "light.a")`` and ``entities: [light.a]`` yields
|
|
``("entities", "light.a")`` — matching the brief's field taxonomy.
|
|
"""
|
|
out: list[tuple[str, str]] = []
|
|
_walk_card_leaves(card, "", out)
|
|
return out
|
|
|
|
|
|
def _walk_card_leaves(value: Any, key: str, out: list[tuple[str, str]]) -> None:
|
|
"""Recursive worker for :func:`_card_string_leaves` (module-level for clarity).
|
|
|
|
Descends dicts/lists collecting ``(nearest_key, string)`` leaves, skipping the
|
|
structural ``cards``/``sections`` keys (walked as their own cards). A top-level
|
|
card dict enters the ``dict`` branch, so its own keys attribute their leaves.
|
|
"""
|
|
if isinstance(value, str):
|
|
if value:
|
|
out.append((key, value))
|
|
elif isinstance(value, dict):
|
|
for k, v in value.items():
|
|
if k not in _DASHBOARD_STRUCTURAL_KEYS:
|
|
_walk_card_leaves(v, str(k), out)
|
|
elif isinstance(value, (list, tuple)):
|
|
for item in value:
|
|
_walk_card_leaves(item, key, out)
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/services_list
|
|
# =============================================================================
|
|
def _do_services_list(
|
|
hass: HomeAssistant,
|
|
params: dict[str, Any],
|
|
*,
|
|
descriptions: Mapping[str, Any] | None = None,
|
|
translations: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Reshape the service catalog to the REST ``/api/services`` list, ``domain``-filtered.
|
|
|
|
``descriptions`` (``async_get_all_descriptions`` — ``{domain: {service:
|
|
desc}}``) and ``translations`` (``async_get_translations`` for the ``services``
|
|
category) are loaded off the loop by :func:`_services_list_prep`.
|
|
|
|
``domain`` is the only filter — an exact match, same semantics both paths.
|
|
There is deliberately NO ``query`` coarse-filter: the server's exact filter
|
|
matches against the CONCATENATION ``f"{domain}.{service} {name} {description}"``
|
|
(with a title-cased ``service.replace("_"," ").title()`` name fallback), and a
|
|
cheap per-field component pass is NOT a superset of that — it misses queries
|
|
spanning the ``domain.service`` / name / description boundaries and the
|
|
title-cased fallback, so forwarding ``query`` would silently drop matching
|
|
services. No consumer forwards ``query`` (the server re-runs its exact filter +
|
|
pagination over the full payload), so the component simply doesn't accept it.
|
|
Translations are scoped to the kept domains' key prefixes.
|
|
"""
|
|
descriptions = descriptions or {}
|
|
translations = translations or {}
|
|
domain_filter = params.get("domain")
|
|
|
|
services: list[dict[str, Any]] = []
|
|
kept_domains: set[str] = set()
|
|
for domain, domain_services in descriptions.items():
|
|
if domain_filter and domain != domain_filter:
|
|
continue
|
|
services_map = (
|
|
dict(domain_services) if isinstance(domain_services, Mapping) else {}
|
|
)
|
|
services.append({"domain": domain, "services": services_map})
|
|
kept_domains.add(str(domain))
|
|
|
|
return {
|
|
"services": services,
|
|
"translations": _filter_service_translations(translations, kept_domains),
|
|
}
|
|
|
|
|
|
async def _services_list_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Async pre-step for ``services_list``: load descriptions + translations off-loop.
|
|
|
|
Both ``async_get_all_descriptions`` and ``async_get_translations`` await
|
|
(translation loads touch the filesystem / integration setup), so they run in
|
|
the prep via the seam wrappers below and :func:`_do_services_list` stays a pure
|
|
reshape/filter.
|
|
"""
|
|
language = msg.get("language") or "en"
|
|
descriptions = await _fetch_service_descriptions(hass)
|
|
translations = await _fetch_service_translations(hass, language)
|
|
return {"descriptions": descriptions, "translations": translations}
|
|
|
|
|
|
async def _fetch_service_descriptions(hass: HomeAssistant) -> Mapping[str, Any]:
|
|
"""core ``async_get_all_descriptions(hass)``; function-local import test seam.
|
|
|
|
A non-``Mapping`` return is core drift, NOT an empty catalog: RAISE
|
|
``HomeAssistantError`` (→ server command-error fallback to the legacy REST
|
|
``/api/services`` read) rather than serving a well-formed empty catalog the
|
|
server would trust as authoritative. A raising ``async_get_all_descriptions``
|
|
already propagates the same way.
|
|
"""
|
|
from homeassistant.helpers.service import async_get_all_descriptions
|
|
|
|
result = await async_get_all_descriptions(hass)
|
|
if not isinstance(result, Mapping):
|
|
raise _substrate_unavailable("service descriptions")
|
|
return result
|
|
|
|
|
|
async def _fetch_service_translations(
|
|
hass: HomeAssistant, language: str
|
|
) -> Mapping[str, Any]:
|
|
"""core ``async_get_translations(hass, language, "services")``; test seam.
|
|
|
|
Fails soft (empty map) so a translation-load failure degrades to the untranslated
|
|
service list rather than failing the whole command.
|
|
"""
|
|
from homeassistant.helpers.translation import async_get_translations
|
|
|
|
try:
|
|
result = await async_get_translations(hass, language, "services")
|
|
except Exception: # translations are additive; degrade to none on any error
|
|
_LOGGER.warning(
|
|
"services_list: could not load service translations; "
|
|
"continuing without them",
|
|
exc_info=True,
|
|
)
|
|
return {}
|
|
return result if isinstance(result, Mapping) else {}
|
|
|
|
|
|
def _filter_service_translations(
|
|
translations: Mapping[str, Any], kept_domains: set[str]
|
|
) -> dict[str, Any]:
|
|
"""Keep only translation keys whose domain segment is in ``kept_domains``.
|
|
|
|
Backend ``services``-category keys are ``component.<domain>.services.<service>.…``
|
|
so the domain is the second dotted segment.
|
|
"""
|
|
return {
|
|
key: value
|
|
for key, value in translations.items()
|
|
if _translation_key_domain(key) in kept_domains
|
|
}
|
|
|
|
|
|
def _translation_key_domain(key: Any) -> str | None:
|
|
"""The ``<domain>`` segment of a ``component.<domain>.services.…`` translation key."""
|
|
if not isinstance(key, str):
|
|
return None
|
|
parts = key.split(".")
|
|
return parts[1] if len(parts) > 1 else None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/reference_data
|
|
# =============================================================================
|
|
def _do_reference_data(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the service index + entity-id universe the reference validator reads.
|
|
|
|
``services`` is the REST ``/api/services`` list shape ``build_service_index``
|
|
consumes — reusing :func:`_overview_services`, whose per-service bodies are
|
|
EMPTY dicts (the index only reads service-name keys). ``entity_ids`` is every
|
|
``hass.states.async_all()`` id (the ``build_entity_set`` universe). Pure,
|
|
synchronous, no prep — both are in-memory reads.
|
|
|
|
A drifted service registry (``hass.services.async_services()`` raised / renamed
|
|
→ non-``Mapping``) or state machine (``hass.states.async_all`` absent / renamed)
|
|
RAISES ``HomeAssistantError`` (→ server command-error fallback to the legacy
|
|
REST ``get_services()`` / ``get_states()`` pair) rather than returning empty
|
|
catalogs — which would make EVERY reference emit a false "not found" warning,
|
|
where the legacy failure mode is skip-validation. A genuinely-empty (but
|
|
present) substrate still returns its empty result.
|
|
"""
|
|
include_states = params.get("include_states", True)
|
|
if not isinstance(
|
|
_call_no_arg(getattr(hass, "services", None), "async_services"), Mapping
|
|
):
|
|
raise _substrate_unavailable("service registry")
|
|
entity_ids: list[str] = []
|
|
if include_states:
|
|
states_obj = getattr(hass, "states", None)
|
|
if not callable(getattr(states_obj, "async_all", None)):
|
|
raise _substrate_unavailable("state machine")
|
|
for state in _iter_states(hass):
|
|
entity_id = getattr(state, "entity_id", None)
|
|
if entity_id:
|
|
entity_ids.append(entity_id)
|
|
return {"services": _overview_services(hass), "entity_ids": entity_ids}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/search — search_visibility (opt-in hidden-set exclusion)
|
|
# =============================================================================
|
|
# HA's EntityCategory enum values (homeassistant.const). Mirrors the server
|
|
# resolver's KNOWN_ENTITY_CATEGORIES so an unknown exclude_category hides nothing.
|
|
_KNOWN_ENTITY_CATEGORIES = frozenset({"config", "diagnostic"})
|
|
|
|
# Degradation warnings surfaced when a visibility dimension fails open (mirrors the
|
|
# server resolver so the two paths emit byte-identical text — pinned by the
|
|
# cross-seam warnings contract test). The component cannot import the server
|
|
# package (it ships over HACS independently), so these strings are duplicated here
|
|
# and the contract test asserts they stay equal to ``visibility.resolver``'s.
|
|
_ASSIST_UNAVAILABLE_WARNING = (
|
|
"Entity visibility filter is enabled with respect_assist_exposure but the "
|
|
"Assist exposure data was unavailable; that dimension is skipped for this "
|
|
"request (other dimensions still apply)."
|
|
)
|
|
_ALLOWLIST_REGISTRY_EMPTY_WARNING = (
|
|
"Entity visibility filter is enabled with an area/label allowlist but the "
|
|
"entity registry returned no entries; those allow dimensions are skipped for "
|
|
"this request (an allow_entity_ids list, if set, still applies) so the filter "
|
|
"does not blank every entity."
|
|
)
|
|
|
|
|
|
def _unknown_categories_warning(unknown_categories: set[str]) -> str:
|
|
"""The resolver's unknown-``exclude_categories`` warning text (byte-identical)."""
|
|
return (
|
|
"Entity visibility: ignoring unknown exclude_categories "
|
|
f"{sorted(unknown_categories)} (valid: config, diagnostic)."
|
|
)
|
|
|
|
|
|
def _visibility_hidden_set(
|
|
view: _RegistryView,
|
|
states: Any,
|
|
visibility: Mapping[str, Any],
|
|
should_expose_fn: Any,
|
|
*,
|
|
assist_available: bool = True,
|
|
) -> set[str]:
|
|
"""Compute the opt-in hidden entity_id set, mirroring the server's resolver.
|
|
|
|
A pure replication of ``visibility.resolver.hidden_entity_ids`` over the live
|
|
``_RegistryView`` + ``states`` (rather than the WS ``{success, result}``
|
|
payloads the server passes): a conjunction of independent hide dimensions —
|
|
the deny list, the category / hidden / area / label excludes (area/labels are
|
|
device-inherited), the allow-list restrict mode, and the Assist dimension. The
|
|
Assist dimension delegates to the injectable ``should_expose_fn(entity_id) ->
|
|
bool`` (:func:`_assist_should_expose` in production — a READ-ONLY reconstruction
|
|
of core's ``async_should_expose`` from the explicit exposure map + expose_new +
|
|
domain defaults, matching the resolver; a fake in tests), the injection point the
|
|
cross-seam contract test aligns with the server's own Assist result. The
|
|
production seam is read-only by design: core's own ``async_should_expose`` writes
|
|
computed defaults back, which this fast path must not do (see
|
|
:func:`_assist_should_expose`).
|
|
|
|
``should_expose_fn`` is consulted only when ``respect_assist_exposure`` is set
|
|
AND ``assist_available`` is True. When the config requests the Assist dimension
|
|
but the exposure machinery is unavailable (``assist_available=False``), the
|
|
dimension is SKIPPED — hiding nothing by Assist — mirroring the resolver's
|
|
"skip the dimension when its data is unavailable" fail-open (the paired
|
|
degradation warning is surfaced by :func:`_visibility_warnings`). Kept a
|
|
standalone pure function so it is unit-testable without the full search
|
|
pipeline.
|
|
"""
|
|
exclude_categories = set(visibility.get("exclude_categories") or [])
|
|
categories = exclude_categories & _KNOWN_ENTITY_CATEGORIES
|
|
exclude_hidden = bool(visibility.get("exclude_hidden"))
|
|
denied = set(visibility.get("deny_entity_ids") or [])
|
|
exclude_areas = set(visibility.get("exclude_areas") or [])
|
|
exclude_labels = set(visibility.get("exclude_labels") or [])
|
|
allow_entity_ids = set(visibility.get("allow_entity_ids") or [])
|
|
allow_areas = set(visibility.get("allow_areas") or [])
|
|
allow_labels = set(visibility.get("allow_labels") or [])
|
|
respect_assist = bool(visibility.get("respect_assist_exposure"))
|
|
# ``assist_active`` gates the per-entity Assist should_expose sub-check (skipped
|
|
# when the config asks for Assist but its data is unavailable). The allow/assist
|
|
# loop below is guarded by ``allow_active or respect_assist`` — a structural
|
|
# mirror of the resolver's guard, not a functional requirement: when Assist is
|
|
# requested-but-unavailable and no allowlist is set, the loop runs but hides
|
|
# nothing (both sub-checks are inert). Kept identical so the two paths match.
|
|
assist_active = respect_assist and assist_available
|
|
allow_active = bool(allow_areas or allow_labels or allow_entity_ids)
|
|
|
|
registry_by_id = _registry_index_by_id(view)
|
|
# states-only entity universe (YAML/template entities absent from the registry
|
|
# that the allow / Assist dimensions must still be able to hide).
|
|
state_ids = {
|
|
eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid
|
|
}
|
|
|
|
# Fail-open guard (mirrors the resolver): an area/label allowlist needs registry
|
|
# data to match; if the registry is empty but there are states-only candidates,
|
|
# restrict mode would blank everything, so drop those allow dimensions.
|
|
if (allow_areas or allow_labels) and not registry_by_id and state_ids:
|
|
allow_areas = set()
|
|
allow_labels = set()
|
|
allow_active = bool(allow_entity_ids)
|
|
|
|
hidden: set[str] = set(denied)
|
|
_apply_visibility_excludes(
|
|
view,
|
|
registry_by_id,
|
|
denied,
|
|
categories,
|
|
exclude_hidden,
|
|
exclude_areas,
|
|
exclude_labels,
|
|
hidden,
|
|
)
|
|
if allow_active or respect_assist:
|
|
_apply_visibility_allow_assist(
|
|
view,
|
|
registry_by_id,
|
|
state_ids,
|
|
allow_active,
|
|
allow_entity_ids,
|
|
allow_areas,
|
|
allow_labels,
|
|
assist_active,
|
|
should_expose_fn,
|
|
hidden,
|
|
)
|
|
return hidden
|
|
|
|
|
|
def _visibility_warnings(
|
|
view: _RegistryView,
|
|
states: Any,
|
|
visibility: Mapping[str, Any],
|
|
*,
|
|
assist_available: bool = True,
|
|
) -> list[str]:
|
|
"""Degradation warnings for a visibility computation, mirroring the resolver.
|
|
|
|
Companion to :func:`_visibility_hidden_set`: the hidden-set function silently
|
|
fails open on a degraded dimension (an unknown ``exclude_category``, an
|
|
area/label allowlist against an empty registry, or a requested-but-unavailable
|
|
Assist dimension), so this returns the operator-facing warnings the server's
|
|
``load_hidden_set`` would emit for the same config. The ha_search consumer
|
|
merges them into the response so the component fast path is no longer silent
|
|
about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning
|
|
text (pinned by the cross-seam contract test). Kept a standalone pure function
|
|
so each degraded dimension is unit-testable.
|
|
|
|
The resolver's registry-unavailable warning has no analog here: the component
|
|
reads HA's live in-process registry, which is never the failed-WS payload the
|
|
server can receive.
|
|
"""
|
|
warnings: list[str] = []
|
|
|
|
exclude_categories = set(visibility.get("exclude_categories") or [])
|
|
unknown = exclude_categories - _KNOWN_ENTITY_CATEGORIES
|
|
if unknown:
|
|
warnings.append(_unknown_categories_warning(unknown))
|
|
|
|
allow_areas = set(visibility.get("allow_areas") or [])
|
|
allow_labels = set(visibility.get("allow_labels") or [])
|
|
registry_by_id = _registry_index_by_id(view)
|
|
state_ids = {
|
|
eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid
|
|
}
|
|
# Empty-registry allowlist fail-open: same guard as _visibility_hidden_set —
|
|
# only fires when there are states-only candidates the restrict mode would
|
|
# otherwise blank.
|
|
if (allow_areas or allow_labels) and not registry_by_id and state_ids:
|
|
warnings.append(_ALLOWLIST_REGISTRY_EMPTY_WARNING)
|
|
|
|
if bool(visibility.get("respect_assist_exposure")) and not assist_available:
|
|
warnings.append(_ASSIST_UNAVAILABLE_WARNING)
|
|
|
|
return warnings
|
|
|
|
|
|
def _registry_index_by_id(view: _RegistryView) -> dict[str, Any]:
|
|
"""Index the entity-registry entries by entity_id."""
|
|
index: dict[str, Any] = {}
|
|
for entry in _all_entity_entries(view):
|
|
eid = getattr(entry, "entity_id", None)
|
|
if eid:
|
|
index[eid] = entry
|
|
return index
|
|
|
|
|
|
def _apply_visibility_excludes(
|
|
view: _RegistryView,
|
|
registry_by_id: dict[str, Any],
|
|
denied: set[str],
|
|
categories: set[str],
|
|
exclude_hidden: bool,
|
|
exclude_areas: set[str],
|
|
exclude_labels: set[str],
|
|
hidden: set[str],
|
|
) -> None:
|
|
"""Add the exclude-dimension hits to ``hidden`` (registry-derived, deny-first).
|
|
|
|
Mirrors the resolver's exclude loop. The empty-set dimensions are inert (``x in
|
|
set()`` / ``set() & x`` are falsy), so an inactive dimension hides nothing
|
|
without a guard; only ``exclude_hidden`` is a bool flag and keeps its guard.
|
|
"""
|
|
for eid, entry in registry_by_id.items():
|
|
if eid in denied:
|
|
continue
|
|
if _enum_value(getattr(entry, "entity_category", None)) in categories:
|
|
hidden.add(eid)
|
|
continue
|
|
if exclude_hidden and getattr(entry, "hidden_by", None) is not None:
|
|
hidden.add(eid)
|
|
continue
|
|
if _effective_area_for_entry(view, entry) in exclude_areas:
|
|
hidden.add(eid)
|
|
continue
|
|
if exclude_labels & _effective_labels_for_entry(view, entry):
|
|
hidden.add(eid)
|
|
|
|
|
|
def _apply_visibility_allow_assist(
|
|
view: _RegistryView,
|
|
registry_by_id: dict[str, Any],
|
|
state_ids: set[str],
|
|
allow_active: bool,
|
|
allow_entity_ids: set[str],
|
|
allow_areas: set[str],
|
|
allow_labels: set[str],
|
|
assist_active: bool,
|
|
should_expose_fn: Any,
|
|
hidden: set[str],
|
|
) -> None:
|
|
"""Add allow-restrict + Assist hits to ``hidden`` over registry + states.
|
|
|
|
These conjunctive filters must reach states-only entities, so they iterate the
|
|
full candidate universe. Mirrors the resolver's allow/assist loop. ``should_expose_fn``
|
|
is consulted only when ``assist_active`` (Assist requested AND its data
|
|
available); an unavailable-Assist request degrades to no Assist hiding here,
|
|
with the warning surfaced separately.
|
|
"""
|
|
for eid in registry_by_id.keys() | state_ids:
|
|
if eid in hidden:
|
|
continue
|
|
entry = registry_by_id.get(eid)
|
|
if allow_active and not _entity_allowed(
|
|
view, eid, entry, allow_entity_ids, allow_areas, allow_labels
|
|
):
|
|
hidden.add(eid)
|
|
continue
|
|
if assist_active and not should_expose_fn(eid):
|
|
hidden.add(eid)
|
|
|
|
|
|
def _entity_allowed(
|
|
view: _RegistryView,
|
|
eid: str,
|
|
entry: Any,
|
|
allow_entity_ids: set[str],
|
|
allow_areas: set[str],
|
|
allow_labels: set[str],
|
|
) -> bool:
|
|
"""Whether an entity satisfies the allowlist (restrict mode) — matched, so kept."""
|
|
if eid in allow_entity_ids:
|
|
return True
|
|
if entry is None:
|
|
return False
|
|
if _effective_area_for_entry(view, entry) in allow_areas:
|
|
return True
|
|
return bool(allow_labels & _effective_labels_for_entry(view, entry))
|
|
|
|
|
|
def _effective_area_for_entry(view: _RegistryView, entry: Any) -> str | None:
|
|
"""An entity's ``area_id`` falling back to its device's (HA area inheritance)."""
|
|
area_id = getattr(entry, "area_id", None)
|
|
if isinstance(area_id, str) and area_id:
|
|
return area_id
|
|
device_id = getattr(entry, "device_id", None)
|
|
if isinstance(device_id, str) and device_id:
|
|
dev = _device(view, device_id)
|
|
dev_area = getattr(dev, "area_id", None) if dev is not None else None
|
|
return dev_area if isinstance(dev_area, str) and dev_area else None
|
|
return None
|
|
|
|
|
|
def _effective_labels_for_entry(view: _RegistryView, entry: Any) -> set[str]:
|
|
"""An entity's labels plus its device's labels (device labels apply to entities)."""
|
|
labels = set(getattr(entry, "labels", None) or [])
|
|
device_id = getattr(entry, "device_id", None)
|
|
if isinstance(device_id, str) and device_id:
|
|
dev = _device(view, device_id)
|
|
if dev is not None:
|
|
labels |= set(getattr(dev, "labels", None) or [])
|
|
return labels
|
|
|
|
|
|
def _assist_should_expose(hass: HomeAssistant, entity_id: str) -> bool:
|
|
"""Whether ``entity_id`` is exposed to the ``conversation`` assistant — READ-ONLY.
|
|
|
|
A read-only replication of core's ``async_should_expose`` for the conversation
|
|
assistant. core's real function is NOT called because it has a WRITE side effect:
|
|
for an entity with no explicit stored exposure it computes the default and
|
|
persists it back (``entity_registry.async_update_entity_options`` for a registry
|
|
entity, or the exposed-entities store for a legacy one — see core
|
|
``exposed_entities.py``). Consulting it once per candidate entity from a
|
|
``readOnlyHint`` search would stamp exposure onto the whole entity universe and
|
|
pin those defaults, which violates this module's pure-read contract. So this
|
|
reconstructs the SAME precedence (matching the server resolver's
|
|
``_is_assist_exposed``) with no write: an explicit per-entity ``should_expose``
|
|
wins; otherwise the "expose new entities" flag gates the default-exposure check.
|
|
|
|
Composed from three read-only, individually monkeypatchable seams. Fails OPEN
|
|
(returns ``True`` — do not hide) on any error, matching the resolver's "skip the
|
|
Assist dimension when its data is unavailable" behaviour.
|
|
"""
|
|
try:
|
|
explicit = _explicit_assist_exposure(hass, entity_id)
|
|
if explicit is not None:
|
|
return explicit
|
|
if not _assist_expose_new_entities(hass):
|
|
return False
|
|
return _assist_default_exposed(hass, entity_id)
|
|
except Exception: # fail open (do not hide) on any error, mirroring the resolver
|
|
return True
|
|
|
|
|
|
def _explicit_assist_exposure(hass: HomeAssistant, entity_id: str) -> bool | None:
|
|
"""The entity's explicit ``conversation`` ``should_expose`` (True/False), else None.
|
|
|
|
Reads the SAME read-only surface :func:`_do_exposure`'s list mode mirrors — core's
|
|
``async_get_entity_settings`` (registry ``options`` for a registry entity, else
|
|
the legacy exposed-entities store), which never writes. Returns ``None`` when there
|
|
is no explicit override (an id in neither the registry nor the store, or no
|
|
``conversation.should_expose`` key), so the caller falls through to the
|
|
expose-new default. A non-``Unknown entity`` raise propagates (fails open upstream).
|
|
"""
|
|
try:
|
|
settings = _async_get_entity_settings(hass, entity_id)
|
|
except Exception as exc:
|
|
if _is_unknown_entity_error(exc):
|
|
return None
|
|
raise
|
|
conv = settings.get("conversation") if isinstance(settings, Mapping) else None
|
|
if isinstance(conv, Mapping) and "should_expose" in conv:
|
|
return bool(conv["should_expose"])
|
|
return None
|
|
|
|
|
|
def _assist_expose_new_entities(hass: HomeAssistant) -> bool:
|
|
"""core's ``ExposedEntities.async_get_expose_new_entities("conversation")`` — read-only.
|
|
|
|
Function-local import + a standalone seam so the fake-hass suite can monkeypatch
|
|
it. A ``@callback`` that only reads the assistant preferences (no store write).
|
|
"""
|
|
from homeassistant.components.homeassistant.exposed_entities import (
|
|
DATA_EXPOSED_ENTITIES,
|
|
)
|
|
|
|
exposed = hass.data[DATA_EXPOSED_ENTITIES]
|
|
return bool(exposed.async_get_expose_new_entities("conversation"))
|
|
|
|
|
|
def _assist_default_exposed(hass: HomeAssistant, entity_id: str) -> bool:
|
|
"""Read-only mirror of ``ExposedEntities._is_default_exposed`` for ``conversation``.
|
|
|
|
core's ``async_should_expose`` calls the private ``_is_default_exposed`` and then
|
|
WRITES the result back; this recomputes it WITHOUT the write. Imports core's own
|
|
default-exposure constants (no drift — the running core defines them) and uses
|
|
core's ``get_device_class``, so the domain / device-class verdict matches core
|
|
exactly. entity_category / hidden_by entities are never a default exposure. A
|
|
standalone seam so the fake-hass suite can monkeypatch it.
|
|
"""
|
|
from homeassistant.components.homeassistant.exposed_entities import (
|
|
DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES,
|
|
DEFAULT_EXPOSED_DOMAINS,
|
|
DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES,
|
|
)
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
entry = er.async_get(hass).async_get(entity_id)
|
|
if entry is not None and (
|
|
getattr(entry, "entity_category", None) is not None
|
|
or getattr(entry, "hidden_by", None) is not None
|
|
):
|
|
return False
|
|
domain = entity_id.split(".")[0] if "." in entity_id else entity_id
|
|
if domain in DEFAULT_EXPOSED_DOMAINS:
|
|
return True
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.entity import get_device_class
|
|
|
|
try:
|
|
device_class = get_device_class(hass, entity_id)
|
|
except HomeAssistantError: # the entity no longer exists — matches core
|
|
return False
|
|
if domain == "binary_sensor":
|
|
return device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
|
|
if domain == "sensor":
|
|
return device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES
|
|
return False
|
|
|
|
|
|
def _assist_exposure_available(hass: HomeAssistant) -> bool:
|
|
"""Whether core's Assist exposure machinery can be consulted for this request.
|
|
|
|
The resolver emits ``_ASSIST_UNAVAILABLE_WARNING`` when its expose-list fetch
|
|
fails wholesale; the component's analog is core's ``async_should_expose`` being
|
|
unavailable — it reads ``hass.data[DATA_EXPOSED_ENTITIES]`` and raises when the
|
|
exposed_entities store isn't set up (``_assist_should_expose`` then fails open
|
|
per entity, hiding nothing but warning about nothing either). Probing the store
|
|
once lets the caller skip the Assist dimension AND surface the resolver-parity
|
|
degradation warning instead of degrading silently. Imported lazily and a test
|
|
seam (monkeypatched alongside ``_assist_should_expose``).
|
|
"""
|
|
try:
|
|
from homeassistant.components.homeassistant.exposed_entities import (
|
|
DATA_EXPOSED_ENTITIES,
|
|
)
|
|
except Exception:
|
|
return False
|
|
data = getattr(hass, "data", None)
|
|
return isinstance(data, Mapping) and DATA_EXPOSED_ENTITIES in data
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/server_entry
|
|
# =============================================================================
|
|
def _find_server_config_entry(hass: HomeAssistant) -> Any | None:
|
|
"""Return the component's OWN server config entry, or ``None`` if absent.
|
|
|
|
Picks the single ``DOMAIN`` entry stamped with
|
|
``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` (the one ``entry.data``
|
|
key the component reads — the documented data-minimization exception). Shared
|
|
by the ``server_entry`` READ cap (:func:`_do_server_entry`) and the
|
|
``server_entry_update`` WRITE cap (:func:`_server_entry_update_prep`), so both
|
|
discriminate the entry identically.
|
|
"""
|
|
for entry in _iter_config_entries(hass):
|
|
if getattr(entry, "domain", None) != DOMAIN:
|
|
continue
|
|
if _entry_marker_type(entry) != ENTRY_TYPE_SERVER:
|
|
continue
|
|
return entry
|
|
return None
|
|
|
|
|
|
def _do_server_entry(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Locate the component's OWN server config entry and return its identity.
|
|
|
|
Discriminates the server-type entry via :func:`_find_server_config_entry`
|
|
(the ``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` marker). ``channel`` /
|
|
``pip_spec`` come from ``entry.options`` (``None`` when absent); ``entry_id`` is
|
|
``None`` when no server entry exists.
|
|
"""
|
|
entry = _find_server_config_entry(hass)
|
|
if entry is None:
|
|
return {"entry_id": None, "channel": None, "pip_spec": None}
|
|
options = getattr(entry, "options", None)
|
|
opts = options if isinstance(options, Mapping) else {}
|
|
return {
|
|
"entry_id": getattr(entry, "entry_id", None),
|
|
"channel": opts.get(OPT_CHANNEL),
|
|
"pip_spec": opts.get(OPT_PIP_SPEC),
|
|
}
|
|
|
|
|
|
def _entry_marker_type(entry: Any) -> Any:
|
|
"""Read ONLY ``entry.data[CONF_ENTRY_TYPE]`` — the entry-type marker key."""
|
|
data = getattr(entry, "data", None)
|
|
if isinstance(data, Mapping):
|
|
return data.get(CONF_ENTRY_TYPE)
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/server_entry_update (the server_entry WRITE counterpart — Phase 3)
|
|
# =============================================================================
|
|
def _do_server_entry_update(
|
|
hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Pure sync formatter for ``server_entry_update``.
|
|
|
|
ALL of the work — locating the entry, merging the delta against the LIVE
|
|
options, and (unless it is a no-op) scheduling the deferred
|
|
``async_update_entry`` — happens in the async :func:`_server_entry_update_prep`,
|
|
which hands the finished envelope in as ``result``. This only returns it (the WS
|
|
wrapper's ``send_result`` adds the outer success frame), so no scheduling /
|
|
awaiting work ever runs in a ``_do_*`` step.
|
|
"""
|
|
return result
|
|
|
|
|
|
async def _server_entry_update_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Apply a ``channel`` / ``pip_spec`` delta to the server entry, DEFERRED.
|
|
|
|
Returns ``{"result": <envelope>}``. The order is load-bearing:
|
|
|
|
1. Locate the server entry (:func:`_find_server_config_entry`); a missing entry
|
|
raises ``HomeAssistantError`` so the server's command-error path falls back to
|
|
its legacy options-flow submit.
|
|
2. Require at least one of ``channel`` / ``pip_spec`` (the server always sends
|
|
one — this is defence-in-depth).
|
|
3. Snapshot the ``delta`` (the provided fields, keyed by the OPT_* option keys)
|
|
and the CURRENT ``entry.options`` — used ONLY for the no-op check and the
|
|
``previous``/``applying`` response envelope. Every UNtouched key is preserved
|
|
because the actual write MERGES the delta against the LIVE ``entry.options``
|
|
at APPLY time (step 5), NOT against this snapshot — so a concurrent change to
|
|
another key during the flush window is not clobbered, and none of the
|
|
URL/secret overrides get blanked (which is why the server drops its
|
|
preserved-key resend on this path).
|
|
4. No-op short-circuit: if the delta applied to the snapshot equals the current
|
|
options, return ``{scheduled: False, unchanged: True, ...}`` WITHOUT
|
|
scheduling. (The update listener's own ``DATA_LAST_OPTIONS`` guard would also
|
|
make such an update not reload, but skipping the schedule keeps it explicit.)
|
|
5. Otherwise schedule the deferred ``_apply`` — which re-reads the LIVE
|
|
``entry.options`` and merges the delta against THAT before calling
|
|
``async_update_entry`` — on a HASS-owned background task after
|
|
:data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`, and return
|
|
``{scheduled: True, ...}`` immediately.
|
|
|
|
**The deferred-reload crux.** ``async_update_entry`` fires the server entry's
|
|
update listener, which reloads the entry — tearing down the very in-process
|
|
server thread answering THIS frame. Calling it inline would kill that thread
|
|
before the WS ``{scheduled: True}`` response flushed, so the caller would never
|
|
get its confirmation. It is therefore scheduled after a flush delay. The task is
|
|
created with :func:`hass.async_create_background_task` (hass-owned), NOT
|
|
``entry.async_create_background_task``: an entry-owned task is cancelled by the
|
|
unload the reload performs, so it could cancel itself before firing — the exact
|
|
trap ``embedded_entry._on_version_update`` documents. Being hass-owned, the task
|
|
survives to invoke ``async_update_entry`` (a synchronous ``@callback`` that
|
|
returns as soon as it schedules the listener), then completes; the reload it
|
|
triggers runs as its own hass task after the response has flushed.
|
|
"""
|
|
import asyncio
|
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
entry = _find_server_config_entry(hass)
|
|
if entry is None:
|
|
raise HomeAssistantError(
|
|
"no ha_mcp_tools in-process server config entry to update"
|
|
)
|
|
|
|
has_channel = "channel" in msg
|
|
has_pip_spec = "pip_spec" in msg
|
|
if not has_channel and not has_pip_spec:
|
|
raise HomeAssistantError(
|
|
"server_entry_update needs at least one of channel / pip_spec"
|
|
)
|
|
|
|
options = getattr(entry, "options", None)
|
|
current = dict(options) if isinstance(options, Mapping) else {}
|
|
# ``delta`` is the applied write (merged against the LIVE options at APPLY time);
|
|
# ``applying`` is its response-envelope view. The prep-time ``new_options`` is a
|
|
# snapshot used ONLY for the no-op check below, never for the write.
|
|
delta: dict[str, Any] = {}
|
|
applying: dict[str, Any] = {}
|
|
if has_channel:
|
|
delta[OPT_CHANNEL] = msg["channel"]
|
|
applying["channel"] = msg["channel"]
|
|
if has_pip_spec:
|
|
# Normalize like the options flow's ``_normalize`` (config_flow.py): a
|
|
# whitespace-only value OR the default unpinned dist (``DEFAULT_PIP_SPEC``)
|
|
# means "no override" — collapse it to "" so the channel keeps
|
|
# auto-updating. Persisting it verbatim would read as an intentional
|
|
# override and disable auto-updates. This keeps the no-op check honest: a
|
|
# frame that normalizes to the stored value is unchanged, not a schedule.
|
|
pip_spec = msg["pip_spec"]
|
|
if str(pip_spec).strip() in ("", DEFAULT_PIP_SPEC):
|
|
pip_spec = ""
|
|
delta[OPT_PIP_SPEC] = pip_spec
|
|
applying["pip_spec"] = pip_spec
|
|
new_options = {**current, **delta}
|
|
|
|
entry_id = getattr(entry, "entry_id", None)
|
|
previous = {
|
|
"channel": current.get(OPT_CHANNEL),
|
|
"pip_spec": current.get(OPT_PIP_SPEC),
|
|
}
|
|
|
|
if new_options == current:
|
|
return {
|
|
"result": {
|
|
"scheduled": False,
|
|
"unchanged": True,
|
|
"entry_id": entry_id,
|
|
"applying": applying,
|
|
"previous": previous,
|
|
}
|
|
}
|
|
|
|
async def _apply() -> None:
|
|
# Deferred so the WS response flushes before the reload this triggers tears
|
|
# down the serving thread. See the docstring for why it is hass-owned. The
|
|
# delta is merged against the LIVE ``entry.options`` HERE (not at prep) so a
|
|
# concurrent change to another key during the flush window is preserved.
|
|
await asyncio.sleep(SERVER_ENTRY_UPDATE_FLUSH_DELAY_S)
|
|
try:
|
|
live = getattr(entry, "options", None)
|
|
merged = {**(dict(live) if isinstance(live, Mapping) else {}), **delta}
|
|
applied = hass.config_entries.async_update_entry(entry, options=merged)
|
|
if applied is False:
|
|
# ``async_update_entry`` returns False when the merged options already
|
|
# match (e.g. a concurrent write applied the same delta first). No
|
|
# caller is left to answer, so surface the no-apply in the log.
|
|
_LOGGER.warning(
|
|
"server_entry_update: no change applied to entry %s (options "
|
|
"already current)",
|
|
entry_id,
|
|
)
|
|
else:
|
|
_LOGGER.info(
|
|
"server_entry_update applied %s to entry %s", applying, entry_id
|
|
)
|
|
except Exception: # pragma: no cover - defensive; no caller left to raise to
|
|
_LOGGER.exception("server_entry_update deferred apply failed")
|
|
|
|
hass.async_create_background_task(
|
|
_apply(), name=f"{WS_API_PREFIX} server_entry_update"
|
|
)
|
|
return {
|
|
"result": {
|
|
"scheduled": True,
|
|
"entry_id": entry_id,
|
|
"applying": applying,
|
|
"previous": previous,
|
|
}
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/call_service (the first WRITE capability — Phase 3, issue #1813)
|
|
# =============================================================================
|
|
def _do_call_service(
|
|
hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Pure sync formatter for ``call_service``.
|
|
|
|
ALL of the work — the authoritative domain block, the ``ServiceNotFound``
|
|
check, the pre-state capture, the expected-aware register-before-fire listener,
|
|
the single ``async_call`` dispatch, the immediate-match, and the bounded
|
|
confirmation wait — happens in the async :func:`_call_service_prep`, which hands
|
|
the finished result dict in as ``result``. This function only returns that
|
|
envelope (mirroring every other ``_do_*``: the WS wrapper's ``send_result`` adds
|
|
the outer success frame), so no awaiting / blocking work ever runs in a ``_do_*``
|
|
step (D2).
|
|
"""
|
|
return result
|
|
|
|
|
|
async def _call_service_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Do all of ``call_service``'s async work; return ``{"result": <envelope>}``.
|
|
|
|
The order is load-bearing:
|
|
|
|
1. **D1 — authoritative domain block (security-critical).** Refuse
|
|
``domain == "ha_mcp_tools"`` (case/whitespace-normalized) BEFORE any
|
|
``has_service`` / dispatch. This is enforced HERE, in the component that
|
|
fires the call, independent of (and in addition to) the server-side guard:
|
|
a component ``call_service`` that skipped it would let a caller invoke the
|
|
admin-gated ``ha_mcp_tools.get_caller_token`` in-process (the server IS
|
|
admin) and then every file/YAML service. The block keys off the RESOLVED
|
|
domain, so it holds no matter which path reaches this function.
|
|
2. **ServiceNotFound** before dispatch, so an unknown service is a clean
|
|
``SERVICE_NOT_FOUND`` and never a landed-but-unreported write.
|
|
3. Pre-state capture for each ``entity_id`` (a synchronous in-memory read).
|
|
4. Register the expected-aware ``EVENT_STATE_CHANGED`` waiter BEFORE the dispatch
|
|
(D5) so a fast entity's event can't arrive before the listener exists. The
|
|
waiter confirms only on reaching the server's ``expected_state`` hint (skipping
|
|
intermediate/noise events); a ``None`` hint keeps any-first-event confirmation.
|
|
5. Fire exactly ONE ``async_call`` (``blocking=True``); flip ``dispatched``
|
|
immediately after so a post-dispatch problem is never retried as a failed
|
|
call (D3/D9).
|
|
6. Immediate-match (:func:`_match_immediate`) for an idempotent no-op — a target
|
|
whose CURRENT state already equals its hint confirms with NO wait — then a
|
|
bounded wait for whatever is still unconfirmed (D4); expiry is ``partial``.
|
|
7. Diff pre→post into the real transition(s) — the REAL observed transition, never
|
|
the hint value.
|
|
|
|
Raised exceptions PROPAGATE — the WS handler turns them into a command error the
|
|
server maps (D7). Two distinct classes propagate: the D1 domain block and
|
|
``ServiceNotFound`` are PRE-dispatch (they raise before ``async_call``, so nothing
|
|
landed); an ``async_call`` / ``return_response`` validation error is MID-dispatch
|
|
(the handler may mutate state and THEN raise — the documented D9 at-most-once
|
|
residual, NOT pre-dispatch). A confirmation timeout is caught and reported as
|
|
``partial`` (never re-raised): the call already landed.
|
|
|
|
The whole POST-dispatch section — the immediate-match re-read, the wait, and the
|
|
pre→post diff — runs inside ONE ``try`` that, once ``dispatched`` is True, never
|
|
lets a raise escape (I1): a raise mapped to a command error would re-POST an
|
|
already-landed write. A pre-confirmation ``async_call`` failure (``dispatched``
|
|
still False) re-raises so the server can map it; any post-dispatch failure degrades
|
|
to a minimal dispatched-but-unconfirmed envelope. The immediate-match re-read is
|
|
itself raise-proof (:func:`_match_immediate`), the attribute diff is raise-proofed
|
|
(:func:`_values_differ`), and ``unsub`` always runs in the ``finally``.
|
|
Serialization residual (bounded, no sanitize pass): the transition embeds each
|
|
state's ``as_dict()`` and the WS transport re-encodes it; HA core enforces
|
|
JSON-serializable state attributes for its own REST/WS/recorder APIs, so a state
|
|
that reached the component already serializes — re-encoding it here is safe.
|
|
"""
|
|
domain = msg["domain"]
|
|
service = msg["service"]
|
|
|
|
# 1./2. Pre-dispatch guards (D1 domain block + ServiceNotFound) — both raise
|
|
# BEFORE any listener registration or dispatch, so a refused call is never a
|
|
# landed-but-unreported write and ``async_call`` is provably never reached.
|
|
_guard_call_service_target(hass, domain, service)
|
|
|
|
service_data = msg.get("service_data") or {}
|
|
entity_ids = list(msg.get("entity_ids") or [])
|
|
wait = msg.get("wait", True)
|
|
timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT)
|
|
return_response = msg.get("return_response", False)
|
|
should_confirm = bool(wait and entity_ids)
|
|
# The server's confirmation HINT (``_SERVICE_TO_STATE.get(service)``), applied to
|
|
# every confirmation target. Absent / None keeps any-first-event confirmation.
|
|
expected_state = msg.get("expected_state")
|
|
expected_by_entity = dict.fromkeys(entity_ids, expected_state)
|
|
|
|
# 3. Pre-state capture (synchronous in-memory reads, guarded against drift).
|
|
pre = {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids}
|
|
|
|
# 4. Register-before-fire (D5): only when there is something to confirm.
|
|
evt: Any = None
|
|
captured: dict[str, Any] = {}
|
|
unsub: Any = None
|
|
if should_confirm:
|
|
evt, captured, unsub = _register_transition_waiter(
|
|
hass, set(entity_ids), expected_by_entity
|
|
)
|
|
|
|
# 5. Dispatch exactly once. 6. Immediate-match + bounded wait. 7. Build the diff.
|
|
# Everything after ``dispatched = True`` is inside this ONE try so no post-dispatch
|
|
# raise (a drifted re-read, an exotic-attribute diff) escapes (I1) — that would be
|
|
# mapped to legacy and re-POST an already-landed write. ``unsub`` always runs.
|
|
response: Any = None
|
|
dispatched = False
|
|
result: dict[str, Any]
|
|
try:
|
|
response = await hass.services.async_call(
|
|
domain,
|
|
service,
|
|
dict(service_data),
|
|
blocking=True,
|
|
return_response=return_response,
|
|
)
|
|
dispatched = True
|
|
if should_confirm:
|
|
await _await_confirmation(
|
|
hass, entity_ids, expected_by_entity, captured, evt, timeout
|
|
)
|
|
result = _build_call_service_result(
|
|
hass,
|
|
domain,
|
|
service,
|
|
entity_ids,
|
|
pre,
|
|
captured,
|
|
should_confirm=should_confirm,
|
|
dispatched=dispatched,
|
|
return_response=return_response,
|
|
response=response,
|
|
)
|
|
except Exception:
|
|
# PRE-confirmation ``async_call`` failure (never dispatched) → re-raise so the
|
|
# server maps it (D7/D9 MID-dispatch residual). Any POST-dispatch failure →
|
|
# degrade to a minimal dispatched-but-unconfirmed envelope (never re-POSTed).
|
|
if not dispatched:
|
|
raise
|
|
_LOGGER.exception(
|
|
"call_service post-dispatch step failed after dispatch; returning "
|
|
"dispatched-but-unconfirmed envelope (%s.%s)",
|
|
domain,
|
|
service,
|
|
)
|
|
result = _dispatched_unconfirmed_result(domain, service)
|
|
finally:
|
|
if unsub is not None:
|
|
unsub()
|
|
return {"result": result}
|
|
|
|
|
|
def _guard_call_service_target(hass: HomeAssistant, domain: str, service: str) -> None:
|
|
"""Pre-dispatch refusals for ``call_service`` — raise BEFORE any dispatch.
|
|
|
|
* **D1 (security-critical)** — refuse ``domain == "ha_mcp_tools"``
|
|
(case/whitespace-normalized). Enforced HERE, in the component that fires the
|
|
call, independent of (and in addition to) the server-side guard: a component
|
|
``call_service`` that skipped it would let a caller invoke the admin-gated
|
|
``ha_mcp_tools.get_caller_token`` in-process (the server IS admin) and then
|
|
every file/YAML service. The block keys off the RESOLVED domain, so it holds
|
|
no matter which path reaches this function.
|
|
* **ServiceNotFound** — an unknown service is a clean ``SERVICE_NOT_FOUND``, not
|
|
a phantom write. Both raises propagate to the WS handler (D7).
|
|
"""
|
|
if str(domain).strip().lower() == DOMAIN:
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
raise HomeAssistantError(
|
|
"the ha_mcp_tools domain is not callable through call_service; "
|
|
"use the dedicated ha_* tools"
|
|
)
|
|
if not hass.services.has_service(domain, service):
|
|
from homeassistant.exceptions import ServiceNotFound
|
|
|
|
raise ServiceNotFound(domain, service)
|
|
|
|
|
|
def _event_state_value(state: Any) -> Any:
|
|
"""The primary state string from a ``State`` object OR an ``as_dict()`` mapping.
|
|
|
|
Raise-proof: an exotic/stub shape (or a ``.state`` accessor that raises) degrades
|
|
to ``None`` so the expected-aware waiter and the post-dispatch immediate-match can
|
|
never propagate past the dispatch (I1). ``None`` never equals a (str) expected
|
|
hint, so an unreadable state simply does not confirm — it keeps waiting.
|
|
"""
|
|
try:
|
|
if isinstance(state, Mapping):
|
|
return state.get("state")
|
|
return getattr(state, "state", None)
|
|
except Exception: # pragma: no cover - defensive; exotic/stub shapes
|
|
return None
|
|
|
|
|
|
def _register_transition_waiter(
|
|
hass: HomeAssistant, target_set: set[str], expected_by_entity: Mapping[str, Any]
|
|
) -> tuple[Any, dict[str, Any], Any]:
|
|
"""Register the ``EVENT_STATE_CHANGED`` listener BEFORE the dispatch (D5).
|
|
|
|
Returns ``(evt, captured, unsub)``: ``evt`` is set once every id in
|
|
``target_set`` has reported a CONFIRMING ``new_state``; ``captured`` maps each id
|
|
to its raw new_state; ``unsub`` tears the listener down. Registering before the
|
|
dispatch closes the race where a fast entity's event arrives before the
|
|
listener exists.
|
|
|
|
``expected_by_entity`` supplies each target's server-computed expected-state HINT
|
|
(``_SERVICE_TO_STATE``). With a hint, ONLY the event that reaches that state
|
|
confirms — a multi-phase service's intermediate states (``lock``:
|
|
unlocked→locking→locked) and attribute-only noise (a ``media_player`` position
|
|
tick while ``state`` stays "playing") are skipped, NOT captured. With no hint
|
|
(``None``, e.g. ``set_temperature``) any first ``new_state`` confirms — today's
|
|
unchanged behavior.
|
|
"""
|
|
import asyncio
|
|
|
|
from homeassistant.const import EVENT_STATE_CHANGED
|
|
|
|
evt = asyncio.Event()
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _on_change(event: Any) -> None:
|
|
data = getattr(event, "data", None) or {}
|
|
eid = data.get("entity_id")
|
|
new = data.get("new_state")
|
|
# M-newstate-none: a state_changed with new_state=None means the entity was
|
|
# REMOVED mid-wait — that is not a confirmed transition, so do NOT capture it
|
|
# (leaving the target uncaptured keeps the op ``partial``, not falsely
|
|
# confirmed). ``_post_state`` still re-reads a best-available current state.
|
|
if eid in target_set and new is not None:
|
|
exp = expected_by_entity.get(eid)
|
|
# Hint present → confirm ONLY on reaching the expected state (skip
|
|
# intermediate/noise events). Hint None → any first event confirms.
|
|
if exp is None or _event_state_value(new) == exp:
|
|
captured[eid] = new
|
|
if target_set <= set(captured):
|
|
evt.set()
|
|
|
|
# Mark the listener a HA callback so ``EventBus.async_listen`` classifies its
|
|
# ``HassJob`` as ``HassJobType.Callback`` and runs it INLINE on the event loop
|
|
# (``is_callback`` reads exactly ``getattr(func, "_hass_callback", False)``).
|
|
# Without this a plain function is ``HassJobType.Executor``: every instance-wide
|
|
# ``state_changed`` gets thrown at the thread pool, and ``evt.set()`` then runs
|
|
# cross-thread on a non-thread-safe ``asyncio.Event`` → delayed/spurious
|
|
# ``partial`` confirmations and an ``InvalidStateError`` race with the
|
|
# timeout-cancel. We set the attribute directly rather than using ``@callback``:
|
|
# the unit-test harness MagicMock-stubs ``homeassistant.core``, so the decorator
|
|
# would be a MagicMock and break the listener, whereas the plain attribute set is
|
|
# exactly what ``callback`` does (``func.__dict__["_hass_callback"] = True``) and
|
|
# is inert under the stub.
|
|
_on_change._hass_callback = True # type: ignore[attr-defined]
|
|
|
|
unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, _on_change)
|
|
return evt, captured, unsub
|
|
|
|
|
|
def _match_immediate(
|
|
hass: HomeAssistant,
|
|
entity_ids: list[str],
|
|
expected_by_entity: Mapping[str, Any],
|
|
captured: dict[str, Any],
|
|
) -> None:
|
|
"""Capture a target whose CURRENT state already equals its expected hint.
|
|
|
|
Mirrors legacy's "sample current state first": for each not-yet-captured target
|
|
with a known expected state, re-read the live state right after ``async_call``
|
|
returns and, if it already equals the expected value, capture it as confirmation
|
|
so NO wait is needed — a ``turn_on`` on an already-on light confirms instantly
|
|
(pre == expected == "on") instead of timing out to a false ``partial``. No-hint
|
|
targets (``exp is None``) are left for the any-first-event waiter — unchanged.
|
|
|
|
Called AFTER the dispatch, so it MUST be raise-proof (I1): a re-read that raised
|
|
and reached the WS handler would be mapped to legacy and re-POST an already-landed
|
|
write. ``_state_get`` is guarded (``None`` on drift) and ``_event_state_value`` is
|
|
raise-proof, so this never propagates past dispatch. ``captured`` is mutated in
|
|
place with the raw ``State`` (``_post_state``/``_state_as_dict`` normalize it, the
|
|
same shape the waiter captures).
|
|
"""
|
|
for eid in entity_ids:
|
|
exp = expected_by_entity.get(eid)
|
|
if exp is None or eid in captured:
|
|
continue
|
|
cur = _state_get(hass, eid)
|
|
if cur is not None and _event_state_value(cur) == exp:
|
|
captured[eid] = cur
|
|
|
|
|
|
async def _await_confirmation(
|
|
hass: HomeAssistant,
|
|
entity_ids: list[str],
|
|
expected_by_entity: Mapping[str, Any],
|
|
captured: dict[str, Any],
|
|
evt: Any,
|
|
timeout: float,
|
|
) -> None:
|
|
"""Immediate-match the idempotent no-ops, then bounded-wait whatever remains (D4).
|
|
|
|
Runs AFTER the single ``async_call``: :func:`_match_immediate` captures a target
|
|
whose current state already equals its hint (a ``turn_on`` on an already-on light)
|
|
with NO wait; the bounded wait runs only if some target is still unconfirmed and
|
|
its expiry is swallowed (``partial``, never a failure). Kept as its own helper so
|
|
:func:`_call_service_prep` stays under the complexity gate; raise-proof re-read
|
|
(``_match_immediate``), so nothing here escapes past the dispatch (I1).
|
|
"""
|
|
import asyncio
|
|
|
|
_match_immediate(hass, entity_ids, expected_by_entity, captured)
|
|
if set(entity_ids) <= set(captured):
|
|
return # every target confirmed by the immediate-match — no wait needed
|
|
try:
|
|
await asyncio.wait_for(evt.wait(), timeout)
|
|
except TimeoutError:
|
|
pass # partial confirmation, not a failure (D4)
|
|
|
|
|
|
def _build_call_service_result(
|
|
hass: HomeAssistant,
|
|
domain: str,
|
|
service: str,
|
|
entity_ids: list[str],
|
|
pre: Mapping[str, Any],
|
|
captured: Mapping[str, Any],
|
|
*,
|
|
should_confirm: bool,
|
|
dispatched: bool,
|
|
return_response: bool,
|
|
response: Any,
|
|
) -> dict[str, Any]:
|
|
"""Assemble the ``call_service`` response envelope from the captured transition.
|
|
|
|
``confirmed`` is True only when every target reported within the wait;
|
|
``partial`` is a confirmation that lapsed (never a failure). ``dispatched`` is
|
|
only ever ``True`` here (a pre-dispatch problem raised out of the prep);
|
|
reporting the flag rather than a literal keeps the D9 at-most-once boundary —
|
|
"reached this shape ⇒ the single async_call fired" — explicit for the server,
|
|
which never retries a dispatched write. ``service_response`` is present only
|
|
when it was both requested AND non-``None``.
|
|
"""
|
|
transitions = [
|
|
_call_service_transition(eid, pre.get(eid), _post_state(hass, eid, captured))
|
|
for eid in entity_ids
|
|
]
|
|
confirmed = bool(should_confirm and set(entity_ids) <= set(captured))
|
|
result: dict[str, Any] = {
|
|
"domain": domain,
|
|
"service": service,
|
|
"dispatched": dispatched,
|
|
"confirmed": confirmed,
|
|
"partial": bool(should_confirm and not confirmed),
|
|
"transitions": transitions,
|
|
}
|
|
if return_response and response is not None:
|
|
result["service_response"] = response
|
|
return result
|
|
|
|
|
|
def _dispatched_unconfirmed_result(domain: str, service: str) -> dict[str, Any]:
|
|
"""Minimal ``call_service`` envelope when post-dispatch formatting raised (I1).
|
|
|
|
The single ``async_call`` already fired; building the rich transition raised (an
|
|
exotic captured state / serialization edge). Report dispatched-but-unconfirmed with
|
|
no transitions rather than propagating — a propagated raise would become a command
|
|
error the server maps to legacy and re-POST an already-landed write (double-apply).
|
|
Reads only the plain domain/service strings, so it cannot itself raise.
|
|
"""
|
|
return {
|
|
"domain": domain,
|
|
"service": service,
|
|
"dispatched": True,
|
|
"confirmed": False,
|
|
"partial": True,
|
|
"transitions": [],
|
|
}
|
|
|
|
|
|
def _post_state(
|
|
hass: HomeAssistant, entity_id: str, captured: Mapping[str, Any]
|
|
) -> Any:
|
|
"""The post-dispatch state for ``entity_id`` as a plain dict (or ``None``).
|
|
|
|
Prefers the listener-captured ``new_state`` (the event that confirmed the
|
|
transition, normalized through the shared :func:`_state_as_dict`); falls back
|
|
to a fresh guarded ``hass.states`` re-read when the target did not report within
|
|
the wait (the ``partial`` case), so a transition row is still populated with the
|
|
best-available current state. Both ``None`` (a vanished/stateless entity) and
|
|
core drift degrade to ``None`` rather than raising.
|
|
"""
|
|
if entity_id in captured:
|
|
as_dict = _state_as_dict(captured[entity_id])
|
|
if as_dict is not None:
|
|
return as_dict
|
|
return _state_as_dict(_state_get(hass, entity_id))
|
|
|
|
|
|
def _values_differ(a: Any, b: Any) -> bool:
|
|
"""Whether two attribute values differ, raise-proof for array-like values.
|
|
|
|
A plain ``a != b`` raises "truth value ... is ambiguous" for numpy arrays and
|
|
other exotic ``__ne__`` results that aren't a bool. Post-dispatch formatting MUST
|
|
NOT raise (a raise here would surface as a command error the server maps to legacy
|
|
→ a re-POST of an already-landed write), so fall back to a ``repr`` compare when
|
|
the direct compare's truthiness is not a plain bool.
|
|
"""
|
|
try:
|
|
return bool(a != b)
|
|
except Exception: # array-like / exotic __ne__ whose result isn't a plain bool
|
|
return repr(a) != repr(b)
|
|
|
|
|
|
def _call_service_transition(
|
|
entity_id: str,
|
|
old_state: dict[str, Any] | None,
|
|
new_state: dict[str, Any] | None,
|
|
) -> dict[str, Any]:
|
|
"""The real pre→post transition for one target entity.
|
|
|
|
``changed`` compares the top-level ``state`` (always a plain string — safe);
|
|
``attributes_changed`` lists the attribute keys whose values differ (added/removed
|
|
keys included), compared through :func:`_values_differ` so an array-like attribute
|
|
value cannot raise. Both sides may be ``None`` (a stateless or vanished entity),
|
|
which the ``or {}`` guards fold into an all-``None`` comparison rather than raising.
|
|
"""
|
|
old = old_state or {}
|
|
new = new_state or {}
|
|
old_attrs = old.get("attributes") or {}
|
|
new_attrs = new.get("attributes") or {}
|
|
attributes_changed = sorted(
|
|
key
|
|
for key in set(old_attrs) | set(new_attrs)
|
|
if _values_differ(old_attrs.get(key), new_attrs.get(key))
|
|
)
|
|
return {
|
|
"entity_id": entity_id,
|
|
"old_state": old_state,
|
|
"new_state": new_state,
|
|
"changed": old.get("state") != new.get("state"),
|
|
"attributes_changed": attributes_changed,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/bulk_call_service (the BATCH write capability — Phase 3, D5a)
|
|
# =============================================================================
|
|
def _do_bulk_call_service(
|
|
hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Pure sync formatter for ``bulk_call_service``.
|
|
|
|
Like :func:`_do_call_service`, ALL of the work — the per-op D1 domain block,
|
|
the ``ServiceNotFound`` checks, the expected-aware register-before-fire pass, the
|
|
dispatches, the per-op immediate-match, and the one bounded batch wait — happens
|
|
in the async :func:`_bulk_call_service_prep`, which hands the finished envelope in
|
|
as ``result``. This function only returns it, so no awaiting / blocking work runs
|
|
in a ``_do_*`` step (D2).
|
|
"""
|
|
return result
|
|
|
|
|
|
async def _bulk_call_service_prep(
|
|
hass: HomeAssistant, msg: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Do all of ``bulk_call_service``'s async work; return ``{"result": ...}``.
|
|
|
|
Register-before-fire is trivially correct for the batch: every listener is
|
|
registered in one synchronous pass BEFORE any ``async_call`` is issued, so no
|
|
op's confirming event can arrive before its listener exists. The order is
|
|
load-bearing:
|
|
|
|
1. **D1 batch fail-closed (security-critical).** Run
|
|
:func:`_guard_call_service_target` for EVERY operation FIRST — before any
|
|
pre-state read, listener, or dispatch. A single op targeting the
|
|
``ha_mcp_tools`` domain (or an unknown service) makes the WHOLE frame raise:
|
|
no partial batch is dispatched, so no ``ha_mcp_tools.*`` op can ever slip
|
|
through in a batch (register-before-fire + all-guards-first means a refused
|
|
op aborts before any real write lands).
|
|
2. Pre-state capture for every op's ``entity_ids`` (synchronous in-memory).
|
|
3. Register ALL expected-aware confirmation listeners in one pass BEFORE any
|
|
dispatch (each op's ``expected_state`` hint governs its waiter); every
|
|
``unsub`` is torn down in the ``finally``.
|
|
4. Dispatch: ``parallel`` fans the ``async_call``s out through
|
|
:func:`asyncio.gather` with ``return_exceptions=True`` so one op's failure
|
|
does not abort the others (its exception is recorded on that op, NOT raised —
|
|
UNLIKE the step-1 guards, which DO raise the whole frame pre-dispatch);
|
|
``parallel=False`` awaits them in order. Each op flips its own ``dispatched``
|
|
flag the moment its ``async_call`` returns.
|
|
5. Immediate-match per dispatched op (:func:`_bulk_match_immediate`) — an
|
|
idempotent no-op whose current state already equals its hint confirms with no
|
|
wait — then ONE shared bounded deadline (D4, not per-op serial timeouts) for
|
|
every op still unconfirmed.
|
|
6. Per-op pre→post diff, reusing the single-call transition/build helpers.
|
|
7. Return every op's result plus batch counts.
|
|
"""
|
|
operations = list(msg.get("operations") or [])
|
|
parallel = bool(msg.get("parallel", True))
|
|
wait = bool(msg.get("wait", True))
|
|
timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT)
|
|
|
|
# 1. D1 batch fail-closed: guard EVERY op before ANY pre-state / listener /
|
|
# dispatch. A refused op (``ha_mcp_tools`` domain or unknown service) raises
|
|
# the whole frame here, so nothing in the batch is ever dispatched partially.
|
|
for op in operations:
|
|
_guard_call_service_target(hass, op["domain"], op["service"])
|
|
|
|
# 2. Normalize + pre-state capture (synchronous in-memory reads) per op.
|
|
ops = [_bulk_op_record(hass, op, wait=wait) for op in operations]
|
|
|
|
# 3. Register-before-fire (D5): every confirmable op's listener is registered in
|
|
# one synchronous pass BEFORE any dispatch; ALL unsubs torn down in finally.
|
|
unsubs = _bulk_register_all(hass, ops)
|
|
try:
|
|
await _bulk_dispatch_all(hass, ops, parallel=parallel) # 4
|
|
_bulk_match_immediate(hass, ops) # 5a immediate-match idempotent no-ops
|
|
await _bulk_wait_all(ops, timeout) # 5b (one shared deadline; expiry=partial)
|
|
finally:
|
|
for unsub in unsubs:
|
|
unsub()
|
|
|
|
# 6./7. Per-op pre→post diff + batch counts. Post-dispatch assembly MUST be total
|
|
# (I1): the ops already fired, so a raise here would be mapped to legacy and
|
|
# re-dispatch every landed op (double-fire). On any failure return a minimal
|
|
# envelope reporting each op dispatched-but-unconfirmed (its real dispatched/error
|
|
# preserved) instead of propagating.
|
|
try:
|
|
result = _build_bulk_result(hass, ops)
|
|
except Exception:
|
|
_LOGGER.exception(
|
|
"bulk_call_service post-dispatch assembly failed after dispatch; "
|
|
"returning dispatched-but-unconfirmed batch envelope"
|
|
)
|
|
result = _dispatched_unconfirmed_bulk_result(ops)
|
|
return {"result": result}
|
|
|
|
|
|
def _bulk_op_record(
|
|
hass: HomeAssistant, op: Mapping[str, Any], *, wait: bool
|
|
) -> dict[str, Any]:
|
|
"""A mutable working record for one batch operation (incl. its pre-state).
|
|
|
|
Reads the resolved ``{domain, service, service_data?, entity_ids?,
|
|
expected_state?}`` row defensively (the direct-prep tests pass raw dicts that
|
|
never went through the schema, so the mutable defaults are re-applied here).
|
|
``pre`` is the synchronous in-memory pre-state per target; ``expected_by_entity``
|
|
maps every target to this op's confirmation hint (``_SERVICE_TO_STATE``) so the
|
|
waiter + immediate-match key off it; ``dispatched`` / ``error`` / ``response``
|
|
start empty and are filled during dispatch; ``should_confirm`` is true only when
|
|
the batch is waiting AND this op names targets to confirm.
|
|
"""
|
|
entity_ids = list(op.get("entity_ids") or [])
|
|
expected_state = op.get("expected_state")
|
|
return {
|
|
"domain": op["domain"],
|
|
"service": op["service"],
|
|
"service_data": op.get("service_data") or {},
|
|
"entity_ids": entity_ids,
|
|
"expected_by_entity": dict.fromkeys(entity_ids, expected_state),
|
|
"should_confirm": bool(wait and entity_ids),
|
|
"pre": {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids},
|
|
"evt": None,
|
|
"captured": {},
|
|
"dispatched": False,
|
|
"response": None,
|
|
"error": None,
|
|
}
|
|
|
|
|
|
async def _bulk_dispatch_one(hass: HomeAssistant, op: dict[str, Any]) -> None:
|
|
"""Fire exactly one op's ``async_call`` and flip its ``dispatched`` flag.
|
|
|
|
Mirrors the single-call dispatch (``blocking=True``), but bulk never requests a
|
|
per-op ``return_response`` (D5a keeps the batch simple — the single
|
|
``call_service`` covers response-returning calls). ``dispatched`` is set only
|
|
AFTER ``async_call`` returns, so a raise (captured per-op by the caller) leaves
|
|
it ``False`` and the op is never counted as a landed write.
|
|
"""
|
|
op["response"] = await hass.services.async_call(
|
|
op["domain"],
|
|
op["service"],
|
|
dict(op["service_data"]),
|
|
blocking=True,
|
|
return_response=False,
|
|
)
|
|
op["dispatched"] = True
|
|
|
|
|
|
def _bulk_register_all(hass: HomeAssistant, ops: list[dict[str, Any]]) -> list[Any]:
|
|
"""Register every confirmable op's transition listener in one pass (D5).
|
|
|
|
One synchronous sweep BEFORE any dispatch, so no op's confirming event can
|
|
arrive before its listener exists. Each confirmable op is handed its own ``evt``
|
|
/ ``captured`` (mutating the op record); the returned ``unsub`` list is torn down
|
|
in the prep's ``finally``. Non-confirmable ops register nothing.
|
|
|
|
If ``async_listen`` raises mid-sweep (near-impossible in practice), every listener
|
|
already registered in this pass is unsubbed before re-raising — the prep's
|
|
``try/finally`` has not been entered yet, so those would otherwise leak on the bus.
|
|
"""
|
|
unsubs: list[Any] = []
|
|
try:
|
|
for op in ops:
|
|
if op["should_confirm"]:
|
|
evt, captured, unsub = _register_transition_waiter(
|
|
hass, set(op["entity_ids"]), op["expected_by_entity"]
|
|
)
|
|
op["evt"] = evt
|
|
op["captured"] = captured
|
|
unsubs.append(unsub)
|
|
except Exception:
|
|
for unsub in unsubs:
|
|
unsub()
|
|
raise
|
|
return unsubs
|
|
|
|
|
|
async def _bulk_dispatch_all(
|
|
hass: HomeAssistant, ops: list[dict[str, Any]], *, parallel: bool
|
|
) -> None:
|
|
"""Fire every op's dispatch, recording a per-op failure without aborting the batch.
|
|
|
|
``parallel`` fans the dispatches out through :func:`asyncio.gather` with
|
|
``return_exceptions=True`` so one op's ``async_call`` raising is captured on THAT
|
|
op (``error`` set, ``dispatched`` left ``False``) and the others still run;
|
|
``parallel=False`` awaits them in order, catching each op's failure the same way.
|
|
Neither mode propagates a per-op dispatch error — that is the whole point of the
|
|
batch (UNLIKE the pre-dispatch D1/ServiceNotFound guards, which DO raise).
|
|
"""
|
|
import asyncio
|
|
|
|
if parallel:
|
|
outcomes = await asyncio.gather(
|
|
*(_bulk_dispatch_one(hass, op) for op in ops),
|
|
return_exceptions=True,
|
|
)
|
|
for op, outcome in zip(ops, outcomes, strict=True):
|
|
if isinstance(outcome, BaseException):
|
|
op["error"] = _bulk_op_error(outcome)
|
|
else:
|
|
for op in ops:
|
|
try:
|
|
await _bulk_dispatch_one(hass, op)
|
|
except Exception as err:
|
|
op["error"] = _bulk_op_error(err)
|
|
|
|
|
|
def _bulk_match_immediate(hass: HomeAssistant, ops: list[dict[str, Any]]) -> None:
|
|
"""Immediate-match every dispatched, confirmable op (idempotent no-ops).
|
|
|
|
Runs the SAME raise-proof :func:`_match_immediate` per op AFTER the batch
|
|
dispatch and BEFORE the shared wait: an op whose target already sits at its
|
|
expected hint (a ``turn_on`` on an already-on light) is captured here so
|
|
:func:`_bulk_wait_all` skips it — no full-timeout stall for a batch of no-ops.
|
|
Raise-proof (``_match_immediate`` guards each re-read), so it cannot propagate
|
|
past the batch dispatch (I1).
|
|
"""
|
|
for op in ops:
|
|
if op["should_confirm"] and op["dispatched"]:
|
|
_match_immediate(
|
|
hass, op["entity_ids"], op["expected_by_entity"], op["captured"]
|
|
)
|
|
|
|
|
|
async def _bulk_wait_all(ops: list[dict[str, Any]], timeout: float) -> None:
|
|
"""Bounded confirmation wait for the batch: ONE shared deadline (D4).
|
|
|
|
Waits up to ``timeout`` for every dispatched, confirmable op's transition on a
|
|
single shared deadline (not per-op serial timeouts). An op already FULLY captured
|
|
by the immediate-match (:func:`_bulk_match_immediate`) is skipped — its ``evt`` was
|
|
never ``set`` (the match populates ``captured`` directly), so waiting on it would
|
|
stall the whole batch to the timeout. Expiry is swallowed — whichever ops did not
|
|
report are ``partial`` (never a failure); the ops that did report stay confirmed.
|
|
A batch with nothing left to confirm returns immediately.
|
|
"""
|
|
import asyncio
|
|
|
|
waiters = [
|
|
op["evt"].wait()
|
|
for op in ops
|
|
if op["should_confirm"]
|
|
and op["dispatched"]
|
|
and not (set(op["entity_ids"]) <= set(op["captured"]))
|
|
]
|
|
if not waiters:
|
|
return
|
|
try:
|
|
await asyncio.wait_for(asyncio.gather(*waiters), timeout)
|
|
except TimeoutError:
|
|
pass # partial confirmation for whichever ops did not report (D4)
|
|
|
|
|
|
def _bulk_op_error(exc: BaseException) -> str:
|
|
"""A short, stable error string for a per-op dispatch failure.
|
|
|
|
A per-op ``async_call`` exception under the batch is recorded here (never
|
|
propagated past the frame — the other ops still return their results), so the
|
|
server can surface which op failed and why without the whole batch aborting.
|
|
"""
|
|
text = str(exc).strip()
|
|
return f"{type(exc).__name__}: {text}" if text else type(exc).__name__
|
|
|
|
|
|
def _build_bulk_op_result(hass: HomeAssistant, op: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Assemble one op's result envelope from its captured transition.
|
|
|
|
Reuses the single-call :func:`_call_service_transition` / :func:`_post_state`
|
|
diff helpers so the per-op transition shape is byte-identical to
|
|
``call_service``. ``confirmed`` requires the op to have DISPATCHED and every
|
|
target to have reported within the shared wait; ``partial`` is a dispatched-but
|
|
-unconfirmed op (never a failure); an op whose ``async_call`` raised carries
|
|
``error`` with ``dispatched: false`` and is neither confirmed nor partial.
|
|
"""
|
|
entity_ids = list(op["entity_ids"])
|
|
captured = op["captured"]
|
|
should_confirm = op["should_confirm"]
|
|
dispatched = op["dispatched"]
|
|
transitions = [
|
|
_call_service_transition(
|
|
eid, op["pre"].get(eid), _post_state(hass, eid, captured)
|
|
)
|
|
for eid in entity_ids
|
|
]
|
|
confirmed = bool(should_confirm and dispatched and set(entity_ids) <= set(captured))
|
|
result: dict[str, Any] = {
|
|
"domain": op["domain"],
|
|
"service": op["service"],
|
|
"entity_ids": entity_ids,
|
|
"dispatched": dispatched,
|
|
"confirmed": confirmed,
|
|
"partial": bool(should_confirm and dispatched and not confirmed),
|
|
"transitions": transitions,
|
|
}
|
|
if op["error"] is not None:
|
|
result["error"] = op["error"]
|
|
return result
|
|
|
|
|
|
def _build_bulk_result(
|
|
hass: HomeAssistant, ops: list[dict[str, Any]]
|
|
) -> dict[str, Any]:
|
|
"""The batch envelope: every op's result plus the batch counts.
|
|
|
|
``dispatched`` counts ops whose single ``async_call`` fired; ``failed`` counts
|
|
ops that recorded a per-op ``error`` (dispatch raised). ``total`` is the batch
|
|
size, so ``total - dispatched`` is the refused/failed-before-landing count.
|
|
"""
|
|
op_results = [_build_bulk_op_result(hass, op) for op in ops]
|
|
return {
|
|
"operations": op_results,
|
|
"total": len(op_results),
|
|
"dispatched": sum(1 for r in op_results if r["dispatched"]),
|
|
"failed": sum(1 for r in op_results if r.get("error") is not None),
|
|
}
|
|
|
|
|
|
def _dispatched_unconfirmed_bulk_result(
|
|
ops: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
"""Minimal batch envelope when post-dispatch assembly raised (I1 total-formatting).
|
|
|
|
Every op already fired (or recorded a pre-landing ``error``); building the rich
|
|
transition rows raised (an exotic attribute value / serialization edge). Report
|
|
each op with empty transitions rather than propagating — a propagated raise would
|
|
become a command error the server maps to legacy and re-dispatch every landed op
|
|
(double-fire). Reads only each record's plain ``domain``/``service``/``entity_ids``
|
|
/``dispatched``/``should_confirm``/``error`` fields (never the rich captured
|
|
state), so it cannot itself raise; the real per-op ``dispatched``/``error`` are
|
|
preserved so a genuinely-failed op is not misreported as landed.
|
|
"""
|
|
op_results = [
|
|
{
|
|
"domain": op["domain"],
|
|
"service": op["service"],
|
|
"entity_ids": list(op["entity_ids"]),
|
|
"dispatched": op["dispatched"],
|
|
"confirmed": False,
|
|
"partial": bool(op["should_confirm"] and op["dispatched"]),
|
|
"transitions": [],
|
|
**({"error": op["error"]} if op["error"] is not None else {}),
|
|
}
|
|
for op in ops
|
|
]
|
|
return {
|
|
"operations": op_results,
|
|
"total": len(op_results),
|
|
"dispatched": sum(1 for r in op_results if r["dispatched"]),
|
|
"failed": sum(1 for r in op_results if r.get("error") is not None),
|
|
}
|