Skip to content

Commit 7125284

Browse files
feat: Phase 1 component-backed read capabilities (states, blueprint bodies, device reads, entity enrichment, exposure) (#1813) (#1895)
* feat: component-backed bulk state reads and full blueprint bodies (#1813) Adds two ha_mcp_tools capabilities to Phase 1 (issue #1813), each behind the established capability gate with a byte-identical legacy fallback. states (A1): ha_get_state resolves single- and bulk-mode reads through one in-process ha_mcp_tools/states frame (State.as_dict() per hit) instead of up to 100 REST GETs. The component read is the shared fetch primitive for both modes; MAX_ENTITIES=100 is enforced server-side regardless of backend; a component- reported missing id is classified through the same path as a legacy 404, so the per-id ENTITY_NOT_FOUND error and the ha_search() suggestion are preserved on both paths. Falls back to the per-id REST fetch silently (logged) when the component is absent, downlevel, or errors. blueprint_get (A2): ha_get_blueprint now returns the full parsed blueprint body under config (core's blueprint/list serves metadata only). The component reads the on-disk file, path-jailed under <config>/blueprints/<domain>/ (symlink-safe containment mirroring the file-tool jail) with the read+parse offloaded to the executor via the async prep. !input markers are preserved as {"__input__": name} and every other custom tag (!secret/!include/...) is neutralized to None at load time, so no resolved secret plaintext can reach the body. The dead if "blueprint" in blueprint_data branch is removed and the overpromising docstring corrected for the capability-miss (metadata-only) path. Component: adds _do_states + _do_blueprint_get (+ prep, jailed reader, blueprint YAML loader), CAPABILITIES entries, _command_specs rows, schemas, and the module docstring inventory. No SCHEMA_VERSION or component version bump (additive). Tests: fake-hass component unit tests, server routing tests, and cross-seam contract tests covering bulk parity, missing-id handling, MAX_ENTITIES, blueprint path-traversal rejection, !secret neutralization, capability-miss and unknown_command fallbacks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: component-backed device reads for get/remove and radio resolves (#1813) Adds the device_get / device_list capabilities to Phase 1 (issue #1813) behind the established capability gate, and routes the single-device consumers that dumped the whole device registry for one lookup through them, each with a byte-identical legacy fallback. Component: _do_device_get returns one DeviceEntry.dict_repr by id ({device: dict_repr | None}); _do_device_list returns them all ({devices: [...]}). The body is core's DeviceEntry.dict_repr returned VERBATIM (never _plainify'd) so the WS transport encodes it with the same JSON serializer config/device_registry/list uses (it sends json_bytes(entry.dict_repr)) — byte-identical to one legacy list element by construction, the device analog of State.as_dict() for the states capability. Adds CAPABILITIES entries, _command_specs rows, schemas, and the module-docstring inventory. No SCHEMA_VERSION or component version bump (additive). Server (shared component_devices helper owns the caps-gated fetch + error taxonomy in one place): - ha_get_device: a single lookup reads just the target via device_get and a list read via device_list, so neither dumps the whole device registry; the entity registry is still read for a device's entity list (no per-device entity capability) and skipped entirely for a summary list. Single-device fallback (no caps / unknown_command) goes straight to the legacy list, not device_list. - ha_remove_device: its body reads the device via device_get; the shared @with_auto_backup capture (backup_manager._fetch_device, also ha_set_device's pre-write snapshot) routes through device_get too — the identical dump fetched twice per remove is gone. - ha_manage_radio: _resolve_ieee (all 7 ZHA call sites share it) resolves the device via device_get. zha/zwave/matter enrichment and config-entry removal stay on their dedicated paths. resolve_update_entity is left on the legacy path deliberately: it reads the ENTITY registry filtered by device_id, which a DeviceEntry-shaped capability cannot serve (that needs an entities-for-device capability, outside item B). Tests: fake-hass component unit tests for both _do_ functions (raw dict_repr verbatim, missing/absent handling, drift-degrades-to-None), schema + admin-gate + info-shape coverage, per-tool routing tests (capability-hit, capability-miss fallback, unknown_command fallback + invalidate_caps, and the auto-backup capture read routing), and one cross-seam contract test that drives the REAL _do_device_get through ha_get_device, ha_remove_device, and _resolve_ieee to pin the raw DeviceEntry shape across every consumer site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: entities-for-device join on device_get for ha_get_device and radio update resolve (#1813) Extends the device_get capability (added in cce7fa0) with an opt-in include_entities join so a single-device lookup lists that device's entities without dumping the whole entity registry. With include_entities the component returns the device's config/entity_registry/list-shaped rows (RegistryEntry.as_partial_dict, disabled included via er.async_entries_for_device(..., include_disabled_entities=True)) as a SIBLING entities key; the raw DeviceEntry dict stays untouched, so the byte-parity contract holds by construction (the join is a sibling, not a field). Component (websocket_api.py): _device_get_schema gains Optional("include_entities", default=False); _do_device_get adds the entities key only when requested via _device_entities / _entries_for_device / _entity_partial_dict (as_partial_dict verbatim, mirroring _device_dict_repr). No SCHEMA_VERSION or component-version bump — additive under the pending 1.1.0 device_get already opened. Server consumers: - ha_get_device single lookup reads the device AND its entities from one device_get(include_entities) frame, killing the full entity-registry dump; entity_id mode now resolves the device via a single native config/entity_registry/get instead of the full entity map; full-detail LIST mode is the only path that still dumps the entity registry. - resolve_update_entity (radio/base.py) reads the device's rows via the join, filtering update.* / platform client-side exactly as before. - device_list stays entity-free. Backward-compat: the join is additive within schema_version 1, so its absence degrades to legacy rather than depending on a version bump. An older device_get predating the param rejects the extra field via the command's PREVENT_EXTRA base schema (mapped to the None miss), and, belt-and-suspenders, a response that carries the device but no entities key is treated the same — fetch_device_entities_via_component and _single_device_and_entities both fall back to config/entity_registry/list for the entity half rather than reporting zero entities. Documented in the component_devices module docstring. Tests: cross-seam contract test pins both raw shapes (DeviceEntry dict_repr + entity as_partial_dict rows) through the real join; ha_get_device routing tests updated for the killed entity dump (hit / entity_id-resolve / miss-fallback / unknown_command+invalidate / entities-half-absent tolerance); new resolve_update_entity routing suite with the same matrix; component unit tests for the include_entities join (schema, disabled-included, omitted-when-not- requested, unknown-device empty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: entity enrichment fields and component-backed exposure reads (#1813) ha_search gains opt-in result_fields= emitting area/floor/labels/aliases on both the component and legacy paths (default shape unchanged); ha_get_entity gains the same fields additively via the entity_enrich capability. ha_get_entity_exposure routes through the new exposure capability with names/areas joined in-process and a targeted single-entity mode, keeping the legacy keys byte-identical and new fields strictly additive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: mypy variable reuse in _all_exposable_entity_ids Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: reuse area-mode haystack registry entries for result_fields enrichment and restore legacy device-registry fetch order The area+query search path fetched config/entity_registry/get_entries twice when result_fields requested enrichment (once for the alias haystack, once inside the enrichment join); the haystack entries map is now threaded through as prefetched entries so the flow costs one call. Raised by Gemini Code Assist review; its proposed fix (dropping the haystack fetch) would have broken alias matching, so the consolidation went the other way. Also restores device-registry-before-entity-registry fetch order in ha_get_device's legacy fallback, which the #1297 error-contract test pins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: surface degraded enrichment and blueprint reads, close review-found gaps (#1813) Partial batch from the pr-review-toolkit round (completed in follow-up): degraded-enrichment warnings, blueprint null-config warning, device-resolve error branching, narrowed unknown-entity match, defensive-path signals, dead KNOWN_ASSISTANTS removal, docstring corrections, device-seam error-taxonomy tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump component to 1.1.1 (1.1.0 shipped stable) Phase 1 capabilities now ride the new pending version; parity test and module docstring inventory updated. MIN_COMPONENT_VERSION stays 1.1.0 (capability-gated, no new required service). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: complete device-seam and exposure error-taxonomy coverage (#1813) Finish the pr-review-toolkit fix batch left partial by the interrupted agent. The behavioral fixes (F1-F6) and doc corrections (D1-D5) landed in the prior commit; this adds the remaining test items and formats that commit's files: - device_get non-unknown error (timeout) falls back to the legacy registries without invalidating caps -- a transient failure is not a downgrade (T1) - fetch_device_list_via_component taxonomy mirrored from device_get: unknown_command -> invalidate caps; non-unknown -> fallback keeps caps; malformed devices shape -> fallback (T2) - device_get "device" key absent shape guard -> None -> legacy, distinct from the authoritative {"device": None} verdict (T4) - exposure single-mode merge with missing entity_info (None) or a stateless entity_info (registry keys only) omits the live-state keys without crashing (T6) Also apply ruff format to the prior partial commit's files (tools_search.py and the blueprint/search-fields tests), which were committed unformatted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address post-ready Codex review findings on PR #1895 Five findings from the Codex bot review of the Phase 1 read-capability seam (issue #1813), each with a matching regression test: - ha_get_state component read: also catch HomeAssistantConnectionError so a WS transport failure falls back to the REST legacy path (a SEPARATE transport) instead of surfacing a spurious connection error. Unlike ha_search/ha_get_overview -- whose legacy paths also read the WS registry and fail identically -- ha_get_state's legacy read is REST get_entity_state (tools_search.py). [P2] - result_fields legacy enrichment: drop the COMPUTED_NAME alias sentinel (serialized null over the WS registry read) instead of str()-casting it to the literal "None", matching the component join's isinstance filter (tools_search.py). [P3] - area+query search: _fetch_area_entity_entries now returns None (not an empty map) on a FAILED prefetch, so a failed read no longer masquerades as an empty-but-successful prefetch that bypasses the degraded-enrichment warning; enrichment re-fetches and reports the degradation (tools_search.py). [P2] - ha_get_device entity resolver: the legacy fallback uses a STRICT entity-registry list read, so a double transient failure surfaces SERVICE_CALL_FAILED instead of misreporting a real entity as ENTITY_NOT_FOUND (tools_registry.py). [P2] - bulk entity_enrich: chunk the WS command to _GET_ENTRIES_CHUNK_SIZE (the same bound the sibling get_entries read uses) so a large bulk request cannot produce an over-cap WebSocket frame; stays all-or-nothing (tools_entities.py). [P2] Byte-parity contracts unchanged; existing contract/routing tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1c68d4 commit 7125284

26 files changed

Lines changed: 6074 additions & 170 deletions

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.0"
27+
COMPONENT_VERSION = "1.1.1"
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919
"requirements": [
2020
"ruamel.yaml>=0.18.0"
2121
],
22-
"version": "1.1.0"
22+
"version": "1.1.1"
2323
}

custom_components/ha_mcp_tools/websocket_api.py

Lines changed: 702 additions & 15 deletions
Large diffs are not rendered by default.

src/ha_mcp/backup_manager.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,12 +1720,23 @@ async def _restore_entity_state(client: Any, entity_id: str, config: Any) -> Any
17201720
# best-effort.
17211721

17221722

1723-
async def _fetch_device(client: Any, entity_id: str) -> Any:
1723+
async def _fetch_device(client: Any, device_id: str) -> Any:
1724+
# Route the single-device capture through the component's ``device_get`` when
1725+
# available (one in-process read of the raw DeviceEntry) instead of dumping the
1726+
# whole registry — the same pre-write snapshot ``ha_set_device`` /
1727+
# ``ha_remove_device`` capture. Lazy import to avoid the backup_manager →
1728+
# tools → backup_manager cycle. ``None`` from the helper means "component
1729+
# unavailable"; fall back to the full-list scan.
1730+
from .tools.component_devices import fetch_device_via_component
1731+
1732+
result = await fetch_device_via_component(client, device_id)
1733+
if result is not None:
1734+
return result.get("device")
17241735
items = await _ws_send(client, {"type": "config/device_registry/list"})
17251736
if not isinstance(items, list):
17261737
return None
17271738
for item in items:
1728-
if item.get("id") == entity_id:
1739+
if item.get("id") == device_id:
17291740
return item
17301741
return None
17311742

src/ha_mcp/client/rest_client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,7 @@ async def send_websocket_message(self, message: dict[str, Any]) -> dict[str, Any
14451445
return {
14461446
"success": False,
14471447
"error": f"WebSocket request blocked (403 Forbidden): {error_str}",
1448+
"error_code": getattr(e, "code", None),
14481449
"suggestions": [
14491450
"This may be caused by a reverse proxy or security filter",
14501451
"Try simplifying the request (e.g., shorter templates, fewer parameters)",
@@ -1457,7 +1458,14 @@ async def send_websocket_message(self, message: dict[str, Any]) -> dict[str, Any
14571458
# Unknown command." is undiagnosable from a user's log
14581459
# (issue #1889 took a live reproduction to attribute).
14591460
logger.error(f"WebSocket message failed ({message.get('type')}): {e}")
1460-
return {"success": False, "error": str(e)}
1461+
# Preserve HA's structured error code (e.g. ``not_found``) so callers
1462+
# can distinguish HA's authoritative verdict from a transient failure
1463+
# instead of only seeing the stringified message.
1464+
return {
1465+
"success": False,
1466+
"error": str(e),
1467+
"error_code": getattr(e, "code", None),
1468+
}
14611469

14621470
return {"success": False, "error": "WebSocket request failed"}
14631471

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Shared device-registry reads over the ``ha_mcp_tools`` component gate.
2+
3+
Several tools resolve a device the same wasteful way today — pull the ENTIRE
4+
device registry and filter for one entry (``ha_get_device`` single lookup,
5+
``ha_remove_device``'s body, the ``@with_auto_backup`` capture read, and
6+
``ha_manage_radio``'s ``_resolve_ieee``). When the component advertises the
7+
``device_get`` / ``device_list`` capabilities, that whole-registry dump becomes a
8+
single in-process read: ``device_get`` returns one ``DeviceEntry.dict_repr`` by
9+
id, ``device_list`` returns them all — each byte-identical to a
10+
``config/device_registry/list`` element by construction (see
11+
``custom_components/ha_mcp_tools/websocket_api.py``). Consumers keep their own
12+
transforms over that raw shape.
13+
14+
This module owns the caps-gated fetch so the routing discipline — probe caps,
15+
send one frame, invalidate on ``unknown_command``, fall back to the legacy path
16+
on any component error — lives in one place instead of being duplicated per
17+
consumer (the pattern ``tools_search._fetch_states_via_component`` established
18+
for the ``states`` capability). Both helpers return ``None`` to mean "component
19+
unavailable — use the legacy path"; a component that answers authoritatively
20+
returns its payload (with ``device`` possibly ``None`` for "no such device").
21+
22+
``device_get`` also carries an opt-in per-device entity join
23+
(:func:`fetch_device_entities_via_component`): with ``include_entities`` the
24+
component returns the device's ``config/entity_registry/list``-shaped rows as a
25+
SIBLING ``entities`` key, so a single-device lookup no longer dumps the whole
26+
entity registry to list one device's entities. The join is additive within
27+
schema_version 1, so the server tolerates its absence rather than depending on a
28+
version bump. An older ``device_get`` that predates the param never round-trips
29+
the entities half — the extra field is rejected by the command's
30+
``PREVENT_EXTRA`` base schema, surfacing as an error that maps to the ``None``
31+
miss — and, belt-and-suspenders, a response that carries the device but no
32+
``entities`` key is treated the same: fall back to the legacy
33+
``config/entity_registry/list`` for the entity half. Neither breaks the call.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import logging
39+
from typing import Any
40+
41+
from ..client.rest_client import (
42+
HomeAssistantCommandError,
43+
HomeAssistantCommandTimeout,
44+
)
45+
from ..client.websocket_client import get_websocket_client
46+
from .component_api import (
47+
component_supports,
48+
get_component_caps,
49+
invalidate_caps,
50+
is_unknown_command,
51+
)
52+
53+
logger = logging.getLogger(__name__)
54+
55+
WS_DEVICE_GET = "ha_mcp_tools/device_get"
56+
WS_DEVICE_LIST = "ha_mcp_tools/device_list"
57+
58+
59+
async def fetch_device_via_component(
60+
client: Any, device_id: str, *, include_entities: bool = False
61+
) -> dict[str, Any] | None:
62+
"""One ``ha_mcp_tools/device_get`` read; ``None`` ⇒ use the legacy path.
63+
64+
Returns the component's ``{"device": <raw dict> | None}`` payload (the raw
65+
``DeviceEntry.dict_repr`` for the id, byte-identical to a
66+
``config/device_registry/list`` element) or ``None`` when the component lacks
67+
the ``device_get`` capability, was downgraded (``unknown_command`` →
68+
invalidate the cached caps), or errored (logged). A component that answers
69+
with ``{"device": None}`` is authoritative — the device does not exist — so
70+
the caller must distinguish that from a ``None`` return (which means "component
71+
unavailable, fall back"). Falls back **silently**, mirroring
72+
``ha_get_state``: the legacy path returns the byte-identical device either way
73+
and the ``log.warning`` preserves operator visibility. A
74+
``HomeAssistantConnectionError`` (WS down) is not caught here, so it
75+
propagates to the caller's own error handling — the legacy path shares the
76+
same socket and would fail identically.
77+
78+
With ``include_entities`` the payload also carries a sibling ``entities`` list
79+
(the device's ``config/entity_registry/list``-shaped rows), so a single-device
80+
lookup that needs the device's entities skips the whole-entity-registry dump.
81+
``include_entities`` is only sent when true, so the frames of callers that need
82+
only the device (``_resolve_ieee`` / capture / remove) are unchanged.
83+
"""
84+
caps = await get_component_caps(client)
85+
if not component_supports(caps, "device_get"):
86+
return None
87+
kwargs: dict[str, Any] = {"device_id": device_id}
88+
if include_entities:
89+
kwargs["include_entities"] = True
90+
try:
91+
ws = await get_websocket_client(url=client.base_url, token=client.token)
92+
raw = await ws.send_command(WS_DEVICE_GET, **kwargs)
93+
except (HomeAssistantCommandError, HomeAssistantCommandTimeout) as exc:
94+
if is_unknown_command(exc):
95+
invalidate_caps(client)
96+
else:
97+
logger.warning("%s failed; fell back to legacy: %r", WS_DEVICE_GET, exc)
98+
return None
99+
result = raw.get("result")
100+
if not isinstance(result, dict) or "device" not in result:
101+
return None
102+
return result
103+
104+
105+
async def fetch_device_entities_via_component(
106+
client: Any, device_id: str
107+
) -> list[dict[str, Any]] | None:
108+
"""The device's entity rows via ``device_get(include_entities=True)``; ``None`` ⇒ legacy.
109+
110+
Returns the ``config/entity_registry/list``-shaped rows bound to ``device_id``
111+
(disabled included) so a per-device entity lookup avoids the whole-registry
112+
dump. Returns ``None`` — the caller falls back to
113+
``config/entity_registry/list`` — when the component can't serve ``device_get``
114+
at all, OR when it served the device but the response carries no ``entities``
115+
key (an older ``device_get`` predating ``include_entities``, or any component
116+
that does not round-trip the entities half): the entity join is additive, so
117+
its absence degrades to legacy rather than silently reporting zero entities.
118+
An AUTHORITATIVE empty result — the component honored ``include_entities`` and
119+
the device has no entities, or no such device — comes back as a present empty
120+
list, kept distinct from that ``None`` miss.
121+
"""
122+
result = await fetch_device_via_component(client, device_id, include_entities=True)
123+
if result is None:
124+
return None
125+
entities = result.get("entities")
126+
return entities if isinstance(entities, list) else None
127+
128+
129+
async def fetch_device_list_via_component(client: Any) -> dict[str, Any] | None:
130+
"""One ``ha_mcp_tools/device_list`` read; ``None`` ⇒ use the legacy path.
131+
132+
Returns the component's ``{"devices": [<raw dict>, ...]}`` payload (each a raw
133+
``DeviceEntry.dict_repr``, the in-process equivalent of
134+
``config/device_registry/list``) or ``None`` on capability miss, downgrade
135+
(``unknown_command`` → invalidate caps), or error (logged) — same
136+
error-taxonomy and silent fallback as :func:`fetch_device_via_component`.
137+
"""
138+
caps = await get_component_caps(client)
139+
if not component_supports(caps, "device_list"):
140+
return None
141+
try:
142+
ws = await get_websocket_client(url=client.base_url, token=client.token)
143+
raw = await ws.send_command(WS_DEVICE_LIST)
144+
except (HomeAssistantCommandError, HomeAssistantCommandTimeout) as exc:
145+
if is_unknown_command(exc):
146+
invalidate_caps(client)
147+
else:
148+
logger.warning("%s failed; fell back to legacy: %r", WS_DEVICE_LIST, exc)
149+
return None
150+
result = raw.get("result")
151+
if not isinstance(result, dict) or not isinstance(result.get("devices"), list):
152+
return None
153+
return result

src/ha_mcp/tools/radio/base.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from typing import Any
2222

2323
from ...errors import ErrorCode, create_error_response
24+
from ..component_devices import fetch_device_entities_via_component
2425
from ..helpers import raise_tool_error
2526

2627
logger = logging.getLogger(__name__)
@@ -107,10 +108,18 @@ async def resolve_update_entity(
107108
Filters the ``update.*`` entities tied to ``device_id``, preferring the given
108109
``platform`` (e.g. "matter", "zha", "zwave_js") when a device exposes more
109110
than one. Raises ENTITY_NOT_FOUND when the device exposes no update entity.
111+
112+
Routes the per-device entity read through the component's
113+
``device_get(include_entities=True)`` when available (the device's rows in one
114+
in-process frame, ``config/entity_registry/list`` shape) instead of dumping the
115+
whole entity registry; falls back to ``config/entity_registry/list`` otherwise.
116+
The ``update.*`` / platform filtering stays client-side exactly as before.
110117
"""
111-
entities = await ws_call(
112-
client, "config/entity_registry/list", context={"device_id": device_id}
113-
)
118+
entities = await fetch_device_entities_via_component(client, device_id)
119+
if entities is None:
120+
entities = await ws_call(
121+
client, "config/entity_registry/list", context={"device_id": device_id}
122+
)
114123
candidates = [
115124
e
116125
for e in (entities or [])

src/ha_mcp/tools/radio/zigbee.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from fastmcp.exceptions import ToolError
2020

2121
from ...errors import ErrorCode, create_error_response
22+
from ..component_devices import fetch_device_via_component
2223
from ..helpers import raise_tool_error
2324
from .base import (
2425
ActionSpec,
@@ -123,17 +124,40 @@
123124
}
124125

125126

127+
async def _resolve_ieee_devices(client: Any, device_id: Any) -> list[dict[str, Any]]:
128+
"""The device rows ``_resolve_ieee`` scans — component ``device_get`` or legacy.
129+
130+
Returns just the one matching device (``[device]``, or ``[]`` when it does not
131+
exist) via ``ha_mcp_tools/device_get`` when the component serves it, else the
132+
whole ``config/device_registry/list`` for the caller to filter. Only a
133+
non-empty string ``device_id`` is eligible for the component read; anything
134+
else takes the legacy path (and is rejected by the caller's scan).
135+
"""
136+
if isinstance(device_id, str) and device_id:
137+
result = await fetch_device_via_component(client, device_id)
138+
if result is not None:
139+
device = result.get("device")
140+
return [device] if isinstance(device, dict) else []
141+
devices = await ws_call(
142+
client, "config/device_registry/list", context={"device_id": device_id}
143+
)
144+
return list(devices or [])
145+
146+
126147
async def _resolve_ieee(client: Any, device_id: Any) -> str:
127148
"""Resolve a ``device_id`` to its ZHA IEEE address via the device registry.
128149
129150
Parses the ``["zha", "<ieee>"]`` registry identifier (falling back to an
130151
``("ieee", ...)`` connection). Raises VALIDATION_INVALID_PARAMETER when the
131152
device is not a ZHA device.
153+
154+
Routes the single-device lookup through the component's ``device_get`` when
155+
available (one in-process read of the raw ``DeviceEntry`` shape) instead of
156+
dumping the whole device registry; falls back to
157+
``config/device_registry/list`` when the component can't serve it.
132158
"""
133-
devices = await ws_call(
134-
client, "config/device_registry/list", context={"device_id": device_id}
135-
)
136-
for device in devices or []:
159+
devices = await _resolve_ieee_devices(client, device_id)
160+
for device in devices:
137161
if device.get("id") != device_id:
138162
continue
139163
for ident in device.get("identifiers", []):

0 commit comments

Comments
 (0)