"""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 ``/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/`` 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 ``/blueprints//`` (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: | 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/_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_(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 ````) 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//config/`` 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 ``/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/`` 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/`` 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 ``/blueprints//``. 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 ``//..`` 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 `` as ``{"__input__": }``. 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: | 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 (``[, ...]`` — 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: , default_password: }`` 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/_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..services..…`` 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 ```` segment of a ``component..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": }``. 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": }``. 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), }