Skip to content

Commit 9e80808

Browse files
feat: Phase 2 component-backed robustness reads (config entries, registry lookups, system snapshot, dashboards, services, visibility) (#1813) (#1902)
* feat: add ha_mcp_tools sync-read WS capabilities and info timezone Adds six in-process ha_mcp_tools/* commands behind the #1812 capability seam (component side only): config_entries, registry_lookup, system_snapshot, entity_lookup, backup_prep, registries. Each follows the existing pattern (WS constant, voluptuous schema, _command_specs row, CAPABILITIES entry, pure _do_* handler with an optional async prep). config_entries emits the config_entries/get row shape, scrubs resolved !secret values from options, and never reads entry.data; registry_lookup returns EVERY entity of a config entry (not the single-valued index); system_snapshot reads its four health slices in one synchronous pass; registries rows are full-field and byte-compatible with the config/<x>_registry/list WS shapes (verified against core). _do_info now reports hass.config.time_zone as an additive field, and the server-side ComponentCaps caches it (nothing consumes it yet). Component unit tests appended; component_api tests extended for timezone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc * fix: add error_reason_translation_key/placeholders and num_subentries to config_entries row The config_entries capability row omitted three fields core's config_entries/get WS command passes through opaquely via ConfigEntry.as_json_fragment (error_reason_translation_key, error_reason_translation_placeholders, num_subentries), making the component response subtractive vs the legacy path. Verified against home-assistant/core's config_entries.py: the first two are plain instance attributes defaulting to None; num_subentries is computed inline as len(entry.subentries), not a stored attribute. * feat: add phase 2 async-prep component capabilities Add five ha_mcp_tools component WS capabilities, all consumed later by the server behind the capability handshake with legacy fallback: - dashboards (list/get/search) — async _dashboards_prep does all lovelace Store loads; list mirrors lovelace/dashboards/list + additive mode, get returns a storage body (yaml_excluded status for YAML dashboards), search walks storage dashboards' cards/sections capped at 200. YAML bodies never emitted. - services_list — REST /api/services reshape from async_get_all_descriptions + services translations, with a coarse superset filter the server refines. - reference_data — the service index + entity-id universe the config reference validator's build_service_index/build_entity_set consume. - search_visibility — an optional visibility param on ha_mcp_tools/search; _visibility_hidden_set mirrors the server's hidden_entity_ids resolver, delegating the Assist dimension to core's async_should_expose, applied as a hard exclude before counts/pagination so ha_search can drop its filter gate. - server_entry — the component's own server config entry (entry_id/channel/ pip_spec), reading only the CONF_ENTRY_TYPE marker from entry.data. Bump COMPONENT_VERSION and the manifest to 1.2.0. * fix: reject scope-less category and target-less registry_lookup requests Both were silently returning an empty result instead of signaling caller error. registries now requires a non-empty category_scopes when "category" is requested, and registry_lookup requires entity_ids or config_entry_id. Both raise HomeAssistantError (mirroring _do_backup_prep) rather than voluptuous, since a flat schema can't express these cross-field rules. * feat: route ha_get_integration through the config_entries component capability Serve a config entry's identity, its already-materialized options (raw persisted, secret-scrubbed), and its subentries from one ha_mcp_tools/ config_entries frame, replacing the REST list-all get, the per-entry OptionsFlow start/abort probe, and the subentries WS call. include_schema / include_subentry_schema stay on the legacy live flow (a schema only exists in an open flow); the component's raw options are kept over the flow-derived shape. Falls back to the legacy path on any capability miss / downgrade / command error, per the standard routing taxonomy. * fix: guarantee options key on component-served integration entries Mirror the legacy path's entry.setdefault("options", {}) on the component path so a (theoretical) options-less component row can't produce a missing key. Also note that the options-shape caveat test compares against a synthetic flow dict, not a real live flow. * feat: route helper delete/create entity resolution through registry_lookup Consume the component's registry_lookup capability from the helper delete/create paths, adding component_registry.py as the caps-gated seam (mirroring component_devices.py). - _delete_simple_helper: when registry_lookup is advertised, resolve the unique_id with ONE registry_lookup(entity_ids=[id]) read instead of the 3-attempt exponential-backoff config/entity_registry/get loop. An in-process read has no WS-timing race, so the retry loop (and its sleeps) is skipped; on capability miss / component error the legacy loop runs unchanged. The {type}/delete calls, both fallbacks, and the exact NOT_FOUND vs SERVICE_CALL_FAILED classification are preserved. - _get_entities_for_config_entry: one registry_lookup(config_entry_id=id) read replaces the whole config/entity_registry/list dump (rows are the byte-identical as_partial_dict shape, already scoped to the entry). This covers both the flow-helper delete sub-entity collection and the ha_config_set_helper post-create wait poll via the shared helper. The component read runs inside the existing warnings-swallow guard so a WS drop degrades identically to the legacy dump. - _get_entry_id_for_flow_helper is left legacy (a single targeted config/entity_registry/get, not a dump) — the brief permits either and this keeps its error classification untouched. Legacy paths are kept forever; component-less installs are unaffected. * fix: path-accurate component resolve error detail and falsy unique_id coverage * feat: route ha_manage_backup identity resolution through backup_prep component capability create_backup and restore_backup each resolved the local backup agent and default password via two sequential backup/agents/info + backup/config/info WS calls. When the ha_mcp_tools component advertises backup_prep, one in-process read now supplies both, falling back to the legacy sequential calls on capability miss, unknown_command, or a non-fatal command error. A component local_agent_id: None raises the same "no local agent" error the legacy path does; a missing default_password preserves each call site's existing behavior (create_backup raises, restore_backup warns and proceeds without a safety backup). The backup/generate and backup/restore write path is unchanged. * fix: align component-path available_agents context with legacy shape * feat: route ha_list_floors_areas and registry auto-backup reads through the component ha_list_floors_areas fetched the area and floor registries via two concurrent but independent WebSocket calls, leaving a TOCTOU window where a registry change between the two reads could misclassify an area as orphaned/unassigned. The auto-backup capture reads for label, category, and area/floor each dumped a whole registry to find one entry. Add a shared fetch_registries_via_component helper (src/ha_mcp/tools/component_registries.py) that reads the ha_mcp_tools component's registries capability in one in-process frame when available, with a legacy fallback otherwise. Wire it into ha_list_floors_areas (closing the TOCTOU window with a single consistent snapshot) and into backup_manager's _fetch_label / _fetch_category / _fetch_area_or_floor (lazy-imported to avoid the backup_manager/tools import cycle, mirroring the existing _fetch_device pattern). _fetch_zone and all _restore_* writes are unchanged. * fix: validate component registry slice shapes and cover backup fetcher fallbacks fetch_registries_via_component only checked that the outer result was a dict, not that each requested slice (areas/floors/labels/categories) had the expected shape. Add a shape guard mirroring the legacy path's _require_list-style check (never raises; falls back to legacy on mismatch). Also add routing tests for backup_manager's label/category/ area/floor fetchers falling back to legacy on caps-absent and component-error conditions. * feat: serve HA timezone from the ha_mcp_tools info handshake when available ha_get_history and the logbook-source ha_get_logs localize timestamps via add_timezone_metadata, which issued a fresh GET /api/config REST call on every single call purely to read time_zone. _fetch_ha_timezone now consults the cached ha_mcp_tools/info handshake (ComponentCaps.timezone) first; a non-empty value is used directly with no REST call, falling back to the unchanged legacy fetch when the component is absent, predates the field, or reports it empty. * test: stop history fixtures paying a real WS probe for component caps _fetch_ha_timezone now calls get_component_caps(client) before falling back to client.get_config(). test_tools_history.py's mock clients carry a real-looking base_url/token without patching get_websocket_client, so each affected test paid a real (failing) WS connection attempt on every call — get_component_caps never caches a HomeAssistantConnectionError. Patch get_websocket_client to raise deterministically so the tests fall straight to their already-mocked legacy get_config path. * test: pin allow-mode and deny-precedence visibility dimensions * feat: route ha_get_system_health through the system_snapshot component capability One ha_mcp_tools/system_snapshot read replaces the 3x config_entries/get fetches (a TOCTOU across zwave_network/matter_network/dead_entities) plus the repairs/list_issues and dead_entities registry/state fetches, threaded into _fetch_repairs, _fetch_zwave_network, _fetch_matter_network, and _fetch_dead_entities. All-or-nothing per call: a capability miss, command error, or malformed slice falls back to every consuming section's own legacy fetch. * fix: explicit snapshot prefetch gate and propagation/dismissed coverage _fetch_dead_entities now requires all three prefetched slices together (states/registry/entries) rather than gating on prefetched_registry alone, making the system_snapshot all-or-nothing invariant explicit in the function instead of relying on the call site. Adds routing coverage that a HomeAssistantConnectionError during the snapshot read propagates rather than degrading to per-section legacy fallbacks, and contract coverage that include_dismissed_repairs=True through the prefetched-repairs path matches the legacy fetch. * feat: route find_server_config_entry through the server_entry capability When the ha_mcp_tools component advertises server_entry, one in-process read identifies the server config entry directly instead of probing every ha_mcp_tools-domain entry's options flow for the pip_spec schema shape. Exactly one options flow is still opened (for the identified entry) since ha_dev_manage_server submits it; current_options is built from that flow's own schema so the preserved-option-keys resend logic is unaffected. Capability miss, command errors, or a failure to open the identified entry's flow fall back to the unchanged legacy per-candidate probe loop. * fix: shared flow-fields helper and empty entry_id handling for server_entry path Factors the duplicated flow-schema-to-fields-dict derivation in _open_server_entry_flow and find_server_config_entry's legacy probe loop into one _fields_from_flow_schema helper, and makes _fetch_server_entry_via_component treat an empty-string entry_id as a component miss (falls back to the legacy probe) rather than trusting it as the authoritative "no entry" verdict that only a real None means. * feat: route ha_list_services through the services_list component capability Threads domain (not query) to ha_mcp_tools/services_list so one component frame replaces the legacy REST get_services() + WS frontend/get_translations pair; _process_services still runs unchanged and does the exact query filtering server-side. query is deliberately kept off the wire call: the component's coarse query filter can drop a domain from the payload entirely, but _process_services's domains field is gated only by domain_filter, so a query-trimmed payload would silently list fewer domains than legacy. * test: correct the query-forwarding parity narrative in services_list contract docstring * feat: route ha_search through the component under an active visibility filter An active entity-visibility filter previously forced ha_search onto the legacy path unconditionally, since a plain `search` component applies no filtering and would leak hidden entities. The `search_visibility` capability closes that gap: a component that advertises it accepts the serialized hide config (new `VisibilityConfig.to_wire`) as the `search` `visibility` param and excludes hidden entities before its own counts/pagination, so a visibility-active install can now take the fast path too. Gate matrix (only entered when the component advertises `search`): - filter inactive -> component, no `visibility` param (old components unaffected) - filter active + `search_visibility` -> component WITH the serialized config - filter active without the capability -> legacy (unchanged) - config unloadable -> fail-closed to legacy (never route unfiltered) The config load reuses the memoized local read (`load_visibility_wire`); no hide-set is precomputed server-side and no Assist exposure is fetched on this path (the component's in-process `async_should_expose` handles it). ha_get_overview is unchanged. Adds a routing suite and a cross-seam contract suite asserting the component-filtered surviving set equals the legacy resolver's over identical fixtures for every hide dimension (category, hidden, deny, area incl. device-inherited, label, allow-mode, assist) and the include_hidden interaction. * refactor: single visibility config load on the component search path * feat: route dashboard reads through the dashboards component capability Consume the ha_mcp_tools/dashboards capability for the dashboard read surface (Global-Constraint-2 idiom): fetch_dashboards_list, the get-mode config read, _verify_config_unchanged, and the auto-backup capture prefer one in-process component frame and fall back to the legacy lovelace WS reads. list rows are filtered to storage-only to match legacy; a yaml_excluded / not_found / unavailable component response falls back per call. Add a cross-dashboard search mode (mode="search", query=) that answers "which dashboards contain this entity/card". It works component-less via a server-side walk that is a byte-for-byte port of the component walk, so both paths return identical matches. All writes (lovelace/config/save, dashboards/delete/create/update) are unchanged. * test: pin dashboard search asymmetry, truncation, and icon-shape parity Adds a docstring caveat for the component-less default-dashboard search exclusion, plus contract tests for case-insensitive search, the 200-match truncation cap, the default-dashboard search asymmetry, and the icon-less list-row shape divergence between the component and legacy paths. * feat: route automation/scene resolvers + reference validator through component Add entity_lookup + reference_data consumers for issue #1813 Phase 2. Three set/remove/post-write consumers pay for a whole-collection fetch to answer a single question and now route through the ha_mcp_tools component when it advertises the capability, falling back to the legacy path otherwise: - ConfigSceneTools._resolve_scene_entity_id and AutomationConfigTools._resolve_automation_entity_id gain an allow_component flag (set/remove/post-write call sites only) that routes one entity_lookup frame in place of the whole entity-registry dump (scene) / get_states scan (automation). The scene path drops its 0.2s sleep + retry — the in-process read is authoritative on return. - validate_config_references routes one reference_data frame in place of gather(get_services, get_states); build_service_index / build_entity_set consume the payload unchanged. The automation/script/scene config-get tools keep making ZERO component calls: the resolvers gate the whole component branch (caps probe included) behind allow_component, which the get paths never pass. TestConfigGetSeam still passes unmodified. A new component_config_reads module owns the caps-gated fetch + error taxonomy (unknown_command invalidates caps, command errors fall back, connection errors propagate) so the routing discipline lives in one place. New tests: test_ha_config_resolvers_component_routing (routing taxonomy per consumer) and test_component_entity_lookup_contract (real _do_entity_lookup / _do_reference_data driven through the real consumers — multi-platform unique_id collision, automation unique_id==config-id equivalence, reference-warning parity with the legacy path). Existing legacy-path tool tests are made credential-less so the component gate short-circuits to the path they pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc * fix: absorb registration lag on component scene resolve and restore validator exception surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc * test: deterministic exception stubs and caps-probe isolation for full-suite runs Pin homeassistant.exceptions to the file's stub per-test in test_component_ws_search.py so registry_lookup/backup_prep/registries raise assertions don't depend on module collection order, and mirror test_tools_history.py's caps-probe fixture in test_tools_utility_log_order.py so the logbook path's timezone-caps probe can't attempt a real WS connection. * refactor: disambiguate registry helper module names and close review test gaps - Rename component_registry.py to component_registry_lookup.py so its filename matches its capability (registry_lookup) and no longer collides with component_registries.py at a glance. - Add a malformed-params rejection test for the server_entry schema. - Add the missing unknown_command-invalidates and HomeAssistantConnectionError-propagates fallback tests for the search_visibility routing path. * fix: address PR #1902 CI round 1 (format, hassfest deps, typing, section-flattened options) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc * fix: address Codex review findings on PR #1902 (yaml dashboard parity, empty entry_id, rest-legacy fallbacks) - Keep YAML dashboards' metadata rows in dashboards list results on both the component and legacy paths (listing metadata is safe; bodies stay excluded). - Skip mode="yaml" rows in the component-less legacy search walk so a YAML Lovelace config's resolved !secret is never fetched into a match. - Treat an empty-string entry_id as a single-entry lookup for a nonexistent id (entries: []) in the component config_entries handler, not list mode. - On a scene resolve, when the component recheck returns None (component gone mid-retry), fall back to the legacy list+retry resolver instead of the naive scene.{scene_id} guess; only an authoritative empty goes to the naive guess. - For ha_list_services and ha_get_integration only, catch HomeAssistantConnectionError from the component fetch and fall back to their REST legacy paths (their legacy is REST, not the shared pooled WS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc * fix: address Codex round-2 findings (fail-closed dashboard walk, transport fallbacks, force_reload, visibility warnings) * fix: correct dashboard mode provenance and drop the dead yaml-mode constant Core's schemas stamp mode on every lovelace/dashboards/list row (storage items default it, YAML entries require it) — the fail-closed storage-only walk stays, but the untagged case is legacy-era storage items, not every component-less row. Removes _DASHBOARD_YAML_MODE, dead after the flip. * fix: component-side review fixes (read-only assist, walk coverage, entry fields, schema tightening) * fix: server-side review fixes (repairs active filter, walk parity, radio entries routing, dashboard search guards) * fix: uniform transport fallback across component fetch helpers and transient-negative caps cache Every component fetch helper now applies one taxonomy: a HomeAssistantConnectionError off send_command AND the plain Exception get_websocket_client() raises when WebSocketManager can't build the pooled socket are caught and routed to the legacy path (return None, or inline-legacy + warning for search/overview/zones/helpers), never propagated. Six reviews proved the "legacy shares the socket and fails identically" premise false for every consumer (swallowing send_websocket_message bridge / REST / dedicated one-shot capture socket / dedicated health WS), so an escaping transport error could abort a landed write, block an auto-backup-wrapped write, or fail a whole tool the legacy path would have served. Docstrings rewritten to the honest per-consumer rationale. Add a short-TTL (30s) transient-negative caps cache in component_api so a WS-broken install stops re-paying the slow connect on every tool call, self-healing past the window; kept independent from the 300s absent-negative. Convert the component accessor drift guards (_do_entity_lookup / _do_registries / _do_reference_data / _fetch_service_descriptions) to raise HomeAssistantError on an unavailable core substrate (None registry / non-Mapping services) so the server falls back to legacy, instead of serving a well-formed empty it would trust as authoritative; a present-but-empty substrate still returns empty. Invert every connection-error-propagates routing test to pin fallback, add establish-failure fallback tests via patch_ws_establish_failure across the helper suites, and add caps transient-cache + drift-guard component tests. * test: align gate probes and seam-error conversion with the uniform transport taxonomy * fix: satisfy format and typing gates on the transport-sweep additions * fix: resolve CodeQL findings on the review-wave additions --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 017e44b commit 9e80808

71 files changed

Lines changed: 17979 additions & 411 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

custom_components/ha_mcp_tools/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
2525
# capability negotiation — not this version — gates each WS command (see
2626
# ``websocket_api.CAPABILITIES``).
27-
COMPONENT_VERSION = "1.1.1"
27+
COMPONENT_VERSION = "1.2.0"
2828

2929
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
3030
# means "tools" so the pre-existing services entry keeps working across the

custom_components/ha_mcp_tools/manifest.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
"domain": "ha_mcp_tools",
33
"name": "HA-MCP Custom Component",
44
"after_dependencies": [
5-
"http",
5+
"backup",
66
"cloud",
7-
"frontend"
7+
"frontend",
8+
"http",
9+
"lovelace"
810
],
911
"codeowners": [
1012
"@homeassistant-ai"
@@ -19,5 +21,5 @@
1921
"requirements": [
2022
"ruamel.yaml>=0.18.0"
2123
],
22-
"version": "1.1.1"
24+
"version": "1.2.0"
2325
}

custom_components/ha_mcp_tools/websocket_api.py

Lines changed: 2362 additions & 52 deletions
Large diffs are not rendered by default.

src/ha_mcp/backup_manager.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,14 +1296,18 @@ async def _restore_scene(client: Any, entity_id: str, config: Any) -> Any:
12961296
async def _fetch_dashboard(client: Any, entity_id: str) -> Any:
12971297
"""Fetch a dashboard config via the same helper the get tool uses.
12981298
1299-
Delegates to ``tools_config_dashboards._get_dashboard_config_internal``
1300-
which handles the WS envelope, force-cache-bypass, and structured
1301-
error wrapping consistently with how the rest of the dashboard surface
1302-
fetches state. Imported lazily to avoid an import cycle.
1299+
The identifier is pre-resolved to its canonical url_path via the shared
1300+
``_resolve_dashboard`` (component ``list`` when available), then the config is
1301+
read through the component ``get`` (one in-process frame) with a fall back to
1302+
the legacy ``lovelace/config`` read (``_get_dashboard_config_internal``, which
1303+
handles the WS envelope, force-cache-bypass, and structured error wrapping).
1304+
The component refuses YAML bodies, so those capture through legacy unchanged.
1305+
Imported lazily to avoid an import cycle.
13031306
"""
13041307
from fastmcp.exceptions import ToolError
13051308

13061309
from .tools.tools_config_dashboards import (
1310+
_component_dashboard_config,
13071311
_get_dashboard_config_internal,
13081312
_resolve_dashboard,
13091313
)
@@ -1329,6 +1333,12 @@ async def _fetch_dashboard(client: Any, entity_id: str) -> Any:
13291333
err,
13301334
)
13311335

1336+
# Component fast path (freshness-safe in-memory read); None ⇒ legacy below,
1337+
# which also covers YAML dashboards and not-found (nothing to back up).
1338+
component_config = await _component_dashboard_config(client, fetch_path)
1339+
if component_config is not None:
1340+
return component_config
1341+
13321342
try:
13331343
config, _config_hash = await _get_dashboard_config_internal(client, fetch_path)
13341344
except ToolError as err:
@@ -1442,10 +1452,21 @@ def _strip_readonly(config: dict[str, Any], *extra: str) -> dict[str, Any]:
14421452

14431453

14441454
async def _fetch_label(client: Any, entity_id: str) -> Any:
1445-
items = _require_list(
1446-
await _ws_send(client, {"type": "config/label_registry/list"}),
1447-
"config/label_registry/list",
1448-
)
1455+
# Route the capture through the component's ``registries`` capability when
1456+
# available (one in-process read of the label registry) instead of dumping
1457+
# the whole registry via WS. Lazy import to avoid the backup_manager →
1458+
# tools → backup_manager cycle (same pattern as ``_fetch_device``). ``None``
1459+
# from the helper means "component unavailable"; fall back to the full list.
1460+
from .tools.component_registries import fetch_registries_via_component
1461+
1462+
component_result = await fetch_registries_via_component(client, ["label"])
1463+
if component_result is not None:
1464+
items = component_result.get("labels") or []
1465+
else:
1466+
items = _require_list(
1467+
await _ws_send(client, {"type": "config/label_registry/list"}),
1468+
"config/label_registry/list",
1469+
)
14491470
for item in items:
14501471
if item.get("label_id") == entity_id:
14511472
return item
@@ -1466,12 +1487,22 @@ async def _fetch_category(client: Any, entity_id: str) -> Any:
14661487
scope, _, cat_id = entity_id.partition(":")
14671488
if not cat_id:
14681489
return None
1469-
items = _require_list(
1470-
await _ws_send(
1471-
client, {"type": "config/category_registry/list", "scope": scope}
1472-
),
1473-
"config/category_registry/list",
1490+
# Same component-first routing as ``_fetch_label``; categories are scoped,
1491+
# so the requested scope rides ``category_scopes``.
1492+
from .tools.component_registries import fetch_registries_via_component
1493+
1494+
component_result = await fetch_registries_via_component(
1495+
client, ["category"], category_scopes=[scope]
14741496
)
1497+
if component_result is not None:
1498+
items = (component_result.get("categories") or {}).get(scope, [])
1499+
else:
1500+
items = _require_list(
1501+
await _ws_send(
1502+
client, {"type": "config/category_registry/list", "scope": scope}
1503+
),
1504+
"config/category_registry/list",
1505+
)
14751506
for item in items:
14761507
if item.get("category_id") == cat_id:
14771508
return {"scope": scope, **item}
@@ -1607,19 +1638,30 @@ async def _fetch_area_or_floor(client: Any, entity_id: str) -> Any:
16071638
kind, _, real_id = entity_id.partition(":")
16081639
if not real_id:
16091640
return None
1641+
# Same component-first routing as ``_fetch_label`` / ``_fetch_category``.
1642+
from .tools.component_registries import fetch_registries_via_component
1643+
16101644
if kind == "area":
1611-
items = _require_list(
1612-
await _ws_send(client, {"type": "config/area_registry/list"}),
1613-
"config/area_registry/list",
1614-
)
1645+
component_result = await fetch_registries_via_component(client, ["area"])
1646+
if component_result is not None:
1647+
items = component_result.get("areas") or []
1648+
else:
1649+
items = _require_list(
1650+
await _ws_send(client, {"type": "config/area_registry/list"}),
1651+
"config/area_registry/list",
1652+
)
16151653
for item in items:
16161654
if item.get("area_id") == real_id:
16171655
return {"kind": "area", **item}
16181656
elif kind == "floor":
1619-
items = _require_list(
1620-
await _ws_send(client, {"type": "config/floor_registry/list"}),
1621-
"config/floor_registry/list",
1622-
)
1657+
component_result = await fetch_registries_via_component(client, ["floor"])
1658+
if component_result is not None:
1659+
items = component_result.get("floors") or []
1660+
else:
1661+
items = _require_list(
1662+
await _ws_send(client, {"type": "config/floor_registry/list"}),
1663+
"config/floor_registry/list",
1664+
)
16231665
for item in items:
16241666
if item.get("floor_id") == real_id:
16251667
return {"kind": "floor", **item}

src/ha_mcp/tools/backup.py

Lines changed: 149 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,21 @@
3030
MandatoryBackupError,
3131
get_backup_manager,
3232
)
33-
from ..client.rest_client import HomeAssistantClient, HomeAssistantError
34-
from ..client.websocket_client import HomeAssistantWebSocketClient
33+
from ..client.rest_client import (
34+
HomeAssistantClient,
35+
HomeAssistantCommandError,
36+
HomeAssistantCommandTimeout,
37+
HomeAssistantError,
38+
)
39+
from ..client.websocket_client import HomeAssistantWebSocketClient, get_websocket_client
3540
from ..config import get_global_settings
3641
from ..errors import ErrorCode, create_error_response
42+
from .component_api import (
43+
component_supports,
44+
get_component_caps,
45+
invalidate_caps,
46+
is_unknown_command,
47+
)
3748
from .helpers import (
3849
exception_to_structured_error,
3950
get_connected_ws_client,
@@ -89,6 +100,9 @@
89100
# _wait_timeout) can be too tight for a slow or rate-limited remote agent —
90101
# same class of false-timeout problem this file already fixes for creation.
91102
_BACKUP_DELETE_WAIT_S = 60.0
103+
# WS command name is a module-local constant per the component-routing seam
104+
# pattern (see component_api.py / component_devices.py).
105+
WS_BACKUP_PREP = "ha_mcp_tools/backup_prep"
92106

93107

94108
def _get_backup_hint_text() -> str:
@@ -217,6 +231,91 @@ async def _get_backup_password(
217231
return cast(str, default_password)
218232

219233

234+
async def _backup_prep_via_component(
235+
client: HomeAssistantClient,
236+
) -> dict[str, Any] | None:
237+
"""One ``ha_mcp_tools/backup_prep`` read; ``None`` ⇒ run the legacy two-call path.
238+
239+
Returns the component's ``{agent_ids, local_agent_id, default_password}``
240+
payload, replacing the sequential ``backup/agents/info`` (local-agent
241+
discovery, :func:`_get_local_backup_agent_id`) + ``backup/config/info``
242+
(default password, :func:`_get_backup_password`) WS round-trips with one
243+
in-process read. ``None`` on capability miss, downgrade (``unknown_command``
244+
→ invalidate the cached caps), or command error/timeout (logged) — the
245+
caller falls back to the legacy sequential calls. A
246+
``HomeAssistantConnectionError`` (pooled-WS drop) or the plain ``Exception``
247+
``get_websocket_client()`` raises on a failed (re)connect is caught here and
248+
mapped to ``None``: the legacy probes (``_get_local_backup_agent_id`` /
249+
``_get_backup_password``) run on a DEDICATED ``get_connected_ws_client`` socket
250+
already connected before this read, NOT this pooled one — so a wedged pooled
251+
socket must fall back to the legacy calls rather than fail create/restore.
252+
Same caps-gate discipline as ``component_devices.fetch_device_via_component``.
253+
"""
254+
caps = await get_component_caps(client)
255+
if not component_supports(caps, "backup_prep"):
256+
return None
257+
try:
258+
ws = await get_websocket_client(url=client.base_url, token=client.token)
259+
raw = await ws.send_command(WS_BACKUP_PREP)
260+
except (HomeAssistantCommandError, HomeAssistantCommandTimeout) as exc:
261+
if is_unknown_command(exc):
262+
invalidate_caps(client)
263+
else:
264+
logger.warning("%s failed; fell back to legacy: %r", WS_BACKUP_PREP, exc)
265+
return None
266+
except Exception as exc:
267+
# HomeAssistantConnectionError (pooled-WS drop) OR the plain Exception
268+
# get_websocket_client() raises on a failed (re)connect. The legacy probes
269+
# ride a dedicated already-connected socket, so fall back rather than fail
270+
# create/restore on a wedged pooled socket.
271+
logger.warning(
272+
"%s connection error; falling back to legacy: %r", WS_BACKUP_PREP, exc
273+
)
274+
return None
275+
result = raw.get("result")
276+
if not isinstance(result, dict) or "local_agent_id" not in result:
277+
logger.debug(
278+
"%s returned a malformed result (missing 'local_agent_id' key); "
279+
"falling back to legacy",
280+
WS_BACKUP_PREP,
281+
)
282+
return None
283+
return result
284+
285+
286+
def _raise_no_local_backup_agent_error(agent_ids: list[Any] | None) -> NoReturn:
287+
"""Same "no local agent" error ``_get_local_backup_agent_id`` raises.
288+
289+
Used when the ``backup_prep`` component read comes back with
290+
``local_agent_id: None`` — the SAME outcome as the legacy probe finding no
291+
agent named ``"local"``, so both paths fail identically.
292+
"""
293+
raise_tool_error(
294+
create_error_response(
295+
ErrorCode.SERVICE_CALL_FAILED,
296+
"No local backup agent found",
297+
context={"available_agents": [a for a in (agent_ids or []) if a]},
298+
suggestions=[
299+
"Backup creation requires a local agent (hassio.local on "
300+
"Supervised, backup.local on Core); none is registered",
301+
],
302+
)
303+
)
304+
305+
306+
def _raise_no_default_password_error() -> NoReturn:
307+
"""Same error ``_get_backup_password`` raises when no default password is configured."""
308+
raise_tool_error(
309+
create_error_response(
310+
ErrorCode.SERVICE_CALL_FAILED,
311+
"No default backup password configured in Home Assistant",
312+
suggestions=[
313+
"Configure automatic backups in Home Assistant settings to set a default password"
314+
],
315+
)
316+
)
317+
318+
220319
def _parse_backup_date(raw: Any) -> datetime | None:
221320
"""Parse an HA backup `date` (ISO-8601, may use `Z` suffix) to a tz-aware
222321
datetime, returning None on missing/malformed input. Naive timestamps are
@@ -530,13 +629,24 @@ async def create_backup(
530629
)
531630
ws_client = cast(HomeAssistantWebSocketClient, ws_client)
532631

533-
# Get backup password (raises ToolError on failure)
534-
password = await _get_backup_password(ws_client)
632+
# One component read replaces the sequential password + local-agent
633+
# probes when the ha_mcp_tools component supports backup_prep.
634+
prep = await _backup_prep_via_component(client)
635+
if prep is not None:
636+
password = prep.get("default_password")
637+
if not password:
638+
_raise_no_default_password_error()
639+
local_agent = prep.get("local_agent_id")
640+
if not local_agent:
641+
_raise_no_local_backup_agent_error(prep.get("agent_ids"))
642+
else:
643+
# Get backup password (raises ToolError on failure)
644+
password = await _get_backup_password(ws_client)
535645

536-
# Discover the local backup agent at call time. HA Core registers
537-
# `backup.local`; HA Supervised registers `hassio.local`. Hardcoding
538-
# either breaks the other deployment.
539-
local_agent = await _get_local_backup_agent_id(ws_client)
646+
# Discover the local backup agent at call time. HA Core registers
647+
# `backup.local`; HA Supervised registers `hassio.local`. Hardcoding
648+
# either breaks the other deployment.
649+
local_agent = await _get_local_backup_agent_id(ws_client)
540650

541651
# Generate backup name if not provided
542652
if not name:
@@ -768,19 +878,38 @@ async def restore_backup(
768878
)
769879
)
770880

771-
# Discover the local backup agent (Supervisor's hassio.local on
772-
# Supervised, backup.local on Core). Used for both the safety backup
773-
# and the restore call below.
774-
local_agent = await _get_local_backup_agent_id(ws_client)
881+
# One component read replaces the sequential local-agent + password
882+
# probes when the ha_mcp_tools component supports backup_prep.
883+
prep = await _backup_prep_via_component(client)
884+
if prep is not None:
885+
# Local backup agent (Supervisor's hassio.local on Supervised,
886+
# backup.local on Core). Used for both the safety backup and the
887+
# restore call below.
888+
local_agent = prep.get("local_agent_id")
889+
if not local_agent:
890+
_raise_no_local_backup_agent_error(prep.get("agent_ids"))
891+
892+
# Create safety backup BEFORE restoring
893+
logger.info("Creating safety backup before restore...")
894+
password = prep.get("default_password")
895+
if not password:
896+
# No default password - log warning but continue (restore might still work)
897+
logger.warning("No default password - proceeding without safety backup")
898+
password = None
899+
else:
900+
# Discover the local backup agent (Supervisor's hassio.local on
901+
# Supervised, backup.local on Core). Used for both the safety backup
902+
# and the restore call below.
903+
local_agent = await _get_local_backup_agent_id(ws_client)
775904

776-
# Create safety backup BEFORE restoring
777-
logger.info("Creating safety backup before restore...")
778-
try:
779-
password = await _get_backup_password(ws_client)
780-
except ToolError:
781-
# Password error - log warning but continue (restore might still work)
782-
logger.warning("No default password - proceeding without safety backup")
783-
password = None
905+
# Create safety backup BEFORE restoring
906+
logger.info("Creating safety backup before restore...")
907+
try:
908+
password = await _get_backup_password(ws_client)
909+
except ToolError:
910+
# Password error - log warning but continue (restore might still work)
911+
logger.warning("No default password - proceeding without safety backup")
912+
password = None
784913

785914
safety_backup_id, safety_warnings = await _create_safety_backup(
786915
ws_client, password, local_agent, ctx=ctx

0 commit comments

Comments
 (0)