Skip to content

Commit 1029558

Browse files
feat: expose category on automation, script, and helper config tools (#850)
* feat: expose category on automation, script, and helper config tools Add category parameter to domain-specific config tools, building on #677's category CRUD foundation: - ha_config_get_automation: includes category from entity registry - ha_config_set_automation: accepts category parameter, applies via entity registry update after creation (also extracts from config dict to prevent REST API rejection) - ha_config_get_script: includes category from entity registry - ha_config_set_script: same pattern as automations - ha_config_set_helper: adds category alongside existing area_id and labels entity registry updates Categories are stored in the entity registry (not YAML config), so GET tools make a secondary WebSocket call to fetch them, and SET tools apply them via entity_registry/update after the primary create/update succeeds. Closes #702 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Gemini review, add E2E tests for config tool categories Fixes from Gemini review: - Use "helpers" scope for helper categories (not helper_type) - Add category support to helper UPDATE path (was only on create) - Replace bare except:pass with logger.debug for debuggability Add E2E tests: - test_automation_set_and_get_category: full round-trip - test_script_set_and_get_category: full round-trip - test_automation_category_in_config_dict: category extraction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: align helper category param description with automation/script Include scope='helpers' and mention ha_config_set_category() for consistency across all category-aware config tools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract _resolve_automation_entity_id shared helper Deduplicate the entity_id-from-unique_id resolution logic used by both ha_config_get_automation and ha_config_remove_automation into a single helper function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add str() cast to satisfy mypy no-any-return state["entity_id"] returns Any from untyped dict; wrap in str() to match the declared str | None return type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract shared category helpers, restore remove warning - Extract fetch_entity_category() and apply_entity_category() into util_helpers.py, replacing duplicate inline logic in automations and scripts config tools - Restore warning log in ha_config_remove_automation when entity_id resolution fails (was lost during _resolve helper extraction) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add str() cast in fetch_entity_category for mypy categories.get(scope) returns Any from untyped dict; cast to str to match declared str | None return type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d6be6b7 commit 1029558

6 files changed

Lines changed: 605 additions & 16 deletions

File tree

site/src/data/tools.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@
299299
{
300300
"name": "ha_config_set_automation",
301301
"title": "Create or Update Automation",
302-
"description": "Create or update a Home Assistant automation.\n\nCreates a new automation (if identifier omitted) or updates existing automation with provided configuration.\n\nAUTOMATION TYPES:\n\n1. Regular Automations - Define triggers and actions directly\n2. Blueprint Automations - Use pre-built templates with customizable inputs\n\nREQUIRED FIELDS (Regular Automations):\n- alias: Human-readable automation name\n- trigger: List of trigger conditions (time, state, event, etc.)\n- action: List of actions to execute\n\nREQUIRED FIELDS (Blueprint Automations):\n- alias: Human-readable automation name\n- use_blueprint: Blueprint configuration\n - path: Blueprint file path (e.g., \"motion_light.yaml\")\n - input: Dictionary of input values for the blueprint\n\nOPTIONAL CONFIG FIELDS (Regular Automations):\n- description: Detailed description of the user's intent (RECOMMENDED: helps safely modify implementation later)\n- condition: Additional conditions that must be met\n- mode: 'single' (default), 'restart', 'queued', 'parallel'\n- max: Maximum concurrent executions (for queued/parallel modes)\n- initial_state: Whether automation starts enabled (true/false)\n- variables: Variables for use in automation\n\nBASIC EXAMPLES:\n\nSimple time-based automation:\nha_config_set_automation({\n \"alias\": \"Morning Lights\",\n \"description\": \"Turn on bedroom lights at 7 AM to help wake up\",\n \"trigger\": [{\"platform\": \"time\", \"at\": \"07:00:00\"}],\n \"action\": [{\"service\": \"light.turn_on\", \"target\": {\"area_id\": \"bedroom\"}}]\n})\n\nMotion-activated lighting with condition:\nha_config_set_automation({\n \"alias\": \"Motion Light\",\n \"trigger\": [{\"platform\": \"state\", \"entity_id\": \"binary_sensor.motion\", \"to\": \"on\"}],\n \"condition\": [{\"condition\": \"sun\", \"after\": \"sunset\"}],\n \"action\": [\n {\"service\": \"light.turn_on\", \"target\": {\"entity_id\": \"light.hallway\"}},\n {\"delay\": {\"minutes\": 5}},\n {\"service\": \"light.turn_off\", \"target\": {\"entity_id\": \"light.hallway\"}}\n ],\n \"mode\": \"restart\"\n})\n\nUpdate existing automation:\nha_config_set_automation(\n identifier=\"automation.morning_routine\",\n config={\n \"alias\": \"Updated Morning Routine\",\n \"trigger\": [{\"platform\": \"time\", \"at\": \"06:30:00\"}],\n \"action\": [\n {\"service\": \"light.turn_on\", \"target\": {\"area_id\": \"bedroom\"}},\n {\"service\": \"climate.set_temperature\", \"target\": {\"entity_id\": \"climate.bedroom\"}, \"data\": {\"temperature\": 22}}\n ]\n }\n)\n\nBLUEPRINT AUTOMATION EXAMPLES:\n\nCreate automation from blueprint:\nha_config_set_automation({\n \"alias\": \"Motion Light Kitchen\",\n \"use_blueprint\": {\n \"path\": \"homeassistant/motion_light.yaml\",\n \"input\": {\n \"motion_entity\": \"binary_sensor.kitchen_motion\",\n \"light_target\": {\"entity_id\": \"light.kitchen\"},\n \"no_motion_wait\": 120\n }\n }\n})\n\nUpdate blueprint automation inputs:\nha_config_set_automation(\n identifier=\"automation.motion_light_kitchen\",\n config={\n \"alias\": \"Motion Light Kitchen\",\n \"use_blueprint\": {\n \"path\": \"homeassistant/motion_light.yaml\",\n \"input\": {\n \"motion_entity\": \"binary_sensor.kitchen_motion\",\n \"light_target\": {\"entity_id\": \"light.kitchen\"},\n \"no_motion_wait\": 300\n }\n }\n }\n})\n\nPREFER NATIVE SOLUTIONS OVER TEMPLATES:\nBefore using template triggers/conditions/actions, check if a native option exists:\n- Use `condition: state` with `state: [list]` instead of template for multiple states\n- Use `condition: state` with `attribute:` instead of template for attribute checks\n- Use `condition: numeric_state` instead of template for number comparisons\n- Use `wait_for_trigger` instead of `wait_template` when waiting for state changes\n- Use `choose` action instead of template-based service names\n\nTRIGGER TYPES: time, time_pattern, sun, state, numeric_state, event, device, zone, template, and more\nCONDITION TYPES: state, numeric_state, time, sun, template, device, zone, and more\nACTION TYPES: service calls, delays, wait_for_trigger, wait_template, if/then/else, choose, repeat, parallel\n\nFor comprehensive automation documentation with all trigger/condition/action types and advanced examples:\n- Use: ha_get_skill_home_assistant_best_practices\n- Or visit: https://www.home-assistant.io/docs/automation/\n\nTROUBLESHOOTING:\n- Use ha_get_state() to verify entity_ids exist\n- Use ha_search_entities() to find correct entity_ids\n- Use ha_eval_template() to test Jinja2 templates before using in automations\n- Use ha_search_entities(domain_filter='automation') to find existing automations",
302+
"description": "Create or update a Home Assistant automation.\n\nCreates a new automation (if identifier omitted) or updates existing automation with provided configuration.\n\nAUTOMATION TYPES:\n\n1. Regular Automations - Define triggers and actions directly\n2. Blueprint Automations - Use pre-built templates with customizable inputs\n\nREQUIRED FIELDS (Regular Automations):\n- alias: Human-readable automation name\n- trigger: List of trigger conditions (time, state, event, etc.)\n- action: List of actions to execute\n\nREQUIRED FIELDS (Blueprint Automations):\n- alias: Human-readable automation name\n- use_blueprint: Blueprint configuration\n - path: Blueprint file path (e.g., \"motion_light.yaml\")\n - input: Dictionary of input values for the blueprint\n\nOPTIONAL CONFIG FIELDS (Regular Automations):\n- description: Detailed description of the user's intent (RECOMMENDED: helps safely modify implementation later)\n- category: Category ID for organization (use ha_config_get_category to list, ha_config_set_category to create)\n- condition: Additional conditions that must be met\n- mode: 'single' (default), 'restart', 'queued', 'parallel'\n- max: Maximum concurrent executions (for queued/parallel modes)\n- initial_state: Whether automation starts enabled (true/false)\n- variables: Variables for use in automation\n\nBASIC EXAMPLES:\n\nSimple time-based automation:\nha_config_set_automation({\n \"alias\": \"Morning Lights\",\n \"description\": \"Turn on bedroom lights at 7 AM to help wake up\",\n \"trigger\": [{\"platform\": \"time\", \"at\": \"07:00:00\"}],\n \"action\": [{\"service\": \"light.turn_on\", \"target\": {\"area_id\": \"bedroom\"}}]\n})\n\nMotion-activated lighting with condition:\nha_config_set_automation({\n \"alias\": \"Motion Light\",\n \"trigger\": [{\"platform\": \"state\", \"entity_id\": \"binary_sensor.motion\", \"to\": \"on\"}],\n \"condition\": [{\"condition\": \"sun\", \"after\": \"sunset\"}],\n \"action\": [\n {\"service\": \"light.turn_on\", \"target\": {\"entity_id\": \"light.hallway\"}},\n {\"delay\": {\"minutes\": 5}},\n {\"service\": \"light.turn_off\", \"target\": {\"entity_id\": \"light.hallway\"}}\n ],\n \"mode\": \"restart\"\n})\n\nUpdate existing automation:\nha_config_set_automation(\n identifier=\"automation.morning_routine\",\n config={\n \"alias\": \"Updated Morning Routine\",\n \"trigger\": [{\"platform\": \"time\", \"at\": \"06:30:00\"}],\n \"action\": [\n {\"service\": \"light.turn_on\", \"target\": {\"area_id\": \"bedroom\"}},\n {\"service\": \"climate.set_temperature\", \"target\": {\"entity_id\": \"climate.bedroom\"}, \"data\": {\"temperature\": 22}}\n ]\n }\n)\n\nBLUEPRINT AUTOMATION EXAMPLES:\n\nCreate automation from blueprint:\nha_config_set_automation({\n \"alias\": \"Motion Light Kitchen\",\n \"use_blueprint\": {\n \"path\": \"homeassistant/motion_light.yaml\",\n \"input\": {\n \"motion_entity\": \"binary_sensor.kitchen_motion\",\n \"light_target\": {\"entity_id\": \"light.kitchen\"},\n \"no_motion_wait\": 120\n }\n }\n})\n\nUpdate blueprint automation inputs:\nha_config_set_automation(\n identifier=\"automation.motion_light_kitchen\",\n config={\n \"alias\": \"Motion Light Kitchen\",\n \"use_blueprint\": {\n \"path\": \"homeassistant/motion_light.yaml\",\n \"input\": {\n \"motion_entity\": \"binary_sensor.kitchen_motion\",\n \"light_target\": {\"entity_id\": \"light.kitchen\"},\n \"no_motion_wait\": 300\n }\n }\n }\n})\n\nPREFER NATIVE SOLUTIONS OVER TEMPLATES:\nBefore using template triggers/conditions/actions, check if a native option exists:\n- Use `condition: state` with `state: [list]` instead of template for multiple states\n- Use `condition: state` with `attribute:` instead of template for attribute checks\n- Use `condition: numeric_state` instead of template for number comparisons\n- Use `wait_for_trigger` instead of `wait_template` when waiting for state changes\n- Use `choose` action instead of template-based service names\n\nTRIGGER TYPES: time, time_pattern, sun, state, numeric_state, event, device, zone, template, and more\nCONDITION TYPES: state, numeric_state, time, sun, template, device, zone, and more\nACTION TYPES: service calls, delays, wait_for_trigger, wait_template, if/then/else, choose, repeat, parallel\n\nFor comprehensive automation documentation with all trigger/condition/action types and advanced examples:\n- Use: ha_get_skill_home_assistant_best_practices\n- Or visit: https://www.home-assistant.io/docs/automation/\n\nTROUBLESHOOTING:\n- Use ha_get_state() to verify entity_ids exist\n- Use ha_search_entities() to find correct entity_ids\n- Use ha_eval_template() to test Jinja2 templates before using in automations\n- Use ha_search_entities(domain_filter='automation') to find existing automations",
303303
"inputSchema": {
304304
"properties": {
305305
"config": {
@@ -309,6 +309,10 @@
309309
"type": "Annotated[str | None, Field(description='Automation entity_id or unique_id for updates. Omit to create new automation with generated unique_id.', default=None)]",
310310
"default": null
311311
},
312+
"category": {
313+
"type": "Annotated[str | None, Field(description=\"Category ID to assign to this automation. Use ha_config_get_category(scope='automation') to list available categories, or ha_config_set_category() to create one.\", default=None)]",
314+
"default": null
315+
},
312316
"wait": {
313317
"type": "Annotated[bool | str, Field(description='Wait for automation to be queryable before returning. Default: True. Set to False for bulk operations.', default=True)]",
314318
"default": true
@@ -1520,6 +1524,10 @@
15201524
"type": "Annotated[str | None, Field(description='Description for tag', default=None)]",
15211525
"default": null
15221526
},
1527+
"category": {
1528+
"type": "Annotated[str | None, Field(description=\"Category ID to assign to this helper. Use ha_config_get_category(scope='helpers') to list available categories, or ha_config_set_category() to create one.\", default=None)]",
1529+
"default": null
1530+
},
15231531
"wait": {
15241532
"type": "Annotated[bool | str, Field(description='Wait for helper entity to be queryable before returning. Default: True. Set to False for bulk operations.', default=True)]",
15251533
"default": true
@@ -2101,6 +2109,10 @@
21012109
"config": {
21022110
"type": "Annotated[str | dict[str, Any], Field(description=\"Script configuration dictionary. Must include EITHER 'sequence' (for regular scripts) OR 'use_blueprint' (for blueprint-based scripts). Optional fields: 'alias', 'description', 'icon', 'mode', 'max', 'fields'\")]"
21032111
},
2112+
"category": {
2113+
"type": "Annotated[str | None, Field(description=\"Category ID to assign to this script. Use ha_config_get_category(scope='script') to list available categories, or ha_config_set_category() to create one.\", default=None)]",
2114+
"default": null
2115+
},
21042116
"wait": {
21052117
"type": "Annotated[bool | str, Field(description='Wait for script to be queryable before returning. Default: True. Set to False for bulk operations.', default=True)]",
21062118
"default": true

src/ha_mcp/tools/tools_config_automations.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
)
2525
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
2626
from .util_helpers import (
27+
apply_entity_category,
2728
coerce_bool_param,
29+
fetch_entity_category,
2830
parse_json_param,
2931
wait_for_entity_registered,
3032
wait_for_entity_removed,
@@ -205,6 +207,27 @@ def _strip_empty_automation_fields(config: dict[str, Any]) -> dict[str, Any]:
205207
def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
206208
"""Register Home Assistant automation configuration tools."""
207209

210+
async def _resolve_automation_entity_id(identifier: str) -> str | None:
211+
"""Resolve an automation identifier to its entity_id.
212+
213+
If identifier is already an entity_id (starts with "automation."),
214+
returns it directly. Otherwise, searches states to find the entity
215+
whose unique_id matches the identifier.
216+
"""
217+
if identifier.startswith("automation."):
218+
return identifier
219+
try:
220+
states = await client.get_states()
221+
for state in states:
222+
if (
223+
state.get("entity_id", "").startswith("automation.")
224+
and state.get("attributes", {}).get("id") == identifier
225+
):
226+
return str(state["entity_id"])
227+
except Exception as e:
228+
logger.debug(f"Failed to resolve entity_id for automation {identifier}: {e}")
229+
return None
230+
208231
@mcp.tool(
209232
tags={"Automations"},
210233
annotations={
@@ -237,6 +260,14 @@ async def ha_config_get_automation(
237260
config_result = await client.get_automation_config(identifier)
238261
# Normalize config for round-trip compatibility (GET → SET)
239262
normalized_config = _normalize_config_for_roundtrip(config_result)
263+
264+
# Resolve entity_id and fetch category from entity registry
265+
entity_id = await _resolve_automation_entity_id(identifier)
266+
if entity_id:
267+
cat_id = await fetch_entity_category(client, entity_id, "automation")
268+
if cat_id:
269+
normalized_config["category"] = cat_id
270+
240271
return {
241272
"success": True,
242273
"action": "get",
@@ -296,6 +327,13 @@ async def ha_config_set_automation(
296327
default=None,
297328
),
298329
] = None,
330+
category: Annotated[
331+
str | None,
332+
Field(
333+
description="Category ID to assign to this automation. Use ha_config_get_category(scope='automation') to list available categories, or ha_config_set_category() to create one.",
334+
default=None,
335+
),
336+
] = None,
299337
wait: Annotated[
300338
bool | str,
301339
Field(
@@ -327,6 +365,7 @@ async def ha_config_set_automation(
327365
328366
OPTIONAL CONFIG FIELDS (Regular Automations):
329367
- description: Detailed description of the user's intent (RECOMMENDED: helps safely modify implementation later)
368+
- category: Category ID for organization (use ha_config_get_category to list, ha_config_set_category to create)
330369
- condition: Additional conditions that must be met
331370
- mode: 'single' (default), 'restart', 'queued', 'parallel'
332371
- max: Maximum concurrent executions (for queued/parallel modes)
@@ -444,6 +483,11 @@ async def ha_config_set_automation(
444483

445484
config_dict = cast(dict[str, Any], parsed_config)
446485

486+
# Extract category before sending to HA REST API (which rejects unknown keys).
487+
# Parameter takes precedence over config dict value.
488+
config_category = config_dict.pop("category", None)
489+
effective_category = category if category is not None else config_category
490+
447491
# Normalize field names (triggers -> trigger, actions -> action, etc.)
448492
config_dict = _normalize_automation_config(config_dict)
449493

@@ -499,6 +543,9 @@ async def ha_config_set_automation(
499543
# Wait for automation to be queryable
500544
wait_bool = coerce_bool_param(wait, "wait", default=True)
501545
entity_id = result.get("entity_id")
546+
# On updates, entity_id may not be in the result — derive from identifier
547+
if not entity_id and identifier and identifier.startswith("automation."):
548+
entity_id = identifier
502549
if wait_bool and entity_id:
503550
try:
504551
registered = await wait_for_entity_registered(client, entity_id)
@@ -507,6 +554,12 @@ async def ha_config_set_automation(
507554
except Exception as e:
508555
result["warning"] = f"Automation created but verification failed: {e}"
509556

557+
# Apply category to entity registry if provided
558+
if effective_category and entity_id:
559+
await apply_entity_category(
560+
client, entity_id, effective_category, "automation", result, "automation"
561+
)
562+
510563
if bp_warnings:
511564
result["best_practice_warnings"] = bp_warnings
512565

@@ -572,20 +625,11 @@ async def ha_config_remove_automation(
572625
"""
573626
try:
574627
# Resolve entity_id for wait verification (identifier may be a unique_id)
575-
entity_id_for_wait: str | None = None
576-
if identifier.startswith("automation."):
577-
entity_id_for_wait = identifier
578-
else:
579-
# Try to find entity_id by matching unique_id in automation states
580-
try:
581-
states = await client.get_states()
582-
for state in states:
583-
eid = state.get("entity_id", "")
584-
if eid.startswith("automation.") and state.get("attributes", {}).get("id") == identifier:
585-
entity_id_for_wait = eid
586-
break
587-
except Exception as e:
588-
logger.warning(f"Could not resolve unique_id '{identifier}' to entity_id: {e} — wait verification will be skipped")
628+
entity_id_for_wait = await _resolve_automation_entity_id(identifier)
629+
if not entity_id_for_wait:
630+
logger.warning(
631+
f"Could not resolve unique_id '{identifier}' to entity_id — wait verification will be skipped"
632+
)
589633

590634
result = await client.delete_automation_config(identifier)
591635

0 commit comments

Comments
 (0)