Skip to content

Commit 48b030a

Browse files
fix(tools): bring HA API field names to 2026.6 — stale docstrings, automation plural canonicalization, fan speed (closes #1540) (#1566)
* fix(tools): modernize stale HA API field names in docstring examples Audit of all 83 tool docstrings for inline JSON/dict examples referencing HA API field names renamed in recent releases (issue #1540; follow-on to #1539's service:->action: fix). - ha_get_state: the attribute_keys example used "color_temp", which HA removed from the light state-attribute set in 2026.3 (now "color_temp_kelvin"). attribute_keys silently drops unknown keys, so a model copying the example gets nothing back for that key on a modern light. - ha_config_set_automation: the create-from-scratch examples used the pre-2024.10 singular root keys (trigger/action/condition) and per-trigger "platform:". Updated to the 2024.10+ canonical plural roots (triggers/actions/conditions) and per-trigger "trigger:". Both forms remain accepted by HA; this teaches the current canonical shape. The python_transform examples intentionally keep the singular root + "platform:" form -- they operate on the fetched, round-trip-normalized config -- now with a clarifying note. Corrected the _normalize_automation_config and _normalize_trigger_keys docstrings that claimed "the API expects singular forms": HA accepts both; the tool normalizes to singular internally for round-trip and downstream-validator stability. Docstring/comment only; no behavior change. ruff and automation unit tests pass. * refactor(automations): canonicalize automation config to HA 2024.10+ plural shape Flip the tool's internal wire convention from the legacy singular root keys + per-trigger 'platform:' to HA's 2024.10+ canonical form: plural root list keys ('triggers'/'actions'/'conditions') and per-trigger 'trigger:'. Both forms are still accepted by HA; this makes ha_config_get_automation return the modern shape and the SET path send it. - _normalize_automation_config: canonicalize root list keys singular -> plural (only at root; deeper discriminators/service calls untouched, issue #498). The choose/if/compound 'conditions' lists are already plural and pass through. - _normalize_trigger_keys: flip platform -> trigger (modern per-trigger key). - _normalize_config_for_roundtrip: produce plural roots + 'trigger:' keys. - Internal validators (_validate_required_fields, _check_scene_create_misroute, _validate_condition_platform) read the plural keys (they run post-normalize). - best_practice_checker.check_automation_config reads the canonical plural keys but tolerates the singular aliases too (it may see raw user input), mirroring its existing platform/trigger tolerance. - Rewrote test_automation_normalization for the plural contract + added round-trip tests; updated test_config_automation_validation inputs to the normalized plural shape. ruff clean; all automation/best-practice/normalization unit tests pass. * test(automations): update python_transform e2e for canonical plural config shape ha_config_get_automation now returns HA's 2024.10+ plural root keys, and python_transform operates on that fetched (now plural) config. Update the transform expressions (config['action'] -> config['actions']) and GET-output assertions accordingly. Inputs stay legacy-singular on purpose — they exercise the tool's backward-compat acceptance of the singular aliases. * fix(tools): drop removed fan `speed`/`set_speed` for current 2026.6 services Codebase-wide sweep for stale HA API field/service names (issue #1540, 2026.6 currency). HA removed the legacy fan `speed` param and `fan.set_speed` service in the 2021-2022 percentage migration (verified absent in HA core 2026.6.0). - domain_handlers.py: drop "speed" from the fan domain's suggested `parameters` (kept percentage / preset_mode / direction, the current fan.* services). - tools_service.py: replace the dead "set_speed" entry in _STATE_CHANGING_SERVICES with its state-changing successors "set_percentage" / "set_preset_mode" so fan speed changes are still correctly awaited. The rest of the codebase verified clean against 2026.6 (lights already emit color_temp_kelvin, calendar uses get_events, climate/cover/media_player/todo/ energy/scripts/scenes all current; capability-concept flags like supports_speed left as-is — not HA API fields). * refactor(automations): address PR review (trigger-key hardening, stale strings, coverage) Verified findings from the multi-agent PR review + Gemini: - _normalize_trigger_keys: guard non-dict items (defensive against malformed LLM trigger lists) and drop the legacy 'platform' alias when 'trigger' is also present, so HA's strict schema doesn't reject the config (Gemini). - Fix the stale singular field name in the ha_config_set_automation failure suggestion ("alias, trigger, action" -> "alias, triggers, actions") — the one model-facing site the plural sweep missed (code-reviewer). - Fix the stale "triggers -> trigger" comment at the set-path normalize call to match the new singular -> plural direction (type-design + comment review). - Soften the best_practice_checker tolerance comment (the internal pipeline always pre-normalizes; the singular fallback is defensive for public callers). - Note the sequences -> sequence transform in _normalize_config_for_roundtrip's docstring. - tests/AGENTS.md: flip the "HA API uses singular field names" guidance to the 2024.10+ plural canonical (it's inverted by this PR). - Tests: pin the new trigger-key behavior (drop platform / non-dict passthrough) and add TestPluralKeyTolerance so the plural-first reads in check_automation_config are exercised (they were only hit via singular fallback). ruff clean; 195 affected unit tests pass. * fix(automations): warn instead of silently dropping a conflicting root-key alias Addresses the PR-review silent-failure finding. When a config carries BOTH a singular alias and its canonical plural root key with different values (e.g. a python_transform that sets singular config['trigger'] on a fetched plural config), _normalize_automation_config keeps the plural and drops the singular — previously with no signal. Add _detect_conflicting_root_keys(), run it on the pre-normalization config in both the full-config and python_transform paths, and surface a top-level warnings[] entry ("Config contains both 'trigger' and 'triggers' ... using 'triggers' and ignoring 'trigger'.") so the discarded change is visible. Unit tests for the detector added (all three pairs, equal-values no-op, single form, non-dict). mypy + ruff clean; affected unit suites pass. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent 148f506 commit 48b030a

10 files changed

Lines changed: 560 additions & 344 deletions

src/ha_mcp/tools/best_practice_checker.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,24 @@ def check_automation_config(
197197

198198
warnings = BestPracticeCheckResult()
199199

200+
# Read the canonical 2024.10+ plural root keys, falling back to the singular
201+
# aliases. The internal pipeline always pre-normalizes to plural, so the
202+
# fallback is defensive for direct/public callers of check_automation_config
203+
# (HA accepts both forms). Mirrors _check_triggers' platform/trigger tolerance.
200204
# Condition templates
201-
_check_condition_templates(config.get("condition", []), warnings, skill_prefix)
205+
_check_condition_templates(
206+
config.get("conditions", config.get("condition", [])), warnings, skill_prefix
207+
)
202208

203209
# Action tree (wait_template + nested conditions + target templates)
204-
_check_action_tree(config.get("action", []), warnings, skill_prefix)
210+
_check_action_tree(
211+
config.get("actions", config.get("action", [])), warnings, skill_prefix
212+
)
205213

206214
# Trigger templates + device_id
207-
_check_triggers(config.get("trigger", []), warnings, skill_prefix)
215+
_check_triggers(
216+
config.get("triggers", config.get("trigger", [])), warnings, skill_prefix
217+
)
208218

209219
# Mode vs motion pattern
210220
_check_mode_motion(config, warnings, skill_prefix)
@@ -738,7 +748,7 @@ def _check_mode_motion(
738748
if mode != "single":
739749
return
740750

741-
triggers = _as_list(config.get("trigger", []))
751+
triggers = _as_list(config.get("triggers", config.get("trigger", [])))
742752
has_motion = any(
743753
isinstance(t, dict)
744754
and any(
@@ -750,7 +760,7 @@ def _check_mode_motion(
750760
if not has_motion:
751761
return
752762

753-
if _has_delay_or_wait(config.get("action", [])):
763+
if _has_delay_or_wait(config.get("actions", config.get("action", []))):
754764
_emit(
755765
warnings,
756766
"Automation uses motion trigger with delay/wait but "

src/ha_mcp/tools/tools_config_automations.py

Lines changed: 142 additions & 108 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/tools_search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2331,7 +2331,7 @@ async def ha_get_state(
23312331
default=None,
23322332
description=(
23332333
"Return only the specified keys from each entity's attributes dict "
2334-
'(e.g. ["brightness", "color_temp"] for lights). '
2334+
'(e.g. ["brightness", "color_temp_kelvin"] for lights). '
23352335
"None = full attributes (default). "
23362336
"Unknown keys are silently dropped. "
23372337
'Requires "attributes" to be present in fields= (or fields=None).'

src/ha_mcp/tools/tools_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ def _parse_event_data(data: str | dict[str, Any] | None) -> dict[str, Any] | Non
8181
"set_temperature",
8282
"set_hvac_mode",
8383
"set_fan_mode",
84-
"set_speed",
84+
# fan.set_speed was removed in the HA percentage migration (gone in 2026.6);
85+
# its state-changing successors are set_percentage / set_preset_mode.
86+
"set_percentage",
87+
"set_preset_mode",
8588
"select_option",
8689
"set_value",
8790
"set_datetime",

src/ha_mcp/utils/domain_handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@
7474
},
7575
"fan": {
7676
"valid_actions": ["on", "off", "toggle", "set"],
77-
"parameters": ["speed", "percentage", "preset_mode", "direction"],
77+
# HA removed the legacy `speed` param / `fan.set_speed` service in the
78+
# 2021-2022 percentage migration (absent in 2026.6). Use percentage
79+
# (fan.set_percentage) and preset_mode (fan.set_preset_mode).
80+
"parameters": ["percentage", "preset_mode", "direction"],
7881
"quick_actions": ["toggle", "speed_up", "speed_down"],
7982
"state_attributes": ["percentage", "preset_mode"],
8083
"supports_speed": True,

tests/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ await mcp.call_tool("ha_config_get_script", {})
2525
await mcp.call_tool("ha_config_get_script", {"script_id": "nonexistent"})
2626
```
2727

28-
**HA API uses singular field names:** `trigger` not `triggers`, `action` not `actions`.
28+
**HA automation config uses plural root keys (HA 2024.10+):** `triggers`/`actions`/`conditions` (singular `trigger`/`action`/`condition` are still accepted as aliases). The tool canonicalizes to plural, so `ha_config_get_automation` returns the plural shape and `python_transform` operates on it.
2929

3030
**Poll after creating entities.** After creating an entity (automation, script, helper, etc.), HA needs time to register it. Never search/query immediately — use polling helpers from `tests/src/e2e/utilities/wait_helpers.py`:
3131
```python

tests/src/e2e/workflows/automation/test_python_transform.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def test_python_transform_simple_update(mcp_client, ha_client):
4040
{
4141
"identifier": entity_id,
4242
"config_hash": config_hash,
43-
"python_transform": "config['action'][0]['data']['brightness'] = 255",
43+
"python_transform": "config['actions'][0]['data']['brightness'] = 255",
4444
},
4545
)
4646

@@ -51,7 +51,7 @@ async def test_python_transform_simple_update(mcp_client, ha_client):
5151
verify = await mcp.call_tool_success(
5252
"ha_config_get_automation", {"identifier": entity_id}
5353
)
54-
assert verify["config"]["action"][0]["data"]["brightness"] == 255
54+
assert verify["config"]["actions"][0]["data"]["brightness"] == 255
5555

5656

5757
@pytest.mark.asyncio
@@ -101,7 +101,7 @@ async def test_python_transform_pattern_update(mcp_client, ha_client):
101101
"identifier": entity_id,
102102
"config_hash": config_hash,
103103
"python_transform": """
104-
for a in config['action']:
104+
for a in config['actions']:
105105
if a.get('action') == 'light.turn_on':
106106
a['data']['brightness'] = 200
107107
""",
@@ -113,7 +113,7 @@ async def test_python_transform_pattern_update(mcp_client, ha_client):
113113
verify = await mcp.call_tool_success(
114114
"ha_config_get_automation", {"identifier": entity_id}
115115
)
116-
actions = verify["config"]["action"]
116+
actions = verify["config"]["actions"]
117117
assert actions[0]["data"]["brightness"] == 200
118118
assert actions[1]["data"]["brightness"] == 200
119119
assert actions[2]["data"]["temperature"] == 22
@@ -142,7 +142,7 @@ async def test_python_transform_requires_config_hash(mcp_client, ha_client):
142142
"ha_config_set_automation",
143143
{
144144
"identifier": entity_id,
145-
"python_transform": "config['action'] = []",
145+
"python_transform": "config['actions'] = []",
146146
},
147147
)
148148
error_msg = extract_error_message(result)
@@ -158,7 +158,7 @@ async def test_python_transform_requires_identifier(mcp_client, ha_client):
158158
"ha_config_set_automation",
159159
{
160160
"config_hash": "fakehash",
161-
"python_transform": "config['action'] = []",
161+
"python_transform": "config['actions'] = []",
162162
},
163163
)
164164
error_msg = extract_error_message(result)
@@ -175,7 +175,7 @@ async def test_python_transform_mutual_exclusivity(mcp_client, ha_client):
175175
{
176176
"identifier": "automation.test",
177177
"config": {"alias": "test", "trigger": [], "action": []},
178-
"python_transform": "config['action'] = []",
178+
"python_transform": "config['actions'] = []",
179179
},
180180
)
181181
error_msg = extract_error_message(result)
@@ -225,7 +225,7 @@ async def test_python_transform_hash_conflict(mcp_client, ha_client):
225225
{
226226
"identifier": entity_id,
227227
"config_hash": config_hash,
228-
"python_transform": "config['action'][0]['data'] = {'brightness': 100}",
228+
"python_transform": "config['actions'][0]['data'] = {'brightness': 100}",
229229
},
230230
)
231231
error_msg = extract_error_message(result)
@@ -301,7 +301,7 @@ async def test_chained_transforms(mcp_client, ha_client):
301301
{
302302
"identifier": entity_id,
303303
"config_hash": get_result["config_hash"],
304-
"python_transform": "config['action'][0]['data']['brightness'] = 100",
304+
"python_transform": "config['actions'][0]['data']['brightness'] = 100",
305305
},
306306
)
307307
assert result1["success"] is True
@@ -312,7 +312,7 @@ async def test_chained_transforms(mcp_client, ha_client):
312312
{
313313
"identifier": entity_id,
314314
"config_hash": result1["config_hash"],
315-
"python_transform": "config['action'][0]['data']['brightness'] = 200",
315+
"python_transform": "config['actions'][0]['data']['brightness'] = 200",
316316
},
317317
)
318318
assert result2["success"] is True
@@ -386,7 +386,7 @@ async def test_transform_invalid_config_rejected(mcp_client, ha_client):
386386
{
387387
"identifier": entity_id,
388388
"config_hash": get_result["config_hash"],
389-
"python_transform": "del config['action']",
389+
"python_transform": "del config['actions']",
390390
},
391391
)
392392
error_msg = extract_error_message(result)
@@ -427,10 +427,11 @@ async def test_config_hash_stable_across_reads(mcp_client, ha_client):
427427

428428
@pytest.mark.asyncio
429429
async def test_plural_key_hash_stability(mcp_client, ha_client):
430-
"""Test that plural keys (triggers/actions) don't cause hash instability.
430+
"""Test that plural-key input doesn't cause hash instability.
431431
432-
HA REST API returns plural keys which get normalized to singular.
433-
Hash must be stable across this normalization.
432+
The tool canonicalizes automation configs to HA's 2024.10+ plural root
433+
keys. The normalized (plural) shape — and therefore the hash — must be
434+
stable across reads.
434435
"""
435436
mcp = MCPAssertions(mcp_client)
436437

@@ -614,13 +615,13 @@ async def test_categorized_automation_transform_preserves_category(
614615
{
615616
"identifier": entity_id,
616617
"config_hash": config_hash,
617-
"python_transform": "config['action'][0]['data']['brightness'] = 200",
618+
"python_transform": "config['actions'][0]['data']['brightness'] = 200",
618619
},
619620
)
620621

621622
# Verify category is preserved and transform applied
622623
verify = await mcp.call_tool_success(
623624
"ha_config_get_automation", {"identifier": entity_id}
624625
)
625-
assert verify["config"]["action"][0]["data"]["brightness"] == 200
626+
assert verify["config"]["actions"][0]["data"]["brightness"] == 200
626627
assert verify["config"].get("category") == category_id

0 commit comments

Comments
 (0)