ha-mcp-dev 7.5.0.dev569__tar.gz → 7.5.0.dev571__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {ha_mcp_dev-7.5.0.dev569/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev571}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/client/rest_client.py +16 -4
  4. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_categories.py +39 -3
  5. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_automations.py +225 -92
  6. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_dashboards.py +11 -2
  7. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_scripts.py +189 -30
  8. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_labels.py +93 -36
  9. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_registry.py +16 -4
  10. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_zones.py +113 -50
  11. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  12. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/LICENSE +0 -0
  13. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/MANIFEST.in +0 -0
  14. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/README.md +0 -0
  15. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/setup.cfg +0 -0
  16. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/__init__.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/__main__.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/_pypi_marker +0 -0
  19. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/_version.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/auth/__init__.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/auth/consent_form.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/auth/provider.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/client/__init__.py +0 -0
  24. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/client/supervisor_client.py +0 -0
  25. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/client/websocket_client.py +0 -0
  26. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/client/websocket_listener.py +0 -0
  27. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/config.py +0 -0
  28. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/errors.py +0 -0
  29. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/py.typed +0 -0
  30. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  31. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  32. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  33. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  34. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  35. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  36. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  37. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  38. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  39. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  40. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  41. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  42. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  43. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  46. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  47. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  48. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  49. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  50. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  51. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  52. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/server.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/settings_ui.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/smoke_test.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/__init__.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/backup.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/device_control.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/enhanced.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/helpers.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/reference_validator.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/registry.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/smart_search.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_addons.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_areas.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_calendar.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_camera.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_code.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_energy.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_entities.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_groups.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_hacs.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_history.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_integrations.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_resources.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_search.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_service.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_services.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_system.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_todo.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_traces.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_updates.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_utility.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/tools/util_helpers.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/transforms/__init__.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/transforms/categorized_search.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/__init__.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/config_hash.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/data_paths.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/domain_handlers.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/operation_manager.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/python_sandbox.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp/utils/usage_logger.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  107. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  108. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  109. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  110. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  111. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/tests/__init__.py +0 -0
  112. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/tests/test_constants.py +0 -0
  113. {ha_mcp_dev-7.5.0.dev569 → ha_mcp_dev-7.5.0.dev571}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev569
3
+ Version: 7.5.0.dev571
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.5.0.dev569"
7
+ version = "7.5.0.dev571"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import ssl
10
+ import time
10
11
  from typing import Any
11
12
 
12
13
  import httpx
@@ -963,12 +964,18 @@ class HomeAssistantClient:
963
964
  ) from e
964
965
  raise
965
966
 
966
- # 3-attempt × 6s upper-bound budget; first poll 0.1s catches the
967
- # typical sub-1s entity-publish window.
968
- _POLL_CADENCE: tuple[float, ...] = (0.1, 1.0, 4.9)
967
+ # 3-attempt × 6s upper-bound budget; first poll 0.025s is a 5×
968
+ # cushion above the ~4ms HA-Core entity-registration latency
969
+ # measured by ``test_poll_cadence_measurement.py`` (#1389 p50
970
+ # 104.1-104.8 ms on the prior 0.1s first-poll, all from the sleep
971
+ # itself with ~4 ms of real registration work).
972
+ _POLL_CADENCE: tuple[float, ...] = (0.025, 1.0, 4.975)
969
973
 
970
974
  async def _poll_for_automation_entity(self, unique_id: str) -> str | None:
971
975
  """Poll HA state to find the entity_id assigned to a newly created automation."""
976
+ # Measure cumulative elapsed from function entry to first successful match.
977
+ # Feeds the #1389 p50/p99 validation of `_POLL_CADENCE`.
978
+ start_monotonic = time.monotonic()
972
979
  try:
973
980
  for sleep_time in self._POLL_CADENCE:
974
981
  await asyncio.sleep(sleep_time)
@@ -978,8 +985,13 @@ class HomeAssistantClient:
978
985
  continue
979
986
  if state.get("attributes", {}).get("id") == unique_id:
980
987
  entity_id = state.get("entity_id")
988
+ elapsed_ms = (time.monotonic() - start_monotonic) * 1000.0
981
989
  logger.debug(
982
- f"Found actual entity_id for unique_id {unique_id}: {entity_id}"
990
+ "entity-registration-elapsed: %.1fms "
991
+ "(unique_id=%s, entity_id=%s)",
992
+ elapsed_ms,
993
+ unique_id,
994
+ entity_id,
983
995
  )
984
996
  return entity_id
985
997
  except HomeAssistantError as e:
@@ -36,7 +36,11 @@ class CategoryTools:
36
36
  @tool(
37
37
  name="ha_config_get_category",
38
38
  tags={"Labels & Categories"},
39
- annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Get Category"},
39
+ annotations={
40
+ "idempotentHint": True,
41
+ "readOnlyHint": True,
42
+ "title": "Get Category",
43
+ },
40
44
  )
41
45
  @log_tool_usage
42
46
  async def ha_config_get_category(
@@ -133,7 +137,7 @@ class CategoryTools:
133
137
  available_ids = [cat.get("category_id") for cat in categories[:10]]
134
138
  raise_tool_error(
135
139
  create_error_response(
136
- ErrorCode.ENTITY_NOT_FOUND,
140
+ ErrorCode.RESOURCE_NOT_FOUND,
137
141
  f"Category not found: {category_id}",
138
142
  context={
139
143
  "category_id": category_id,
@@ -247,6 +251,22 @@ class CategoryTools:
247
251
  "message": f"Successfully {action_past} category: {name}",
248
252
  }
249
253
  else:
254
+ error_str = str(result.get("error", "")).lower()
255
+ if "not found" in error_str or "doesn't exist" in error_str:
256
+ raise_tool_error(
257
+ create_error_response(
258
+ ErrorCode.RESOURCE_NOT_FOUND,
259
+ f"Category not found: {category_id}",
260
+ context={
261
+ "name": name,
262
+ "scope": scope,
263
+ "category_id": category_id,
264
+ },
265
+ suggestions=[
266
+ f"Use ha_config_get_category('{scope}') without category_id to see all categories",
267
+ ],
268
+ )
269
+ )
250
270
  raise_tool_error(
251
271
  create_error_response(
252
272
  ErrorCode.SERVICE_CALL_FAILED,
@@ -276,7 +296,11 @@ class CategoryTools:
276
296
  @tool(
277
297
  name="ha_config_remove_category",
278
298
  tags={"Labels & Categories"},
279
- annotations={"destructiveHint": True, "idempotentHint": True, "title": "Remove Category"},
299
+ annotations={
300
+ "destructiveHint": True,
301
+ "idempotentHint": True,
302
+ "title": "Remove Category",
303
+ },
280
304
  )
281
305
  @log_tool_usage
282
306
  async def ha_config_remove_category(
@@ -333,6 +357,18 @@ class CategoryTools:
333
357
  "message": f"Successfully deleted category: {category_id}",
334
358
  }
335
359
  else:
360
+ error_str = str(result.get("error", "")).lower()
361
+ if "not found" in error_str or "doesn't exist" in error_str:
362
+ raise_tool_error(
363
+ create_error_response(
364
+ ErrorCode.RESOURCE_NOT_FOUND,
365
+ f"Category not found: {category_id}",
366
+ context={"category_id": category_id, "scope": scope},
367
+ suggestions=[
368
+ f"Use ha_config_get_category('{scope}') without category_id to see all categories",
369
+ ],
370
+ )
371
+ )
336
372
  raise_tool_error(
337
373
  create_error_response(
338
374
  ErrorCode.SERVICE_CALL_FAILED,
@@ -13,6 +13,7 @@ from fastmcp.tools import tool
13
13
  from pydantic import Field
14
14
 
15
15
  from ..client.rest_client import (
16
+ HomeAssistantAPIError,
16
17
  HomeAssistantAuthError,
17
18
  HomeAssistantConnectionError,
18
19
  )
@@ -53,6 +54,17 @@ from .util_helpers import (
53
54
 
54
55
  logger = logging.getLogger(__name__)
55
56
 
57
+ # Distinctive prefix of the soft-failure warning emitted by
58
+ # ``ha_config_set_automation`` when ``_poll_for_automation_entity``
59
+ # exhausts ``_POLL_CADENCE`` without matching the new automation.
60
+ # Exported so tests (e.g. ``test_poll_cadence_measurement.py``) can
61
+ # detect a missed registration without hard-coding the literal —
62
+ # rewording the warning becomes a compile-time coupling rather than
63
+ # a silent test drift.
64
+ NOT_VERIFIED_WARNING_PREFIX = (
65
+ "Automation was submitted to Home Assistant but the entity was not found"
66
+ )
67
+
56
68
 
57
69
  def _normalize_automation_config(
58
70
  config: Any,
@@ -279,7 +291,9 @@ class AutomationConfigTools:
279
291
  ):
280
292
  return str(state["entity_id"])
281
293
  except Exception as e:
282
- logger.debug(f"Failed to resolve entity_id for automation {identifier}: {e}")
294
+ logger.debug(
295
+ f"Failed to resolve entity_id for automation {identifier}: {e}"
296
+ )
283
297
  return None
284
298
 
285
299
  @tool(
@@ -334,13 +348,17 @@ class AutomationConfigTools:
334
348
  "Use ha_search_entities(domain_filter='automation') to list automations",
335
349
  ],
336
350
  )
337
- normalized_config, config_hash = await self._get_automation_config_internal(identifier)
351
+ normalized_config, config_hash = await self._get_automation_config_internal(
352
+ identifier
353
+ )
338
354
 
339
355
  # Resolve entity_id and fetch category from entity registry
340
356
  # (injected after hash so transient registry failures don't affect the hash)
341
357
  entity_id = await self._resolve_automation_entity_id(identifier)
342
358
  if entity_id:
343
- cat_id = await fetch_entity_category(self._client, entity_id, "automation")
359
+ cat_id = await fetch_entity_category(
360
+ self._client, entity_id, "automation"
361
+ )
344
362
  if cat_id:
345
363
  normalized_config["category"] = cat_id
346
364
 
@@ -644,7 +662,10 @@ class AutomationConfigTools:
644
662
  "Provide the automation entity_id or unique_id",
645
663
  "Use ha_search_entities(domain_filter='automation') to find automations",
646
664
  ],
647
- context={"action": "python_transform", "identifier": identifier},
665
+ context={
666
+ "action": "python_transform",
667
+ "identifier": identifier,
668
+ },
648
669
  )
649
670
  )
650
671
  if config_hash is None:
@@ -656,7 +677,10 @@ class AutomationConfigTools:
656
677
  "Call ha_config_get_automation() first",
657
678
  "Use the config_hash from that response",
658
679
  ],
659
- context={"action": "python_transform", "identifier": identifier},
680
+ context={
681
+ "action": "python_transform",
682
+ "identifier": identifier,
683
+ },
660
684
  )
661
685
  )
662
686
 
@@ -675,7 +699,10 @@ class AutomationConfigTools:
675
699
  ErrorCode.VALIDATION_FAILED,
676
700
  message,
677
701
  suggestions=suggestions,
678
- context={"action": "python_transform", "identifier": identifier},
702
+ context={
703
+ "action": "python_transform",
704
+ "identifier": identifier,
705
+ },
679
706
  )
680
707
  )
681
708
 
@@ -698,11 +725,20 @@ class AutomationConfigTools:
698
725
 
699
726
  # Re-apply category if present
700
727
  entity_id = result.get("entity_id")
701
- if not entity_id and identifier and identifier.startswith("automation."):
728
+ if (
729
+ not entity_id
730
+ and identifier
731
+ and identifier.startswith("automation.")
732
+ ):
702
733
  entity_id = identifier
703
734
  if transform_category and entity_id:
704
735
  await apply_entity_category(
705
- self._client, entity_id, transform_category, "automation", result, "automation"
736
+ self._client,
737
+ entity_id,
738
+ transform_category,
739
+ "automation",
740
+ result,
741
+ "automation",
706
742
  )
707
743
 
708
744
  response: dict[str, Any] = {
@@ -762,12 +798,14 @@ class AutomationConfigTools:
762
798
  self._client, config_dict
763
799
  )
764
800
 
765
- result = await self._client.upsert_automation_config(config_dict, identifier)
801
+ result = await self._client.upsert_automation_config(
802
+ config_dict, identifier
803
+ )
766
804
 
767
805
  # If the client could not verify the entity was registered, warn but don't hard-fail.
768
806
  if result.get("entity_not_verified"):
769
807
  result.setdefault("warnings", []).append(
770
- "Automation was submitted to Home Assistant but the entity was not found "
808
+ f"{NOT_VERIFIED_WARNING_PREFIX} "
771
809
  "after polling. The automation may still have been created -- check Home "
772
810
  "Assistant logs and try reloading automations. Common causes: "
773
811
  "automations.yaml vs automation.yaml filename mismatch, invalid config "
@@ -784,7 +822,9 @@ class AutomationConfigTools:
784
822
  if wait_bool and entity_id:
785
823
  action_word = "created" if identifier is None else "updated"
786
824
  try:
787
- registered = await wait_for_entity_registered(self._client, entity_id)
825
+ registered = await wait_for_entity_registered(
826
+ self._client, entity_id
827
+ )
788
828
  if not registered:
789
829
  result.setdefault("warnings", []).append(
790
830
  f"Automation {action_word} but {entity_id} not yet queryable. "
@@ -798,7 +838,12 @@ class AutomationConfigTools:
798
838
  # Apply category to entity registry if provided
799
839
  if effective_category and entity_id:
800
840
  await apply_entity_category(
801
- self._client, entity_id, effective_category, "automation", result, "automation"
841
+ self._client,
842
+ entity_id,
843
+ effective_category,
844
+ "automation",
845
+ result,
846
+ "automation",
802
847
  )
803
848
 
804
849
  if bp_warnings:
@@ -820,6 +865,13 @@ class AutomationConfigTools:
820
865
  except ToolError:
821
866
  raise
822
867
  except Exception as e:
868
+ # 404 during update only — create (identifier=None) never hits this branch.
869
+ if (
870
+ identifier
871
+ and isinstance(e, HomeAssistantAPIError)
872
+ and e.status_code == 404
873
+ ):
874
+ await self._raise_automation_not_found(identifier)
823
875
  suggestions = [
824
876
  "Check automation configuration format",
825
877
  "Ensure required fields: alias, trigger, action",
@@ -838,6 +890,57 @@ class AutomationConfigTools:
838
890
  suggestions=suggestions,
839
891
  )
840
892
 
893
+ async def _list_automation_entity_ids(self) -> list[str]:
894
+ """Best-effort list of automation entity_ids (up to 10) from the entity registry.
895
+
896
+ Used to populate ``available_automation_ids`` in RESOURCE_NOT_FOUND
897
+ error context. Returns an empty list on any failure — caller treats
898
+ absence as "no IDs to report" rather than failing the structured
899
+ error raise. The 10-entry cap lives here (not at the callers) so a
900
+ new call site can't accidentally bloat the error payload.
901
+ """
902
+ try:
903
+ result = await self._client.send_websocket_message(
904
+ {"type": "config/entity_registry/list"}
905
+ )
906
+ except Exception as e:
907
+ logger.debug("Failed to list automation entity_ids from registry: %s", e)
908
+ return []
909
+ entries = result.get("result", []) if isinstance(result, dict) else result
910
+ if not isinstance(entries, list):
911
+ return []
912
+ return [
913
+ entry["entity_id"]
914
+ for entry in entries
915
+ if isinstance(entry, dict)
916
+ and isinstance(entry.get("entity_id"), str)
917
+ and entry["entity_id"].startswith("automation.")
918
+ ][:10]
919
+
920
+ async def _raise_automation_not_found(self, identifier: str) -> None:
921
+ """Raise a structured RESOURCE_NOT_FOUND ToolError for a missing automation.
922
+
923
+ Single source of truth for the 404→RESOURCE_NOT_FOUND mapping used
924
+ by both the GET path (``_get_automation_config_internal``) and the
925
+ mutation paths (``ha_config_set_automation`` update branch,
926
+ ``ha_config_remove_automation``). Populates
927
+ ``available_automation_ids`` (up to 10) from the entity registry.
928
+ """
929
+ available_ids = await self._list_automation_entity_ids()
930
+ raise_tool_error(
931
+ create_error_response(
932
+ ErrorCode.RESOURCE_NOT_FOUND,
933
+ f"Automation not found: {identifier}",
934
+ context={
935
+ "automation_id": identifier,
936
+ "available_automation_ids": available_ids,
937
+ },
938
+ suggestions=[
939
+ "Use ha_search_entities(domain_filter='automation') to find existing automations"
940
+ ],
941
+ )
942
+ )
943
+
841
944
  async def _get_automation_config_internal(
842
945
  self, identifier: str
843
946
  ) -> tuple[dict[str, Any], str]:
@@ -845,8 +948,18 @@ class AutomationConfigTools:
845
948
 
846
949
  Returns (normalized_config, config_hash) tuple.
847
950
  Used internally by _fetch_and_verify_hash and ha_config_get_automation.
951
+
952
+ Raises a structured ``RESOURCE_NOT_FOUND`` ToolError via
953
+ ``_raise_automation_not_found`` when the REST client returns 404.
954
+ Other ``HomeAssistantAPIError`` instances propagate unchanged to
955
+ caller exception handlers (``exception_to_structured_error`` route).
848
956
  """
849
- config_result = await self._client.get_automation_config(identifier)
957
+ try:
958
+ config_result = await self._client.get_automation_config(identifier)
959
+ except HomeAssistantAPIError as e:
960
+ if e.status_code == 404:
961
+ await self._raise_automation_not_found(identifier)
962
+ raise
850
963
  normalized_config = _normalize_config_for_roundtrip(config_result)
851
964
  config_hash_value = compute_config_hash(normalized_config)
852
965
  return normalized_config, config_hash_value
@@ -859,7 +972,9 @@ class AutomationConfigTools:
859
972
  Returns the current normalized config dict.
860
973
  Raises ToolError if the hash does not match (conflict).
861
974
  """
862
- current_config, current_hash = await self._get_automation_config_internal(identifier)
975
+ current_config, current_hash = await self._get_automation_config_internal(
976
+ identifier
977
+ )
863
978
  if current_hash != config_hash:
864
979
  raise_tool_error(
865
980
  create_error_response(
@@ -880,22 +995,26 @@ class AutomationConfigTools:
880
995
  try:
881
996
  parsed_config = parse_json_param(config, "config")
882
997
  except ValueError as e:
883
- raise_tool_error(create_error_response(
884
- code=ErrorCode.VALIDATION_INVALID_JSON,
885
- message=f"Invalid config parameter: {e}",
886
- suggestions=[
887
- "Pass 'config' as a dict, not a JSON string, to avoid escaping issues.",
888
- "Check for JSON syntax errors: unquoted keys, trailing commas, or invalid escape sequences.",
889
- ],
890
- context={"parameter": "config"},
891
- ))
998
+ raise_tool_error(
999
+ create_error_response(
1000
+ code=ErrorCode.VALIDATION_INVALID_JSON,
1001
+ message=f"Invalid config parameter: {e}",
1002
+ suggestions=[
1003
+ "Pass 'config' as a dict, not a JSON string, to avoid escaping issues.",
1004
+ "Check for JSON syntax errors: unquoted keys, trailing commas, or invalid escape sequences.",
1005
+ ],
1006
+ context={"parameter": "config"},
1007
+ )
1008
+ )
892
1009
 
893
1010
  if parsed_config is None or not isinstance(parsed_config, dict):
894
- raise_tool_error(create_validation_error(
895
- "Config parameter must be a JSON object",
896
- parameter="config",
897
- details=f"Received type: {type(parsed_config).__name__}",
898
- ))
1011
+ raise_tool_error(
1012
+ create_validation_error(
1013
+ "Config parameter must be a JSON object",
1014
+ parameter="config",
1015
+ details=f"Received type: {type(parsed_config).__name__}",
1016
+ )
1017
+ )
899
1018
 
900
1019
  return cast(dict[str, Any], parsed_config)
901
1020
 
@@ -924,24 +1043,28 @@ class AutomationConfigTools:
924
1043
  context: dict[str, Any] = {"missing_fields": missing_fields}
925
1044
  if identifier:
926
1045
  context["identifier"] = identifier
927
- raise_tool_error(create_error_response(
928
- code=ErrorCode.CONFIG_MISSING_REQUIRED_FIELDS,
929
- message=f"Missing required fields: {', '.join(missing_fields)}",
930
- details=(
931
- "Config contains 'sequence', which belongs to scripts. "
932
- "Automations use 'trigger' and 'action'; scripts use 'sequence'."
933
- ),
934
- suggestions=[
935
- "Did you mean ha_config_set_script? Scripts use 'sequence' directly.",
936
- "For an automation, replace 'sequence' with 'action' and add a 'trigger'.",
937
- ],
938
- context=context,
939
- ))
940
- raise_tool_error(create_config_error(
941
- f"Missing required fields: {', '.join(missing_fields)}",
942
- identifier=identifier,
943
- missing_fields=missing_fields,
944
- ))
1046
+ raise_tool_error(
1047
+ create_error_response(
1048
+ code=ErrorCode.CONFIG_MISSING_REQUIRED_FIELDS,
1049
+ message=f"Missing required fields: {', '.join(missing_fields)}",
1050
+ details=(
1051
+ "Config contains 'sequence', which belongs to scripts. "
1052
+ "Automations use 'trigger' and 'action'; scripts use 'sequence'."
1053
+ ),
1054
+ suggestions=[
1055
+ "Did you mean ha_config_set_script? Scripts use 'sequence' directly.",
1056
+ "For an automation, replace 'sequence' with 'action' and add a 'trigger'.",
1057
+ ],
1058
+ context=context,
1059
+ )
1060
+ )
1061
+ raise_tool_error(
1062
+ create_config_error(
1063
+ f"Missing required fields: {', '.join(missing_fields)}",
1064
+ identifier=identifier,
1065
+ missing_fields=missing_fields,
1066
+ )
1067
+ )
945
1068
 
946
1069
  # Issue #1169: reject configs that wrap ``scene.create`` in an
947
1070
  # automation with no functional trigger. Models occasionally produce
@@ -966,31 +1089,33 @@ class AutomationConfigTools:
966
1089
  if _action_contains_scene_create(a)
967
1090
  ]
968
1091
  if scene_create_indices:
969
- raise_tool_error(create_error_response(
970
- code=ErrorCode.VALIDATION_INVALID_PARAMETER,
971
- message=(
972
- "Empty trigger paired with a scene.create action — "
973
- "this automation can never fire. For a state snapshot "
974
- "of one or more entities, use ha_config_set_scene "
975
- "directly instead of wrapping scene.create in an "
976
- "automation."
977
- ),
978
- suggestions=[
979
- "ha_config_set_scene(scene_id='...', config={'name': "
980
- "'...', 'entities': {'<entity_id>': {...}}}) creates "
981
- "a scene without a trigger.",
982
- "If the snapshot really should be the result of an "
983
- "event, add the trigger that should fire it and keep "
984
- "the automation.",
985
- "For a state-derived value that recomputes when its "
986
- "inputs change, use "
987
- "ha_config_set_helper(helper_type='template') instead.",
988
- ],
989
- context={
990
- "scene_create_action_indices": scene_create_indices,
991
- "identifier": identifier,
992
- },
993
- ))
1092
+ raise_tool_error(
1093
+ create_error_response(
1094
+ code=ErrorCode.VALIDATION_INVALID_PARAMETER,
1095
+ message=(
1096
+ "Empty trigger paired with a scene.create action "
1097
+ "this automation can never fire. For a state snapshot "
1098
+ "of one or more entities, use ha_config_set_scene "
1099
+ "directly instead of wrapping scene.create in an "
1100
+ "automation."
1101
+ ),
1102
+ suggestions=[
1103
+ "ha_config_set_scene(scene_id='...', config={'name': "
1104
+ "'...', 'entities': {'<entity_id>': {...}}}) creates "
1105
+ "a scene without a trigger.",
1106
+ "If the snapshot really should be the result of an "
1107
+ "event, add the trigger that should fire it and keep "
1108
+ "the automation.",
1109
+ "For a state-derived value that recomputes when its "
1110
+ "inputs change, use "
1111
+ "ha_config_set_helper(helper_type='template') instead.",
1112
+ ],
1113
+ context={
1114
+ "scene_create_action_indices": scene_create_indices,
1115
+ "identifier": identifier,
1116
+ },
1117
+ )
1118
+ )
994
1119
 
995
1120
  # HA accepts conditions with 'platform' (trigger syntax) but then crashes
996
1121
  # with an unhelpful 500 rather than a 400 validation error.
@@ -998,30 +1123,34 @@ class AutomationConfigTools:
998
1123
  if not isinstance(cond, dict):
999
1124
  continue
1000
1125
  if "platform" in cond and "condition" not in cond:
1001
- raise_tool_error(create_error_response(
1002
- code=ErrorCode.VALIDATION_INVALID_PARAMETER,
1003
- message=(
1004
- f"Condition at index {idx} uses 'platform' (trigger syntax). "
1005
- "Conditions use 'condition', not 'platform'."
1006
- ),
1007
- suggestions=[
1008
- f"Replace 'platform' with 'condition': "
1009
- f"{{'condition': '{cond['platform']}', ...}}",
1010
- "Triggers use 'platform'; conditions use 'condition'.",
1011
- ],
1012
- context={"condition_index": idx, "found_key": "platform"},
1013
- ))
1126
+ raise_tool_error(
1127
+ create_error_response(
1128
+ code=ErrorCode.VALIDATION_INVALID_PARAMETER,
1129
+ message=(
1130
+ f"Condition at index {idx} uses 'platform' (trigger syntax). "
1131
+ "Conditions use 'condition', not 'platform'."
1132
+ ),
1133
+ suggestions=[
1134
+ f"Replace 'platform' with 'condition': "
1135
+ f"{{'condition': '{cond['platform']}', ...}}",
1136
+ "Triggers use 'platform'; conditions use 'condition'.",
1137
+ ],
1138
+ context={"condition_index": idx, "found_key": "platform"},
1139
+ )
1140
+ )
1014
1141
 
1015
1142
  # Prevent duplicate creation when config contains an existing automation id
1016
1143
  if identifier is None and "id" in config_dict:
1017
1144
  existing_id = config_dict["id"]
1018
- raise_tool_error(create_validation_error(
1019
- f"Config contains 'id' field ('{existing_id}') but no identifier was provided. "
1020
- "This would create a duplicate automation instead of updating the existing one.",
1021
- parameter="identifier",
1022
- details=f"To update, pass identifier='{existing_id}' (or the automation's entity_id). "
1023
- "To create a genuinely new automation, remove the 'id' field from the config.",
1024
- ))
1145
+ raise_tool_error(
1146
+ create_validation_error(
1147
+ f"Config contains 'id' field ('{existing_id}') but no identifier was provided. "
1148
+ "This would create a duplicate automation instead of updating the existing one.",
1149
+ parameter="identifier",
1150
+ details=f"To update, pass identifier='{existing_id}' (or the automation's entity_id). "
1151
+ "To create a genuinely new automation, remove the 'id' field from the config.",
1152
+ )
1153
+ )
1025
1154
 
1026
1155
  @tool(
1027
1156
  name="ha_config_remove_automation",
@@ -1086,7 +1215,9 @@ class AutomationConfigTools:
1086
1215
  wait_bool = coerce_bool_param(wait, "wait", default=True)
1087
1216
  if wait_bool and entity_id_for_wait:
1088
1217
  try:
1089
- removed = await wait_for_entity_removed(self._client, entity_id_for_wait)
1218
+ removed = await wait_for_entity_removed(
1219
+ self._client, entity_id_for_wait
1220
+ )
1090
1221
  if not removed:
1091
1222
  result.setdefault("warnings", []).append(
1092
1223
  f"Deletion confirmed by API but {entity_id_for_wait} may still appear briefly."
@@ -1107,6 +1238,8 @@ class AutomationConfigTools:
1107
1238
  except ToolError:
1108
1239
  raise
1109
1240
  except Exception as e:
1241
+ if isinstance(e, HomeAssistantAPIError) and e.status_code == 404:
1242
+ await self._raise_automation_not_found(identifier)
1110
1243
  exception_to_structured_error(
1111
1244
  e,
1112
1245
  context={"identifier": identifier, "action": "delete"},
@@ -1530,8 +1530,13 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1530
1530
  ],
1531
1531
  context={"action": "delete"},
1532
1532
  )
1533
- resolved, _ = await _resolve_dashboard(client, url_path)
1533
+ resolved, dashboards = await _resolve_dashboard(client, url_path)
1534
1534
  if resolved is None:
1535
+ available_ids = [
1536
+ d.get("url_path")
1537
+ for d in (dashboards or [])[:10]
1538
+ if d.get("url_path")
1539
+ ]
1535
1540
  raise_tool_error(
1536
1541
  create_error_response(
1537
1542
  ErrorCode.RESOURCE_NOT_FOUND,
@@ -1541,7 +1546,11 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1541
1546
  "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
1542
1547
  "YAML-mode and default dashboards are not deletable via this tool",
1543
1548
  ],
1544
- context={"action": "delete", "url_path": url_path},
1549
+ context={
1550
+ "action": "delete",
1551
+ "url_path": url_path,
1552
+ "available_dashboard_ids": available_ids,
1553
+ },
1545
1554
  )
1546
1555
  )
1547
1556
  resolved_id = resolved["id"]