Skip to content

Commit 6f63f68

Browse files
feat: Matter & Thread support + unified ha_manage_radio management tool (#1696)
* feat: Matter node diagnostics + ha_manage_radio scaffold (#1693) ha_get_device now enriches Matter devices with node_diagnostics (network type wifi/thread, reachability, IPs, joined fabrics), mirroring the existing Z-Wave node_status / ZHA radio-metrics enrichers. New ha_manage_radio multi-modal tool with a per-radio handler package (radio/). Matter handler is complete: diagnostics, ping, network_status, commission, commission_on_network, share_out (open_commissioning_window), interview, remove_fabric (auto-resolves this HA's fabric), and Thread/WiFi credential provisioning. zwave/zigbee/thread handlers are scaffolded behind the shared dispatch contract (base.py) pending implementation. Unit tests cover the Matter enricher (incl. graceful degradation) and the dispatcher contract (action validation, required-param + destructive-confirm gates, entity_id resolution). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: implement zwave/zigbee/thread radio handlers + matter firmware + network-health includes (#1693) Completes ha_manage_radio across all four radios: - Z-Wave (10 actions): diagnostics, network_status, ping, add (SmartStart/QR non-interactive; interactive S2 PIN pairing -> HA UI), remove_device (failed/exclusion), reinterview, rebuild_routes, set_config_param, firmware_update (update.install), hard_reset - Zigbee/ZHA (18 actions): diagnostics, network_status, permit_join, remove_device, reconfigure, group + member CRUD, bind/unbind, cluster read/write/command, network backup/restore, change_channel, firmware_update - Thread/OTBR (7 actions): network_status, list_datasets, discover_routers, create_network, set_network, set_channel, add_dataset - Matter: add firmware_update for parity (update.install on the node's update entity) ha_get_system_health gains matter_network + thread_network include sections (otbr/info + matter config-entry summary), mirroring zwave_network/zha_network. WS command strings verified against HA core; service-only ZHA ops and update.install go through client.call_service. Destructive actions gated on confirm=True; long-running ops return operation handles. 84 unit tests across the dispatcher and all four handlers (mock-WS; pytest runs in CI per the on-device bionic-wheel limitation). Also fixes a base.py import depth (..errors -> ...errors) that would have broken radio-package import in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): graceful-degradation + validation e2e for ha_manage_radio (#1693) Bare-container e2e (no real radios): asserts ha_manage_radio is registered, the validation gates fire (unknown action lists supported, missing required param, destructive-without-confirm), zwave/thread network_status degrade to available=False, and ha_get_system_health exposes the thread_network/ matter_network includes without crashing. Deep behavior stays unit-tested (mock-WS) + user live-test post-merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: mypy strictness + CodeQL empty-except in radio handlers (#1696) - base.resolve_entry_id returns str|None (str-cast the registry value) - base.confirm_required typed -> None (drop unused type:ignore) - zigbee/matter id-resolve helpers accept Any (args are dynamically typed); rename optional permit_join ieee to avoid a str/str|None reassignment - tools_radio dispatcher returns a typed dict - annotate the best-effort zha/topology/update except (CodeQL py/empty-except) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: drop now-unused ToolError import in radio/base.py (#1696) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): address PR review findings (#1696) - BUG: Matter node_diagnostics dropped IPs — upstream NodeDiagnostics misspells the field 'ip_adresses' (one d); read the real key, surface it as ip_addresses. The test mock hid this; mock + assertion corrected. - Surface each action's summary in the unknown-action error (ActionSpec.summary was dead and its docstring over-claimed); makes the field load-bearing. - ActionSpec.long_running is now the single source of truth — the dispatcher fills it in, covering branches that omitted it (e.g. force-remove). - Drop the dispatcher's manual logger.error: exception_to_structured_error owns logging and was being double-logged. - Gate thread set_network with confirm=True (it replaces the active Thread network and can drop devices), matching create_network/set_channel. - Align the 'no update entity' error to ENTITY_NOT_FOUND across zwave/zigbee/ matter (was three different codes). - Log the best-effort zha/topology/update failure at debug instead of bare pass. - Remove dead RADIOS constant; fix docstrings that over-claimed (no operation 'handle'; ping is not in the read tools; service calls are not ZHA-only; config_param is not a real param; thread summary is a list not keyed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hacs): retry repo registration once on timeout in ha_install_mcp_tools A freshly-added HACS repo can miss the first registration budget on a loaded runner or slow GitHub (intermittently flakes test_install_mcp_tools_*). On that timeout, re-add to re-nudge HACS and wait once more before surfacing the failure. Best-effort — a genuinely stuck add still fails on the retry; a repo that already had an ID is re-raised immediately as a real failure. Happy path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(radio): cover untested handler bodies + SUPPORTED↔handle round-trip (#1696) Adds success-path record-and-assert tests for the Matter write actions (commission, commission_on_network, interview, set_thread, set_wifi_credentials, network_status both branches, firmware no-entity) and the Zigbee group/restore/member/cluster actions, plus a dispatcher-contract suite: a ws_call failure surfaces ToolError, every SUPPORTED action across all four radios is reachable by handle() (drift guard), and entity_id-not-found resolves to ENTITY_NOT_FOUND. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): keep unknown-action 'supported' a list, summaries in suggestions (#1696) The summary-surfacing change made context['supported'] a dict, breaking the e2e assertion that it is a list. Keep 'supported' as the sorted action-name list (stable shape) and surface each action's summary via 'suggestions' instead, so the summary field is still load-bearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(radio): pass confirm=True for now-destructive set_network + add its gate test (#1696) Making set_network destructive (review fix) required confirm=True; the two existing set_network success tests omitted it and started failing the confirm gate. Add confirm=True to both and a dedicated set_network_requires_confirm test. Verified locally: 211 radio unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): normalize matter diagnostics ip_adresses->ip_addresses in handler (#1696) Live testing showed ha_manage_radio(matter, diagnostics) returned the raw upstream 'ip_adresses' typo while ha_get_device surfaced the corrected 'ip_addresses' — inconsistent. Normalize in the handler too (copying the dict so the upstream result isn't mutated). Verified live + 40 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): remove_fabric requires explicit other-controller index, not HA's own (#1696) Live testing revealed that defaulting remove_fabric to HA's own active fabric makes the Matter server return 'Operation aborted' — removing the active fabric severs HA's session before the confirmation returns (the device DOES detach, but reports as an error). The clean detach-from-HA path is deleting the device. remove_fabric now requires params.fabric_index for a DIFFERENT controller and refuses HA's own/active index with actionable guidance to use ha_remove_device, listing active_fabrics so the caller can pick. Verified locally (26 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): narrow zigbee topology-refresh except to ToolError (Gemini review, #1696) Gemini flagged the best-effort zha/topology/update catching broad Exception. Narrow to ToolError: ws_call normalizes transport failures to a ToolError, so catching just that swallows a command-level topology failure (debug-logged) while letting unexpected programmer errors propagate. The other Gemini point (redundant dispatcher logger.error) was already removed in f5d3826. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): address Patch76 review — write-action legibility + DRY + guards (#1696) - Write/management actions now RAISE (integration_required) when the integration isn't configured, instead of returning the graceful success:true/available:false payload — an agent keying on success could otherwise read a no-op management action as done. Read actions (network_status) still degrade gracefully. Affects zwave add/remove_device(exclusion)/rebuild_routes(network)/hard_reset and thread create/set_network/set_channel. - DRY: fold the three near-duplicate update.* firmware-entity resolvers into base.resolve_update_entity(client, device_id, platform=...). - zwave add: enforce exactly-one of the mutually-exclusive inclusion credentials (HA add_node uses vol.Exclusive) with a clear conflict error. - Remove 4 dead 'raise AssertionError' lines sitting after NoReturn raise_tool_error calls (kept the intentional end-of-handler unhandled guards). Verified locally: 207 radio unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: restore py/mixed-returns terminals after NoReturn raise_tool_error (#1696) CI CodeQL (py/mixed-returns) failed at zigbee.py and tools_radio.py: removing the trailing 'raise AssertionError' (a Patch76 nit, applied last commit) left an implicit None fall-through mixed with explicit str returns. Those terminals are NOT dead code — they exist to satisfy the gate (same as the documented 'return None # py/mixed-returns' terminals elsewhere in the repo). Restored in all three resolvers with an explanatory comment so they aren't stripped again. My local CodeQL check only covers implicit-string-concat-in-list, so it missed this; the CI gate is authoritative. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): address Patch76 follow-up — zwave write coverage, firmware parity, cluster_command docs (#1696) - Add per-action integration-absent tests for the zwave write actions (add, remove_device exclusion path, rebuild_routes network scope, hard_reset) that raise integration_required when zwave_js is unconfigured — mirrors thread's test_set_network_no_otbr_raises; the read path was the only one covered. - Align zwave/zigbee firmware_update TO matter (drop the captured result), not the reverse. update.install registers with no supports_response, so call_service returns only the transient affected-states (the entity flipping to in_progress), not the async install outcome; surfacing it on a long_running install misreads as the result. All three now return entity_id + long_running. - cluster_command ActionSpec now steers callers to params (HA deprecated args in favor of params; both still accepted), forwarding unchanged for back-compat. Verified locally: 88 radio unit 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 27c804b commit 6f63f68

16 files changed

Lines changed: 4094 additions & 6 deletions

src/ha_mcp/tools/radio/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Per-radio handler modules for the ``ha_manage_radio`` tool.
2+
3+
Each radio (Z-Wave, Zigbee/ZHA, Matter, Thread) gets its own handler module so
4+
no single file spans every protocol (keeps modules focused per AGENTS.md). The
5+
``tools_radio`` module wires them together behind one MCP tool.
6+
"""

src/ha_mcp/tools/radio/base.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Shared contract and helpers for ``ha_manage_radio`` per-radio handlers.
2+
3+
A handler module exposes:
4+
5+
- ``SUPPORTED``: ``dict[action_name, ActionSpec]`` describing every action it
6+
implements, used for validation, the destructive-confirm gate, and building
7+
actionable "unsupported action" errors.
8+
- ``async def handle(client, action, args) -> dict``: execute one action.
9+
10+
Handlers send raw WebSocket commands through the REST client's
11+
``send_websocket_message`` bridge (same path the ``ha_get_device`` enrichers
12+
use), or call services via ``call_service`` for the operations that are
13+
service-only (most ZHA writes, plus ``zwave_js.ping`` and ``update.install``
14+
firmware installs).
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from dataclasses import dataclass, field
21+
from typing import Any
22+
23+
from ...errors import ErrorCode, create_error_response
24+
from ..helpers import raise_tool_error
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
@dataclass(frozen=True)
30+
class ActionSpec:
31+
"""Metadata for one radio action.
32+
33+
summary: one-line description (surfaced in unsupported-action errors).
34+
destructive: requires ``confirm=True`` before it will run.
35+
long_running: starts an operation that completes out-of-band (inclusion,
36+
rebuild routes, firmware) — the result documents how to follow up.
37+
required: parameter names that must be present in ``args`` (or supplied as
38+
the top-level ``device_id``).
39+
"""
40+
41+
summary: str
42+
destructive: bool = False
43+
long_running: bool = False
44+
required: tuple[str, ...] = field(default_factory=tuple)
45+
46+
47+
def ok(radio: str, action: str, **data: Any) -> dict[str, Any]:
48+
"""Build the standard success envelope for a radio action."""
49+
return {"success": True, "radio": radio, "action": action, **data}
50+
51+
52+
def require(args: dict[str, Any], spec: ActionSpec, radio: str, action: str) -> None:
53+
"""Raise VALIDATION_INVALID_PARAMETER if any required arg is missing/empty."""
54+
missing = [k for k in spec.required if args.get(k) in (None, "")]
55+
if missing:
56+
raise_tool_error(
57+
create_error_response(
58+
ErrorCode.VALIDATION_INVALID_PARAMETER,
59+
f"{radio}/{action} requires: {', '.join(missing)}",
60+
context={"radio": radio, "action": action, "missing": missing},
61+
suggestions=[f"Pass {m} (in params or as device_id)" for m in missing],
62+
)
63+
)
64+
65+
66+
async def ws_call(
67+
client: Any, ws_type: str, *, context: dict[str, Any] | None = None, **fields: Any
68+
) -> Any:
69+
"""Send a WebSocket command and return its ``result``; raise on failure.
70+
71+
Mirrors the ``send_websocket_message`` usage in the ``ha_get_device``
72+
enrichers. Raises ToolError (SERVICE_CALL_FAILED) when HA reports the
73+
command failed, attaching the command type and any caller context.
74+
"""
75+
message = {"type": ws_type, **{k: v for k, v in fields.items() if v is not None}}
76+
result = await client.send_websocket_message(message)
77+
if not result.get("success"):
78+
raise_tool_error(
79+
create_error_response(
80+
ErrorCode.SERVICE_CALL_FAILED,
81+
result.get("error", f"WebSocket command '{ws_type}' failed"),
82+
context={"ws_type": ws_type, **(context or {})},
83+
)
84+
)
85+
return result.get("result")
86+
87+
88+
async def resolve_entry_id(client: Any, domain: str) -> str | None:
89+
"""Return the config entry_id for a single-instance integration ``domain``.
90+
91+
Uses ``config_entries/get`` (underscore form; the slash form is rejected as
92+
"Unknown command"). Returns None when the integration is not configured.
93+
"""
94+
entries = await ws_call(client, "config_entries/get", context={"domain": domain})
95+
for entry in entries or []:
96+
if entry.get("domain") == domain:
97+
entry_id = entry.get("entry_id")
98+
return str(entry_id) if entry_id is not None else None
99+
return None
100+
101+
102+
async def resolve_update_entity(
103+
client: Any, device_id: Any, *, platform: str | None = None
104+
) -> str:
105+
"""Return the device's ``update.*`` firmware entity_id from the registry.
106+
107+
Filters the ``update.*`` entities tied to ``device_id``, preferring the given
108+
``platform`` (e.g. "matter", "zha", "zwave_js") when a device exposes more
109+
than one. Raises ENTITY_NOT_FOUND when the device exposes no update entity.
110+
"""
111+
entities = await ws_call(
112+
client, "config/entity_registry/list", context={"device_id": device_id}
113+
)
114+
candidates = [
115+
e
116+
for e in (entities or [])
117+
if e.get("device_id") == device_id
118+
and str(e.get("entity_id", "")).startswith("update.")
119+
]
120+
if platform is not None:
121+
for entity in candidates:
122+
if entity.get("platform") == platform:
123+
return str(entity["entity_id"])
124+
if candidates:
125+
return str(candidates[0]["entity_id"])
126+
raise_tool_error(
127+
create_error_response(
128+
ErrorCode.ENTITY_NOT_FOUND,
129+
f"No update entity found for device {device_id}; no firmware update is available",
130+
context={"device_id": device_id},
131+
suggestions=[
132+
"Firmware updates appear only when the device exposes an update.* entity",
133+
"Check ha_get_device for an 'update.' entity on this device",
134+
],
135+
)
136+
)
137+
raise AssertionError # py/mixed-returns terminal: raise_tool_error is NoReturn
138+
139+
140+
def integration_not_found(radio: str, domain: str) -> dict[str, Any]:
141+
"""Standard degraded payload when an integration/config-entry is absent.
142+
143+
For read-only actions (``network_status``) only — reporting the absent
144+
integration as a graceful ``success: True`` degradation is correct there.
145+
Write/management actions must call ``integration_required`` instead so an
146+
unconfigured integration surfaces as an error rather than a silent no-op.
147+
"""
148+
return {
149+
"success": True,
150+
"radio": radio,
151+
"available": False,
152+
"warnings": [f"{domain} integration is not configured on this Home Assistant"],
153+
}
154+
155+
156+
def integration_required(radio: str, domain: str) -> None:
157+
"""Raise a ToolError when a write action's integration/config-entry is absent.
158+
159+
The write-action counterpart to ``integration_not_found``: management actions
160+
must not report a no-op as ``success: True``, so this raises instead of
161+
degrading to an ``available: False`` payload.
162+
"""
163+
raise_tool_error(
164+
create_error_response(
165+
ErrorCode.SERVICE_CALL_FAILED,
166+
f"{domain} integration is not configured on this Home Assistant; "
167+
f"cannot run this {radio} management action.",
168+
context={"radio": radio, "domain": domain, "available": False},
169+
suggestions=[f"Install/configure the {domain} integration first"],
170+
)
171+
)
172+
173+
174+
def confirm_required(radio: str, action: str) -> None:
175+
"""Raise the ToolError for a destructive action lacking confirm=True."""
176+
raise_tool_error(
177+
create_error_response(
178+
ErrorCode.VALIDATION_INVALID_PARAMETER,
179+
f"{radio}/{action} is destructive; pass confirm=True to proceed",
180+
context={"radio": radio, "action": action, "destructive": True},
181+
suggestions=["Re-run with confirm=True once you intend the change"],
182+
)
183+
)

0 commit comments

Comments
 (0)