Skip to content

Commit 8f819ee

Browse files
feat: align validator, traces, and update tools with HA 2026.7 (#1735)
* feat: align validator, traces, and update tools with HA 2026.7 (#1726) - best_practice_checker: flag the ten purpose-specific trigger/condition keys renamed in 2026.7 (old keys no longer load) with the new key named inline; flag deprecated trigger options.behavior values any/last (renamed to each/all, repair-issue parity; conditions keep any/all); device-trigger warning now names purpose-specific triggers with targets as the preferred replacement - ha_config_set_automation: document purpose-specific trigger/condition config shape as valid input - traces: stop dropping per-step error fields on condition and trigger steps -- HA 2026.7 always records template errors in traces (core #172917) and numeric entity trigger errors (core #175093) - domain_handlers: device_tracker gains in_zones and tracking_type (2026.7 attributes); lat/long may be absent under presence-scanner tracking - new ha_manage_updates tool: batch install by category mirroring the 2026.7 Update-all UX (core/OS/supervisor excluded from category mode by design, skipped updates never included), plus skip/clear_skipped; explicit-id mode verifies entities exist (HA service calls no-op silently on unknown entities); per-item batch failure results - unit tests for all new checker rules; e2e tests for ha_manage_updates validation and batch semantics Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: merge ha_get_updates into ha_manage_updates with Read Only Mode wiring Tool consolidation per AGENTS.md: one update tool with action= list (default) | get | install | skip | clear_skipped. ha_get_updates is removed (not deprecated); list/get behavior, release-notes modes, and the 404 mapping carry over unchanged. All docstring, suggestion, and test references swept (README/DOCS regenerate via sync-tool-docs). Read Only Mode: ha_manage_updates joins READ_ONLY_EXEMPT_TOOLS with an _updates_write classifier — list/get stay visible and callable while the mode is on; install/skip/clear_skipped are blocked at call time (fail-closed for unknown actions). Exempt-table pin test and classifier parametrize coverage added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard renamed-trigger-key lookup against non-str platform (mypy) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: annotation convention, drift-guard manifests, fatal-error propagation (review round 1) - ha_manage_updates: destructiveHint True per the exactly-one-hint convention (test_tool_annotations); installs are not reversible - test_read_only.py: add ha_manage_updates to all three schema-drift manifests (module, inspected args, gated-or-read partition) - batch install loop: HomeAssistantConnectionError/HomeAssistantAuthError now propagate instead of collecting N identical per-item failures (Gemini review); per-item collect stays broad for service-level errors Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: dispatch happy-path and trace error-propagation coverage - mocked-client unit tests for category resolution + install dispatch (can_install filter, backup flag), mixed exist/missing aggregation with per-item ENTITY_NOT_FOUND, and connection-error propagation out of the batch loop - _format_detailed_trace tests for per-step error surfacing on condition and trigger steps (2026.7 always-recorded template errors) plus the no-error negative Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: derive batch success from per-item outcomes Top-level success was hardcoded True even with zero succeeded items — an all-failed batch read as success to an LLM branching on the flag. Now failed == 0 (all-pass, zero-requested counts as success), matching the aggregate convention in tools_entities/tools_energy. Tests assert the aggregate flag on the mixed and all-missing paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: assert context key at the error-response top level in mixed-batch test create_error_response spreads context keys at the response's top level, not under error.context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6172dd0 commit 8f819ee

14 files changed

Lines changed: 806 additions & 101 deletions

src/ha_mcp/read_only.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ def _custom_tool_write(args: dict[str, Any]) -> str | None:
131131
return "sandbox code execution"
132132

133133

134+
def _updates_write(args: dict[str, Any]) -> str | None:
135+
# ``action`` defaults to "list" at the schema layer, so a missing action
136+
# executes as a read (the middleware sees RAW pre-validation arguments —
137+
# an absent key here means the tool itself will run the list branch).
138+
action = args.get("action")
139+
if action in (None, "list", "get"):
140+
return None
141+
return f"action={action!r}"
142+
143+
134144
def _radio_write(args: dict[str, Any]) -> str | None:
135145
action = args.get("action")
136146
# Reads (allowed): per-node diagnostics, the integration/network summary,
@@ -200,6 +210,14 @@ def _radio_write(args: dict[str, Any]) -> str | None:
200210
"cluster-attribute read ('cluster_read'), and the Thread dataset "
201211
"listing ('list_datasets')",
202212
),
213+
# Update listing/details exist only here — ha_get_updates was merged
214+
# into this tool (issue #1726), so hiding it would remove the read
215+
# surface entirely.
216+
"ha_manage_updates": ReadOnlyExemption(
217+
_updates_write,
218+
"listing pending updates (action='list', the default) and reading "
219+
"update details/release notes (action='get')",
220+
),
203221
}
204222

205223

src/ha_mcp/tools/best_practice_checker.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ def __init__(self, items: list[str] | None = None) -> None:
162162
# ``service:`` (legacy) and ``action:`` (modern, 2024+) for the same field.
163163
_SERVICE_KEYS = ("service", "action")
164164

165+
# Purpose-specific trigger/condition keys renamed in HA 2026.7 — the old keys
166+
# no longer load (home-assistant/core#174463).
167+
_RENAMED_TRIGGER_KEYS = {
168+
"battery.low": "battery.became_low",
169+
"battery.not_low": "battery.no_longer_low",
170+
"lawn_mower.docked": "lawn_mower.returned_to_dock",
171+
"schedule.turned_on": "schedule.block_started",
172+
"schedule.turned_off": "schedule.block_ended",
173+
"timer.time_remaining": "timer.remaining_time_reached",
174+
"update.update_became_available": "update.became_available",
175+
"vacuum.docked": "vacuum.returned_to_dock",
176+
}
177+
_RENAMED_CONDITION_KEYS = {
178+
"climate.target_temperature": "climate.is_target_temperature",
179+
"climate.target_humidity": "climate.is_target_humidity",
180+
}
181+
# Trigger ``options.behavior`` values renamed in HA 2026.7: any→each,
182+
# last→all (home-assistant/core#173259). The old values still load but raise
183+
# an HA repair issue and face removal. Condition ``options.behavior`` keeps
184+
# ``any``/``all`` — conditions are never flagged.
185+
_DEPRECATED_TRIGGER_BEHAVIOR = {"any": "each", "last": "all"}
186+
165187

166188
# ---------------------------------------------------------------------------
167189
# Public API
@@ -318,6 +340,23 @@ def _check_condition_templates(
318340
# Shorthand template condition
319341
_check_template_string(cond, warnings, skill_prefix, "condition")
320342
elif isinstance(cond, dict):
343+
# 2026.7 renamed purpose-specific condition keys — old keys no
344+
# longer load.
345+
condition_key = cond.get("condition")
346+
renamed_condition = (
347+
_RENAMED_CONDITION_KEYS.get(condition_key)
348+
if isinstance(condition_key, str)
349+
else None
350+
)
351+
if renamed_condition:
352+
_emit(
353+
warnings,
354+
f"Condition key `{condition_key}` was renamed to "
355+
f"`{renamed_condition}` in HA 2026.7 and the old key no "
356+
f"longer loads — use `condition: {renamed_condition}`.",
357+
skill_prefix,
358+
"automation-patterns.md#native-conditions",
359+
)
321360
if cond.get("condition") == "template":
322361
vt = cond.get("value_template", "")
323362
if isinstance(vt, str):
@@ -655,12 +694,44 @@ def _check_triggers(
655694

656695
platform = trigger.get("platform", trigger.get("trigger", ""))
657696

697+
# 2026.7 renamed purpose-specific trigger keys — old keys no longer load.
698+
renamed_trigger = (
699+
_RENAMED_TRIGGER_KEYS.get(platform) if isinstance(platform, str) else None
700+
)
701+
if renamed_trigger:
702+
_emit(
703+
warnings,
704+
f"Trigger key `{platform}` was renamed to `{renamed_trigger}` "
705+
"in HA 2026.7 and the old key no longer loads — use "
706+
f"`trigger: {renamed_trigger}`.",
707+
skill_prefix,
708+
"automation-patterns.md#trigger-types",
709+
)
710+
711+
# 2026.7 renamed trigger `options.behavior` values (any→each, last→all).
712+
options = trigger.get("options")
713+
if isinstance(options, dict):
714+
behavior = options.get("behavior")
715+
if isinstance(behavior, str) and behavior in _DEPRECATED_TRIGGER_BEHAVIOR:
716+
_emit(
717+
warnings,
718+
f"Trigger `options.behavior: {behavior}` was renamed to "
719+
f"`{_DEPRECATED_TRIGGER_BEHAVIOR[behavior]}` in HA 2026.7 — "
720+
"the old value still loads but raises a repair issue and "
721+
"will be removed. Valid trigger values: `each`, `first`, "
722+
"`all` (conditions keep `any`/`all`).",
723+
skill_prefix,
724+
"automation-patterns.md#trigger-types",
725+
)
726+
658727
# Device trigger → prefer entity_id-based triggers
659728
if platform == "device":
660729
_emit(
661730
warnings,
662-
"Trigger uses `device` platform with `device_id` — prefer "
663-
"`state` or `event` trigger with `entity_id` when possible "
731+
"Trigger uses `device` platform with `device_id` — prefer a "
732+
"purpose-specific trigger (`<domain>.<name>` with a `target:` "
733+
"of entities/areas/floors/labels, HA 2026.7+) or a `state`/"
734+
"`event` trigger with `entity_id` when possible "
664735
"(device_id breaks on re-add).",
665736
skill_prefix,
666737
"device-control.md#entity-id-vs-device-id",

src/ha_mcp/tools/tools_config_automations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,8 @@ async def ha_config_set_automation(
440440
Field(
441441
description="Complete automation configuration with required fields: 'alias', 'triggers', 'actions'. "
442442
"Optional: 'description', 'conditions', 'mode', 'max', 'initial_state', 'variables'. "
443+
"Purpose-specific triggers/conditions (HA 2026.7+ default: 'trigger': '<domain>.<name>' "
444+
"with 'target'/'options') are valid config. "
443445
"Mutually exclusive with python_transform.",
444446
default=None,
445447
),

src/ha_mcp/tools/tools_traces.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,8 @@ def _populate_trigger_info(
712712
)
713713
if "entity_id" in trigger_vars:
714714
result["trigger"]["entity_id"] = trigger_vars["entity_id"]
715+
if "error" in trigger_step:
716+
result["trigger"]["error"] = trigger_step["error"]
715717

716718
if "trigger" not in result and "trigger" in trace:
717719
result["trigger"] = {"description": trace["trigger"]}
@@ -731,6 +733,11 @@ def _populate_condition_results(
731733
}
732734
if "timestamp" in cond:
733735
cond_result["timestamp"] = cond["timestamp"]
736+
# HA 2026.7+ always records template errors on the failing step
737+
# (core #172917) — dropping this field would hide the reason a
738+
# condition evaluated to None.
739+
if "error" in cond:
740+
cond_result["error"] = cond["error"]
734741
condition_results.append(cond_result)
735742
result["condition_results"] = condition_results
736743

0 commit comments

Comments
 (0)