1945 lines
75 KiB
Python
1945 lines
75 KiB
Python
"""In-process WebSocket command surface for the ha_mcp_tools component.
|
|
|
|
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
|
|
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
|
|
capability gate. v1.1.0 ships four commands (three capabilities):
|
|
|
|
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
|
+ ``component_version`` + advisory ``limits``. One cached probe tells the
|
|
server which commands are live (capability negotiation, NOT a version floor).
|
|
* ``ha_mcp_tools/search`` — a unified in-process search over live registries and
|
|
states, joined and scored, mirroring today's ``ha_search`` response envelope.
|
|
* ``ha_mcp_tools/overview`` — the raw in-process reads the server's
|
|
``get_system_overview`` + ``ha_get_overview`` wrapper consume (states,
|
|
services, entity/device/area registries, ``hass.config``, persistent
|
|
notifications, repairs issues) in one call, so the server builds its existing
|
|
overview envelope with no extra HA round-trips.
|
|
* ``ha_mcp_tools/helpers_list`` — collection helpers (live state-attribute
|
|
bodies) AND flow helpers (``ConfigEntry.options``/``title``/``entry_id`` —
|
|
never ``entry.data``), each with the CURRENT entity_id + display name from the
|
|
registry (renamed helpers show current values — issue #1794), closing the
|
|
documented "flow helpers cannot be listed" gap with no OptionsFlow dance. The
|
|
response's ``covered_types`` names which helper_type values were authoritatively
|
|
enumerated, so the server falls back to its legacy ``<type>/list`` path for an
|
|
uncovered type (e.g. ``tag``, which has no state entity) instead of trusting an
|
|
empty result.
|
|
|
|
``ha_mcp_tools/config_get`` was withdrawn before release: it served an entity's
|
|
``raw_config``, whose freshness lags the config file between a write and the next
|
|
completed reload (no version marker distinguishes a fresh body from a stale one),
|
|
so a get racing a reload returned a pre-edit body. ``ha_config_get_{automation,
|
|
script}`` stay on the legacy REST path (which reads the fresh config file);
|
|
scenes were already legacy-only. A file-reading redesign may return (issue #1813).
|
|
|
|
Design notes that are load-bearing:
|
|
|
|
* **Capability negotiation, not version-lockstep.** ``CAPABILITIES`` grows one
|
|
entry per shipped command (except the always-present ``info`` handshake); the
|
|
server asks "do you support ``search``?" rather than "are you >= X". The
|
|
manifest version is reported for display only.
|
|
* **Data minimization.** Flow-helper indexing reads ``ConfigEntry.options`` /
|
|
``title`` only — **never** ``ConfigEntry.data`` (integration credentials).
|
|
* **YAML config bodies are never emitted.** automation/script/scene bodies are
|
|
indexed for *matching*, but a matched item's ``config`` body is returned only
|
|
when it is storage/editor-backed AND ``include_config`` is set. YAML-loaded
|
|
items return identity/metadata only (their ``raw_config`` may carry resolved
|
|
``!secret`` plaintext). Body emission for YAML belongs to a future file-based
|
|
tool.
|
|
* **Resolved secrets are scrubbed from the match corpus.** Because YAML bodies
|
|
(and flow-helper options) can hold ``!secret`` values resolved to plaintext,
|
|
a body leaf that exactly equals a ``secrets.yaml`` value is dropped before
|
|
scoring (:func:`_load_secret_values`) — otherwise a query equal to a suspected
|
|
secret would confirm it via ``match_in_config`` (a probe oracle). Blocked, not
|
|
merely unemitted.
|
|
* **Event-loop hygiene.** Every registry/state join is a pure in-memory read
|
|
over live data — run synchronously, no persistent index (always fresh, zero
|
|
cache-invalidation surface). The one blocking read — ``secrets.yaml`` for the
|
|
match-corpus scrub — runs in the executor via the command wrapper's async
|
|
pre-step (:func:`_search_prep`), never on the event loop.
|
|
|
|
Extension point — to add another command later: write ``_do_<name>(hass,
|
|
params)``, append its capability to :data:`CAPABILITIES`, and add one row to
|
|
:func:`_command_specs`. ``info`` enumerates the rest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from difflib import SequenceMatcher
|
|
from 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 COMPONENT_VERSION
|
|
|
|
_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"
|
|
|
|
# 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"]
|
|
|
|
# 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
|
|
|
|
# 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(), None),
|
|
(_search_schema(), _do_search, _search_prep),
|
|
(_overview_schema(), _do_overview, None),
|
|
(_helpers_list_schema(), _do_helpers_list, None),
|
|
]
|
|
|
|
|
|
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}
|
|
|
|
|
|
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)),
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# ha_mcp_tools/info
|
|
# =============================================================================
|
|
def _do_info() -> dict[str, Any]:
|
|
"""Return the handshake payload (pure; no hass access)."""
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"component_version": COMPONENT_VERSION,
|
|
"capabilities": list(CAPABILITIES),
|
|
"limits": dict(LIMITS),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# 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 _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")
|
|
|
|
view = _resolve_registries(hass)
|
|
diagnostics: dict[str, int] = {}
|
|
partial_reasons: 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,
|
|
)
|
|
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
|
|
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_values(hass: HomeAssistant) -> frozenset[str]:
|
|
"""Load the string values from the instance's ``secrets.yaml``.
|
|
|
|
These scrub resolved ``!secret`` plaintext out of the config-body match
|
|
corpus: a YAML-loaded automation/script/scene body (or a flow-helper's
|
|
options) can carry a secret already resolved to its plaintext value, and
|
|
matching inside it would turn ``ha_search`` into a probe oracle — a query
|
|
equal to a suspected secret confirmed via ``match_in_config``. Any body leaf
|
|
that exactly equals one of these values is dropped before scoring.
|
|
|
|
Defensive by design: an absent ``secrets.yaml`` (``FileNotFoundError`` — the
|
|
common case) yields an empty set silently; a present-but-unreadable or
|
|
malformed file logs one warning and also degrades to an empty set (the scrub
|
|
turns OFF but never raises). Only string values are collected — a secret can
|
|
be any YAML scalar, but a non-string can't be a plaintext-leak leaf and is
|
|
skipped. Loaded off the event loop by :func:`_search_prep` once per search,
|
|
never cached across calls, so an edited ``secrets.yaml`` applies on the next
|
|
search. ``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()
|
|
try:
|
|
path = path_fn("secrets.yaml")
|
|
if not path:
|
|
return frozenset()
|
|
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()
|
|
except Exception:
|
|
# Present-but-unreadable / malformed / permission error: unexpected, so
|
|
# warn once (this runs once per search) to surface a broken scrub, then
|
|
# degrade OFF (empty set) rather than raising into the WS handler.
|
|
_LOGGER.warning(
|
|
"Could not read secrets.yaml for the search secret-scrub; "
|
|
"continuing without it",
|
|
exc_info=True,
|
|
)
|
|
return frozenset()
|
|
if not isinstance(raw, dict):
|
|
return frozenset()
|
|
return frozenset(v for v in raw.values() if isinstance(v, str) and v)
|
|
|
|
|
|
# --- 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 _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)
|
|
|
|
reg = _reg_entity(view, entity_id)
|
|
aliases = (
|
|
sorted(str(a) for a in (getattr(reg, "aliases", None) or [])) 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))
|
|
|
|
area_name = _area_name(view, area_id)
|
|
floor_name = _floor_name_for_area(view, area_id)
|
|
label_names = _label_names(view, labels)
|
|
|
|
# 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, *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": hidden,
|
|
"_area_id": area_id,
|
|
"_match_texts": match_texts,
|
|
}
|
|
|
|
|
|
def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
|
|
area_id = rec.get("_area_id")
|
|
if area_id and str(area_id).lower() == area_filter_lower:
|
|
return True
|
|
area_name = rec.get("area")
|
|
return bool(area_name and str(area_name).lower() == area_filter_lower)
|
|
|
|
|
|
def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
|
|
"""Strip internal ``_``-prefixed keys for the wire response."""
|
|
return {
|
|
"entity_id": rec["entity_id"],
|
|
"friendly_name": rec["friendly_name"],
|
|
"domain": rec["domain"],
|
|
"state": rec["state"],
|
|
"area": rec["area"],
|
|
"floor": rec["floor"],
|
|
"labels": rec["labels"],
|
|
"aliases": rec["aliases"],
|
|
"score": rec["score"],
|
|
"match_type": rec["match_type"],
|
|
}
|
|
|
|
|
|
# --- Config surfaces (automation/script/scene) -------------------------------
|
|
def _search_config_surface(
|
|
hass: HomeAssistant,
|
|
view: _RegistryView,
|
|
domain: str,
|
|
query_lower: str,
|
|
*,
|
|
match_all: bool,
|
|
exact: bool,
|
|
include_config: bool,
|
|
partial_reasons: list[str],
|
|
diagnostics: dict[str, int],
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> list[dict[str, Any]]:
|
|
"""Score one config domain's loaded entities (raw_config indexed, not emitted for YAML)."""
|
|
component = hass.data.get(domain) if getattr(hass, "data", None) else None
|
|
entities = getattr(component, "entities", None)
|
|
if entities is None:
|
|
diagnostics["config_components_inaccessible"] = (
|
|
diagnostics.get("config_components_inaccessible", 0) + 1
|
|
)
|
|
return []
|
|
|
|
results: list[dict[str, Any]] = []
|
|
for entity in entities:
|
|
entity_id = getattr(entity, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
name, item_id, config_dict = _extract_config(domain, entity)
|
|
source = _classify_source(item_id)
|
|
|
|
if match_all:
|
|
score: int | None = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
entity_id,
|
|
name,
|
|
config_dict,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
|
|
# Scenes never emit a component-served body: a HomeAssistantScene holds no
|
|
# raw storage dict (its states are runtime State objects), so config stays
|
|
# None and config_dict is used only as the faithful match corpus.
|
|
config_out: dict[str, Any] | None = None
|
|
if (
|
|
domain != SEARCH_TYPE_SCENE
|
|
and include_config
|
|
and source == "storage"
|
|
and config_dict is not None
|
|
):
|
|
if _too_large(config_dict):
|
|
partial_reasons.append(f"{domain} {entity_id} body omitted (too large)")
|
|
else:
|
|
config_out = config_dict
|
|
|
|
rec: dict[str, Any] = {
|
|
"id": item_id,
|
|
"entity_id": entity_id,
|
|
"source": source,
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
"config": config_out,
|
|
}
|
|
# Scenes carry a "name"; automations/scripts carry an "alias".
|
|
if domain == SEARCH_TYPE_SCENE:
|
|
rec["name"] = name
|
|
else:
|
|
rec["alias"] = name
|
|
results.append(rec)
|
|
return results
|
|
|
|
|
|
def _extract_config(
|
|
domain: str, entity: Any
|
|
) -> tuple[str, str | None, dict[str, Any] | None]:
|
|
"""Return (display_name, item_id, config_dict) for a config entity.
|
|
|
|
Uses defensive getattr because the exact accessor can drift across core
|
|
versions: automation/script expose ``raw_config``; scenes expose
|
|
``scene_config`` (name/icon/id/states) rather than ``raw_config``. For a
|
|
scene the returned ``config_dict`` is the faithful MATCH corpus only (see
|
|
:func:`_scene_match_corpus`), never an emittable body.
|
|
"""
|
|
entity_id = getattr(entity, "entity_id", "") or ""
|
|
name = getattr(entity, "name", None) or entity_id
|
|
unique_id = getattr(entity, "unique_id", None)
|
|
|
|
if domain == SEARCH_TYPE_SCENE:
|
|
scene_config = getattr(entity, "scene_config", None)
|
|
config_dict = _scene_match_corpus(scene_config)
|
|
item_id = unique_id
|
|
if item_id is None and config_dict is not None:
|
|
item_id = config_dict.get("id")
|
|
if config_dict is not None:
|
|
cfg_name = config_dict.get("name")
|
|
if cfg_name:
|
|
name = str(cfg_name)
|
|
return str(name), (str(item_id) if item_id is not None else None), config_dict
|
|
|
|
raw = getattr(entity, "raw_config", None)
|
|
config_dict = dict(raw) if isinstance(raw, dict) else None
|
|
item_id = unique_id
|
|
if item_id is None and config_dict is not None:
|
|
item_id = config_dict.get("id")
|
|
return str(name), (str(item_id) if item_id is not None else None), config_dict
|
|
|
|
|
|
def _scene_match_corpus(scene_config: Any) -> dict[str, Any] | None:
|
|
"""Faithful, minimal MATCH corpus for a scene — never an emittable body.
|
|
|
|
A ``HomeAssistantScene`` holds no raw storage dict: ``scene_config.states`` is
|
|
a ``{entity_id: State}`` map of RUNTIME ``State`` objects. Scoring/emitting
|
|
those (each stringifying to ``<state light.x=on; ...>``) was garbage and
|
|
diverged the component's scoring from any real body. Index only the faithful,
|
|
non-runtime facts instead: ``id`` / ``name`` / ``icon`` plus the entity-id
|
|
KEYS of ``states`` (so "which scenes touch ``light.x``" still matches) — no
|
|
State values, no timestamps or contexts. Used for MATCHING only; the scene
|
|
record never emits a ``config`` body (see :func:`_search_config_surface`).
|
|
"""
|
|
if scene_config is None:
|
|
return None
|
|
if isinstance(scene_config, Mapping):
|
|
src: Mapping[str, Any] = scene_config
|
|
else:
|
|
collected: dict[str, Any] = {}
|
|
for attr in ("id", "name", "icon", "states", "entities"):
|
|
val = getattr(scene_config, attr, None)
|
|
if val is not None:
|
|
collected[attr] = val
|
|
src = collected
|
|
out: dict[str, Any] = {}
|
|
for key in ("id", "name", "icon"):
|
|
val = src.get(key)
|
|
if val is not None:
|
|
out[key] = str(val)
|
|
entity_ids: set[str] = set()
|
|
for key in ("states", "entities"):
|
|
mapping = src.get(key)
|
|
if isinstance(mapping, Mapping):
|
|
entity_ids.update(str(k) for k in mapping)
|
|
if entity_ids:
|
|
out["entities"] = sorted(entity_ids)
|
|
return out or None
|
|
|
|
|
|
def _plainify(value: Any) -> Any:
|
|
"""Best-effort conversion of registry/state objects to plain JSON-able data."""
|
|
if isinstance(value, dict):
|
|
return {str(k): _plainify(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [_plainify(v) for v in value]
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def _classify_source(item_id: str | None) -> str:
|
|
"""Classify an automation/script/scene as storage- or YAML-backed.
|
|
|
|
HA addresses editor-managed items by their ``id`` (the entity's
|
|
``unique_id``); the config editor's ``/config/<domain>/config/<id>`` REST
|
|
path — and its edit link — key off exactly that id, and 404 for items with
|
|
no id. So an id-bearing item is treated as ``storage`` (body emittable under
|
|
``include_config``); an id-less item is ``yaml`` and its body is never
|
|
emitted from search (its ``raw_config`` may carry resolved ``!secret``
|
|
plaintext). This is the conservative rule: the safe error is toward
|
|
withholding a body, not leaking one.
|
|
"""
|
|
return "storage" if item_id else "yaml"
|
|
|
|
|
|
def _too_large(config_dict: dict[str, Any]) -> bool:
|
|
"""Rough guard so a huge body never balloons a single WS frame."""
|
|
try:
|
|
return len(repr(config_dict)) > MAX_BODY_BYTES
|
|
except Exception: # pragma: no cover - defensive
|
|
return False
|
|
|
|
|
|
# --- Helpers surface ---------------------------------------------------------
|
|
def _collection_storage_index(
|
|
hass: HomeAssistant, domains: frozenset[str]
|
|
) -> dict[str, tuple[dict[str, Any], str | None]]:
|
|
"""Map collection-helper ``entity_id`` -> ``(storage body, storage id)``.
|
|
|
|
Collection helpers keep their full storage config on the ``CollectionEntity``
|
|
as ``_config`` — a schedule's weekday blocks, an input_datetime's
|
|
``has_date``/``has_time``, an input_boolean's ``initial`` — fields the live
|
|
state attributes do NOT carry. The ``StorageCollection`` that loaded them is a
|
|
setup-local (``helpers/collection.py`` writes nothing to ``hass.data``), so
|
|
the reachable in-process source is the domain's ``EntityComponent``
|
|
(``hass.data['entity_components'][domain]``, or ``hass.data[domain]`` for the
|
|
automation/script/scene pattern) and each entity's ``_config``.
|
|
|
|
Domains that decompose config into ``_attr_*`` instead of keeping ``_config``
|
|
(input_number / input_text / input_select) have no entry here; the caller
|
|
falls back to the state-attributes body for them (and for any YAML-defined
|
|
helper whose entity is absent). All access is getattr-guarded against drift.
|
|
"""
|
|
index: dict[str, tuple[dict[str, Any], str | None]] = {}
|
|
data = getattr(hass, "data", None)
|
|
if not isinstance(data, Mapping):
|
|
return index
|
|
instances = data.get(ENTITY_COMPONENTS_KEY)
|
|
for domain in domains:
|
|
component = (
|
|
instances.get(domain) if isinstance(instances, Mapping) else None
|
|
) or data.get(domain)
|
|
entities = getattr(component, "entities", None)
|
|
if entities is None:
|
|
continue
|
|
try:
|
|
entity_list = list(entities)
|
|
except Exception: # pragma: no cover - defensive
|
|
continue
|
|
for entity in entity_list:
|
|
entity_id = getattr(entity, "entity_id", None)
|
|
if not entity_id:
|
|
continue
|
|
raw = getattr(entity, "_config", None)
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
storage_id = getattr(entity, "unique_id", None) or raw.get("id")
|
|
index[entity_id] = (
|
|
dict(raw),
|
|
str(storage_id) if storage_id is not None else None,
|
|
)
|
|
return index
|
|
|
|
|
|
def _search_helpers(
|
|
hass: HomeAssistant,
|
|
query_lower: str,
|
|
*,
|
|
match_all: bool,
|
|
exact: bool,
|
|
include_config: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> list[dict[str, Any]]:
|
|
"""Index collection helpers (states) + flow helpers (config-entry options)."""
|
|
results: list[dict[str, Any]] = []
|
|
|
|
# Collection helpers: entities in the state machine, matched on entity_id /
|
|
# friendly_name AND the searchable config body. That body is the entity's real
|
|
# storage ``_config`` (a schedule's weekday blocks, an input_select's
|
|
# ``options`` + ``initial``, …) when reachable, falling back to the live state
|
|
# attributes otherwise (see _collection_storage_index). The name still comes
|
|
# from the CURRENT friendly_name, not the creation-time storage name.
|
|
storage = _collection_storage_index(hass, COLLECTION_HELPER_DOMAINS)
|
|
for state in _iter_states(hass):
|
|
entity_id = getattr(state, "entity_id", "") or ""
|
|
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
if domain not in COLLECTION_HELPER_DOMAINS:
|
|
continue
|
|
attrs = getattr(state, "attributes", None) or {}
|
|
name = attrs.get("friendly_name", entity_id)
|
|
object_id = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
|
|
stored = storage.get(entity_id)
|
|
if stored is not None:
|
|
body = _plainify(stored[0])
|
|
else:
|
|
body = dict(attrs) if isinstance(attrs, Mapping) else {}
|
|
if match_all:
|
|
score: int | None = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
entity_id,
|
|
name,
|
|
body,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
results.append(
|
|
{
|
|
"entity_id": entity_id,
|
|
"helper_type": domain,
|
|
"object_id": object_id,
|
|
"name": name,
|
|
"kind": "collection",
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
"config": body if include_config else None,
|
|
}
|
|
)
|
|
|
|
# Flow helpers: config entries — options + title ONLY, never data.
|
|
for entry in _iter_config_entries(hass):
|
|
domain = getattr(entry, "domain", None)
|
|
if domain not in FLOW_HELPER_DOMAINS:
|
|
continue
|
|
title = getattr(entry, "title", None) or ""
|
|
# ``ConfigEntry.options`` is a ``MappingProxyType`` in live HA, not a
|
|
# ``dict``; the old ``isinstance(..., dict)`` guard silently dropped it to
|
|
# ``{}``, so a flow helper's body (a template's ``state``, a group's
|
|
# members, …) was never indexed and ``match_in_config`` could never fire.
|
|
# Accept any ``Mapping`` so the persisted options are searchable and
|
|
# emittable under ``include_config``.
|
|
raw_options = getattr(entry, "options", None)
|
|
options = dict(raw_options) if isinstance(raw_options, Mapping) else {}
|
|
entry_id = getattr(entry, "entry_id", None)
|
|
if match_all:
|
|
score = 100
|
|
match_in_name = False
|
|
match_in_config = False
|
|
else:
|
|
scored = _config_score(
|
|
query_lower,
|
|
title,
|
|
title,
|
|
options,
|
|
exact=exact,
|
|
secret_values=secret_values,
|
|
)
|
|
if scored is None:
|
|
continue
|
|
score, match_in_name, match_in_config = scored
|
|
results.append(
|
|
{
|
|
"entity_id": None,
|
|
"helper_type": domain,
|
|
"entry_id": entry_id,
|
|
"name": title,
|
|
"kind": "flow",
|
|
"score": score,
|
|
"match_in_name": match_in_name,
|
|
"match_in_config": match_in_config,
|
|
# Data minimization: options only, never entry.data.
|
|
"options": options if include_config else None,
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
# =============================================================================
|
|
# Scoring — mirrors the server's tiers (guarded by the golden parity test)
|
|
# =============================================================================
|
|
def _apply_hidden_penalty(score: int, hidden: bool) -> int:
|
|
"""Reduce ``score`` by :data:`HIDDEN_SCORE_PENALTY` for hidden entities.
|
|
|
|
Mirrors ``utils.fuzzy_search.apply_hidden_penalty`` so the two rankings
|
|
stay consistent.
|
|
"""
|
|
s = int(score)
|
|
return max(0, s - HIDDEN_SCORE_PENALTY) if hidden else s
|
|
|
|
|
|
def _calc_ratio(a: str, b: str) -> int:
|
|
"""SequenceMatcher ratio (0-100). Mirrors ``fuzzy_search.calculate_ratio``."""
|
|
return int(SequenceMatcher(None, a, b, autojunk=False).ratio() * 100)
|
|
|
|
|
|
def _tokenize(text: str) -> list[str]:
|
|
"""Split on ``.``/``_``/``-``/whitespace, lowercase, drop empties.
|
|
|
|
Mirrors ``utils.fuzzy_search.tokenize``.
|
|
"""
|
|
return [t for t in _SPLIT_RE.split(text.lower()) if t]
|
|
|
|
|
|
def _sep_normalized(text: str) -> str:
|
|
"""Collapse ``.``/``_``/``-``/whitespace runs to single spaces.
|
|
|
|
The server's fuzzy engine (BM25) tokenizes query and documents with the
|
|
same splitter, making ``input_boolean`` and ``input boolean`` equivalent
|
|
queries (pinned by the e2e underscore/space-equivalence test). Comparing
|
|
separator-normalized strings replicates that equivalence for the
|
|
component's tier scorer.
|
|
"""
|
|
return " ".join(_tokenize(text))
|
|
|
|
|
|
def _text_tier(query_lower: str, texts: Any, *, fuzzy: bool) -> int | None:
|
|
"""Entity tier: 100 (exact), 80 (substring), fuzzy ratio (>=threshold), or None.
|
|
|
|
Mirrors the server's ``_match_exact_search_entity`` (100/80) over the entity
|
|
id + friendly name, extended to the joined alias/area/floor/label/domain/
|
|
device texts. In fuzzy mode comparisons run on BOTH the raw strings and
|
|
their separator-normalized forms (unified tokenization — ``_``/space
|
|
equivalence), with a whole-string ``calculate_ratio`` fallback surfacing
|
|
typos above :data:`FUZZY_THRESHOLD`. Exact mode stays raw-only for
|
|
byte-parity with the server's exact path.
|
|
"""
|
|
query_norm = _sep_normalized(query_lower) if fuzzy else ""
|
|
best_substring: int | None = None
|
|
best_ratio = 0
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
tier, ratio = _tier_one_text(query_lower, query_norm, str(text).lower(), fuzzy)
|
|
if tier == 100:
|
|
return 100
|
|
if tier == 80:
|
|
best_substring = 80
|
|
elif ratio > best_ratio:
|
|
best_ratio = ratio
|
|
if best_substring is not None:
|
|
return best_substring
|
|
if fuzzy and best_ratio >= FUZZY_THRESHOLD:
|
|
return best_ratio
|
|
return None
|
|
|
|
|
|
def _tier_one_text(
|
|
query_lower: str, query_norm: str, text_lower: str, fuzzy: bool
|
|
) -> tuple[int | None, int]:
|
|
"""Score one candidate text: ``(tier, ratio)``.
|
|
|
|
Tier 100 = exact (raw, or separator-normalized in fuzzy mode); tier 80 =
|
|
substring (same two forms); otherwise ``ratio`` carries the fuzzy
|
|
whole-string fallback (0 when not in fuzzy mode).
|
|
"""
|
|
if query_lower == text_lower:
|
|
return 100, 0
|
|
text_norm = _sep_normalized(text_lower) if fuzzy and query_norm else ""
|
|
if text_norm and query_norm == text_norm:
|
|
return 100, 0
|
|
if query_lower in text_lower:
|
|
return 80, 0
|
|
if text_norm and query_norm in text_norm:
|
|
return 80, 0
|
|
if fuzzy:
|
|
return None, _calc_ratio(query_lower, text_lower)
|
|
return None, 0
|
|
|
|
|
|
def _name_tier(query_lower: str, texts: Any, *, exact: bool) -> int | None:
|
|
"""Config-name tier: substring => 100 (not 80), else fuzzy ratio or None.
|
|
|
|
Config name matches are binary 100/0 in the server's exact path
|
|
(``_score_deep_match``: ``name_exact = 100 if query in id/name else 0``),
|
|
unlike entity matches which have the 80 substring tier.
|
|
"""
|
|
query_norm = "" if exact else _sep_normalized(query_lower)
|
|
best_ratio = 0
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
text_lower = str(text).lower()
|
|
if query_lower in text_lower:
|
|
return 100
|
|
if not exact:
|
|
if query_norm and query_norm in _sep_normalized(text_lower):
|
|
return 100
|
|
ratio = _calc_ratio(query_lower, text_lower)
|
|
if ratio > best_ratio:
|
|
best_ratio = ratio
|
|
if not exact and best_ratio >= FUZZY_THRESHOLD:
|
|
return best_ratio
|
|
return None
|
|
|
|
|
|
def _config_score(
|
|
query_lower: str,
|
|
entity_id: str,
|
|
name: str,
|
|
config_dict: dict[str, Any] | None,
|
|
*,
|
|
exact: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> tuple[int, bool, bool] | None:
|
|
"""Score a config surface: (total, match_in_name, match_in_config) or None.
|
|
|
|
Exact mode is binary 100/0 with a threshold of 100 (server parity); fuzzy
|
|
mode floors at :data:`FUZZY_THRESHOLD`. ``secret_values`` scrubs the body
|
|
match corpus (see :func:`_search_in_dict_exact`).
|
|
"""
|
|
name_score = _name_tier(query_lower, [entity_id, name], exact=exact) or 0
|
|
config_score = _config_body_score(
|
|
query_lower, config_dict, exact=exact, secret_values=secret_values
|
|
)
|
|
threshold = 100 if exact else FUZZY_THRESHOLD
|
|
total = max(name_score, config_score)
|
|
if total < threshold:
|
|
return None
|
|
return total, name_score >= threshold, config_score >= threshold
|
|
|
|
|
|
def _config_body_score(
|
|
query_lower: str,
|
|
config_dict: dict[str, Any] | None,
|
|
*,
|
|
exact: bool,
|
|
secret_values: frozenset[str] = frozenset(),
|
|
) -> int:
|
|
"""Match the query against a config body's keys/values.
|
|
|
|
Exact => 100/0 substring (``_search_in_dict_exact`` parity). Fuzzy adds a
|
|
token-vs-token ``calculate_ratio`` fallback (the server's tier-3 path).
|
|
``secret_values`` scrubs resolved-``!secret`` leaves from the corpus.
|
|
"""
|
|
if config_dict is None:
|
|
return 0
|
|
if _search_in_dict_exact(config_dict, query_lower, secret_values) >= 100:
|
|
return 100
|
|
if exact:
|
|
return 0
|
|
leaves: list[str] = []
|
|
_collect_string_leaves(config_dict, leaves, secret_values)
|
|
query_tokens = _tokenize(query_lower)
|
|
if not query_tokens:
|
|
return 0
|
|
doc_tokens = {tok for leaf in leaves for tok in _tokenize(leaf)}
|
|
best = 0
|
|
for qt in query_tokens:
|
|
for dt in doc_tokens:
|
|
best = max(best, _calc_ratio(qt, dt))
|
|
return best if best >= FUZZY_THRESHOLD else 0
|
|
|
|
|
|
def _search_in_dict_exact(
|
|
data: Any, query_lower: str, secret_values: frozenset[str] = frozenset()
|
|
) -> int:
|
|
"""Exact substring search in nested structures (100 or 0).
|
|
|
|
Mirrors ``smart_search._scoring.ScoringMixin._search_in_dict_exact``, plus a
|
|
secret scrub: a string leaf that exactly equals a known secret value never
|
|
contributes a match (see :func:`_load_secret_values`), so a query equal to a
|
|
resolved ``!secret`` cannot be confirmed via ``match_in_config``. Keys and
|
|
non-string scalars are never secrets, so they are matched as before.
|
|
"""
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if query_lower in str(key).lower():
|
|
return 100
|
|
if _search_in_dict_exact(value, query_lower, secret_values) >= 100:
|
|
return 100
|
|
return 0
|
|
if isinstance(data, (list, tuple)):
|
|
for item in data:
|
|
if _search_in_dict_exact(item, query_lower, secret_values) >= 100:
|
|
return 100
|
|
return 0
|
|
return _leaf_exact_score(data, query_lower, secret_values)
|
|
|
|
|
|
def _leaf_exact_score(
|
|
data: Any, query_lower: str, secret_values: frozenset[str]
|
|
) -> int:
|
|
"""Exact substring score for a scalar leaf (100 or 0).
|
|
|
|
A string leaf that exactly equals a known secret value scores 0 — the scrub
|
|
that keeps a resolved ``!secret`` out of the match corpus.
|
|
"""
|
|
if isinstance(data, str):
|
|
if data in secret_values:
|
|
return 0
|
|
return 100 if query_lower in data.lower() else 0
|
|
if data is not None:
|
|
return 100 if query_lower in str(data).lower() else 0
|
|
return 0
|
|
|
|
|
|
def _collect_string_leaves(
|
|
data: Any, out: list[str], secret_values: frozenset[str] = frozenset()
|
|
) -> None:
|
|
"""Recursively collect string representations. Mirrors the server helper.
|
|
|
|
A string leaf that exactly equals a known secret value is dropped so it
|
|
never reaches the fuzzy token corpus (the scrub in :func:`_search_in_dict_exact`
|
|
covers the exact path).
|
|
"""
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
out.append(str(key))
|
|
_collect_string_leaves(value, out, secret_values)
|
|
elif isinstance(data, (list, tuple)):
|
|
for item in data:
|
|
_collect_string_leaves(item, out, secret_values)
|
|
elif isinstance(data, str):
|
|
if data not in secret_values:
|
|
out.append(data)
|
|
elif data is not None:
|
|
out.append(str(data))
|
|
|
|
|
|
# =============================================================================
|
|
# Registry accessors (all getattr-guarded against core drift)
|
|
# =============================================================================
|
|
def _iter_states(hass: HomeAssistant) -> list[Any]:
|
|
states = getattr(hass, "states", None)
|
|
getter = getattr(states, "async_all", None) if states is not None else None
|
|
if getter is None:
|
|
return []
|
|
try:
|
|
return list(getter())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _iter_config_entries(hass: HomeAssistant) -> list[Any]:
|
|
config_entries = getattr(hass, "config_entries", None)
|
|
getter = (
|
|
getattr(config_entries, "async_entries", None)
|
|
if config_entries is not None
|
|
else None
|
|
)
|
|
if getter is None:
|
|
return []
|
|
try:
|
|
return list(getter())
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
|
|
def _reg_entity(view: _RegistryView, entity_id: str) -> Any:
|
|
return _call_lookup(view.entity, "async_get", entity_id)
|
|
|
|
|
|
def _device(view: _RegistryView, device_id: str | None) -> Any:
|
|
if not device_id:
|
|
return None
|
|
return _call_lookup(view.device, "async_get", device_id)
|
|
|
|
|
|
def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
|
|
if not area_id:
|
|
return None
|
|
area = _call_lookup(view.area, "async_get_area", area_id)
|
|
name = getattr(area, "name", None) if area is not None else None
|
|
return str(name) if name else None
|
|
|
|
|
|
def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None:
|
|
if not area_id:
|
|
return None
|
|
area = _call_lookup(view.area, "async_get_area", area_id)
|
|
floor_id = getattr(area, "floor_id", None) if area is not None else None
|
|
if not floor_id:
|
|
return None
|
|
floor = _call_lookup(view.floor, "async_get_floor", floor_id)
|
|
name = getattr(floor, "name", None) if floor is not None else None
|
|
return str(name) if name else None
|
|
|
|
|
|
def _label_names(view: _RegistryView, label_ids: Any) -> list[str]:
|
|
names: list[str] = []
|
|
for label_id in sorted(label_ids or []):
|
|
label = _call_lookup(view.label, "async_get_label", label_id)
|
|
name = getattr(label, "name", None) if label is not None else None
|
|
names.append(str(name) if name else str(label_id))
|
|
return names
|
|
|
|
|
|
def _call_lookup(registry: Any, method: str, key: str) -> Any:
|
|
if registry is None:
|
|
return None
|
|
getter = getattr(registry, method, None)
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
return getter(key)
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _call_no_arg(obj: Any, method: str) -> Any:
|
|
"""Call a no-argument accessor (e.g. ``async_services``), guarded."""
|
|
if obj is None:
|
|
return None
|
|
fn = getattr(obj, method, None)
|
|
if not callable(fn):
|
|
return None
|
|
try:
|
|
return fn()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
|
|
|
|
def _iso(value: Any) -> Any:
|
|
"""Serialize a datetime-ish value to an ISO string; pass through otherwise.
|
|
|
|
HA registry/state timestamps are ``datetime`` objects. The WS layer can
|
|
encode them, but the REST shapes the overview consumer mirrors carry ISO
|
|
strings, so normalize here for a stable wire contract.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
iso = getattr(value, "isoformat", None)
|
|
if callable(iso):
|
|
try:
|
|
return iso()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
return value if isinstance(value, (str, int, float, bool)) else str(value)
|
|
|
|
|
|
def _enum_value(value: Any) -> Any:
|
|
"""Unwrap a StrEnum-ish registry field (``entity_category``/``hidden_by``/…).
|
|
|
|
HA stores these as enums whose ``.value`` is the wire string; a plain string
|
|
(or None) passes through unchanged.
|
|
"""
|
|
if value is None or isinstance(value, str):
|
|
return value
|
|
return getattr(value, "value", str(value))
|
|
|
|
|
|
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]) -> 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. No secret scrub: collection bodies come
|
|
from the storage collection (or live state attributes) and flow options are
|
|
storage-backed — neither is YAML-derived, so no resolved ``!secret`` plaintext
|
|
can appear.
|
|
|
|
``covered_types`` names exactly the helper_type values this command can
|
|
enumerate (the state-machine collection domains + the flow domains, minus the
|
|
flow set when ``include_flow_helpers`` is false). It is the anti-silent-wrong
|
|
signal: for a requested helper_type NOT in ``covered_types`` (e.g. ``tag``,
|
|
which has no state entity), an empty ``helpers`` list means "cannot
|
|
enumerate", NOT "none exist" — the server must fall back to its legacy
|
|
``<type>/list`` path rather than trust the emptiness.
|
|
"""
|
|
requested = params.get("helper_types")
|
|
type_filter = frozenset(requested) if requested else None
|
|
include_flow = params.get("include_flow_helpers", True)
|
|
|
|
view = _resolve_registries(hass)
|
|
helpers = _collection_helpers_list(hass, view, type_filter)
|
|
covered = set(HELPERS_LIST_COLLECTION_DOMAINS)
|
|
if include_flow:
|
|
helpers.extend(_flow_helpers_list(hass, view, type_filter))
|
|
covered |= FLOW_HELPER_DOMAINS
|
|
return {
|
|
"helpers": helpers,
|
|
"count": len(helpers),
|
|
"covered_types": sorted(covered),
|
|
}
|
|
|
|
|
|
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
|
|
) -> list[dict[str, Any]]:
|
|
"""Flow (config-entry-backed) helpers — options + title + entry_id, never data."""
|
|
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 = (
|
|
_plainify(dict(raw_options)) 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.
|
|
"""
|
|
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):
|
|
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
|