ha-mcp-dev 7.5.0.dev571__tar.gz → 7.5.0.dev573__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.dev571/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev573}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/client/rest_client.py +32 -34
  4. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/client/websocket_client.py +12 -1
  5. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_automations.py +4 -5
  6. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/util_helpers.py +165 -20
  7. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/LICENSE +0 -0
  9. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/README.md +0 -0
  11. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/setup.cfg +0 -0
  12. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/__init__.py +0 -0
  13. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/__main__.py +0 -0
  14. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/_pypi_marker +0 -0
  15. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/_version.py +0 -0
  16. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/client/supervisor_client.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/client/websocket_listener.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/config.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/errors.py +0 -0
  24. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/py.typed +0 -0
  25. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  26. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  27. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  28. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  29. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  30. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  31. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  32. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  33. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  34. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  35. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  36. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  37. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  38. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  39. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  40. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  41. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  42. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  43. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  46. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  47. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/server.py +0 -0
  48. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/settings_ui.py +0 -0
  49. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/smoke_test.py +0 -0
  50. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/__init__.py +0 -0
  51. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/backup.py +0 -0
  52. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/device_control.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/enhanced.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/helpers.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/reference_validator.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/registry.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/smart_search.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_addons.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_areas.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_calendar.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_camera.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_categories.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_code.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_energy.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_entities.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_groups.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_hacs.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_history.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_integrations.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_labels.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_registry.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_resources.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_search.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_service.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_services.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_system.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_todo.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_traces.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_updates.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_utility.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/tools/tools_zones.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/transforms/__init__.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/transforms/categorized_search.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/__init__.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/config_hash.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/data_paths.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/domain_handlers.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/operation_manager.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/python_sandbox.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp/utils/usage_logger.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  107. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  108. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  109. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  110. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  111. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/tests/__init__.py +0 -0
  112. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/tests/test_constants.py +0 -0
  113. {ha_mcp_dev-7.5.0.dev571 → ha_mcp_dev-7.5.0.dev573}/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.dev571
3
+ Version: 7.5.0.dev573
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.dev571"
7
+ version = "7.5.0.dev573"
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"
@@ -895,8 +895,6 @@ class HomeAssistantClient:
895
895
  Raises:
896
896
  HomeAssistantAPIError: If configuration invalid or API error
897
897
  """
898
- import time
899
-
900
898
  # Generate unique_id for new automation if not provided
901
899
  if identifier is None:
902
900
  unique_id = str(int(time.time() * 1000))
@@ -964,51 +962,51 @@ class HomeAssistantClient:
964
962
  ) from e
965
963
  raise
966
964
 
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)
965
+ # Upper-bound budget for the WS-driven discovery wait (and its REST
966
+ # fallback). Preserves the 6s ceiling PR #1384 tuned for the legacy
967
+ # cadence loop; the WS path resolves in <1s on the happy path because
968
+ # ``state_changed`` fires as soon as HA hydrates the new entity. The
969
+ # cadence tuple itself is gone uniform polling at ``poll_interval``
970
+ # is fine when the WS happy path is the primary route. #1389's
971
+ # cadence-measurement instrumentation also retired with the loop;
972
+ # the WS waiter logs `time.monotonic()` elapsed via its own debug
973
+ # line at ``util_helpers._ws_wait_for_condition``.
974
+ _POLL_BUDGET_S: float = 6.0
973
975
 
974
976
  async def _poll_for_automation_entity(self, unique_id: str) -> str | None:
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()
977
+ """Discover the entity_id assigned to a newly created automation.
978
+
979
+ Delegates to ``wait_for_automation_entity_by_unique_id`` which uses
980
+ a ``state_changed`` WebSocket subscription filtered on
981
+ ``new_state.attributes.id == unique_id`` and falls back to REST
982
+ polling of ``get_states()`` when the WebSocket is unavailable.
983
+ See #1152 / #1395 for context.
984
+ """
985
+ # Local import: ``util_helpers`` imports from ``rest_client`` for
986
+ # the typed-error classes, so a module-level import here would be
987
+ # circular. Mirrors the same pattern in ``_get_waiter_ws_client``.
988
+ from ..tools.util_helpers import wait_for_automation_entity_by_unique_id
989
+
979
990
  try:
980
- for sleep_time in self._POLL_CADENCE:
981
- await asyncio.sleep(sleep_time)
982
- states = await self.get_states()
983
- for state in states:
984
- if not state.get("entity_id", "").startswith("automation."):
985
- continue
986
- if state.get("attributes", {}).get("id") == unique_id:
987
- entity_id = state.get("entity_id")
988
- elapsed_ms = (time.monotonic() - start_monotonic) * 1000.0
989
- logger.debug(
990
- "entity-registration-elapsed: %.1fms "
991
- "(unique_id=%s, entity_id=%s)",
992
- elapsed_ms,
993
- unique_id,
994
- entity_id,
995
- )
996
- return entity_id
991
+ entity_id = await wait_for_automation_entity_by_unique_id(
992
+ self, unique_id, timeout=self._POLL_BUDGET_S
993
+ )
997
994
  except HomeAssistantError as e:
998
995
  # Narrow catch: programming bugs (TypeError/KeyError/etc.) propagate.
999
996
  # Mirrors test-side _POLLING_TRANSIENT_ERRORS in
1000
997
  # tests/src/e2e/utilities/wait_helpers.py and styleguide §
1001
998
  # "Exception Handling in Test Polling Loops".
1002
999
  logger.warning(
1003
- f"Failed to query actual entity_id for unique_id {unique_id}: {e}",
1000
+ f"Failed to discover entity_id for unique_id {unique_id}: {e}",
1004
1001
  exc_info=True,
1005
1002
  )
1006
1003
  return None
1007
1004
 
1008
- logger.warning(
1009
- f"Automation with unique_id {unique_id} was not found in HA state after creation"
1010
- )
1011
- return None
1005
+ if entity_id is not None:
1006
+ logger.debug(
1007
+ f"Found actual entity_id for unique_id {unique_id}: {entity_id}"
1008
+ )
1009
+ return entity_id
1012
1010
 
1013
1011
  async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
1014
1012
  """
@@ -400,7 +400,18 @@ class HomeAssistantWebSocketClient:
400
400
  try:
401
401
  await handler(data["event"])
402
402
  except Exception as e:
403
- logger.error(f"Error in event handler: {e}")
403
+ # ``exc_info=True`` so handler bugs (AttributeError /
404
+ # KeyError / TypeError from schema-drift on the
405
+ # incoming event payload) leave a traceback rather
406
+ # than a one-line obscured error. Without this the
407
+ # dispatch loop keeps a single buggy handler from
408
+ # killing the WS, but the bug itself becomes
409
+ # invisible — handlers wired to ``asyncio.Event``
410
+ # nudges (see ``util_helpers._ws_wait_for_condition``)
411
+ # silently stop nudging and the calling waiter times
412
+ # out reporting "not found." #1395 silent-failure
413
+ # audit.
414
+ logger.error("Error in event handler: %s", e, exc_info=True)
404
415
 
405
416
  def _ensure_send_lock(self) -> None:
406
417
  """Ensure the send lock belongs to the current event loop."""
@@ -56,11 +56,10 @@ logger = logging.getLogger(__name__)
56
56
 
57
57
  # Distinctive prefix of the soft-failure warning emitted by
58
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.
59
+ # exhausts its budget (``_POLL_BUDGET_S``) without resolving the new
60
+ # automation's entity_id. Exported so future tests can detect a missed
61
+ # registration without hard-coding the literal — rewording the warning
62
+ # becomes a compile-time coupling rather than a silent test drift.
64
63
  NOT_VERIFIED_WARNING_PREFIX = (
65
64
  "Automation was submitted to Home Assistant but the entity was not found"
66
65
  )
@@ -614,7 +614,7 @@ costs at most ~6 REST calls per wait (one post-subscribe sample plus
614
614
 
615
615
 
616
616
  async def _legacy_poll_until(
617
- entity_id: str,
617
+ identifier: str,
618
618
  sample: Callable[[], Awaitable[Any]],
619
619
  *,
620
620
  timeout: float,
@@ -627,7 +627,10 @@ async def _legacy_poll_until(
627
627
  backstop tick — it returns a truthy value when the wait should
628
628
  succeed, ``None`` otherwise. Connection / auth errors propagate
629
629
  (callers care about those); other transient errors raised inside
630
- ``sample`` are swallowed there.
630
+ ``sample`` are swallowed there. ``identifier`` is the human-readable
631
+ name used in log lines — usually an entity_id but may be a
632
+ descriptor like ``automation[unique_id=...]`` for discovery waits
633
+ that don't know the entity_id up front.
631
634
  """
632
635
  start = time.monotonic()
633
636
  while time.monotonic() - start < timeout:
@@ -635,7 +638,7 @@ async def _legacy_poll_until(
635
638
  result = await sample()
636
639
  if result is not None:
637
640
  logger.debug(
638
- f"REST waiter: {description} for {entity_id} resolved "
641
+ f"REST waiter: {description} for {identifier} resolved "
639
642
  f"after {time.monotonic() - start:.2f}s"
640
643
  )
641
644
  return result
@@ -643,7 +646,7 @@ async def _legacy_poll_until(
643
646
  raise
644
647
  await asyncio.sleep(poll_interval)
645
648
  logger.warning(
646
- f"REST fallback: {description} for {entity_id} timed out after {timeout}s"
649
+ f"REST fallback: {description} for {identifier} timed out after {timeout}s"
647
650
  )
648
651
  return None
649
652
 
@@ -690,22 +693,23 @@ async def _get_waiter_ws_client(client: Any) -> Any:
690
693
 
691
694
  async def _ws_wait_for_condition(
692
695
  client: Any,
693
- entity_id: str,
696
+ identifier: str,
694
697
  sample: Callable[[], Awaitable[Any]],
695
698
  *,
696
699
  event_types: tuple[str, ...],
697
700
  timeout: float,
698
701
  poll_interval: float,
699
702
  description: str,
703
+ event_filter: Callable[[dict[str, Any]], bool] | None = None,
700
704
  ) -> Any:
701
705
  """Subscribe to ``event_types``, sample after subscribe, wait on event.
702
706
 
703
707
  Implements the standard "subscribe → sample → wait" pattern from #1152:
704
708
 
705
709
  - The handler nudges a single ``asyncio.Event`` whenever HA pushes an
706
- event for our ``entity_id``. The main loop wakes on that nudge or on
707
- the polling-backstop timeout, then re-runs ``sample`` (the REST
708
- source-of-truth check) to decide whether the wait succeeded.
710
+ event matching ``event_filter``. The main loop wakes on that nudge
711
+ or on the polling-backstop timeout, then re-runs ``sample`` (the
712
+ REST source-of-truth check) to decide whether the wait succeeded.
709
713
  - Sample-after-subscribe (not before) closes the gap between the
710
714
  caller's write returning and our subscription landing on the HA
711
715
  side. The event for the write may have already fired by the time we
@@ -714,12 +718,22 @@ async def _ws_wait_for_condition(
714
718
  we fall back to ``_legacy_poll_until``. The helpers' contract is
715
719
  identical to the pre-#1152 REST loop in that case.
716
720
 
721
+ ``identifier`` is used only for log lines — usually an entity_id but
722
+ may be a descriptor like ``automation[unique_id=...]`` for discovery
723
+ waits (#1395) that don't know the entity_id up front. When
724
+ ``event_filter`` is None the default predicate matches events whose
725
+ ``data["entity_id"]`` equals ``identifier`` — i.e. the standard
726
+ "watch this entity_id" shape used by ``wait_for_entity_*`` /
727
+ ``wait_for_state_change``. Callers that need a different match shape
728
+ (e.g. "any automation with attributes.id == unique_id") pass a custom
729
+ ``event_filter``.
730
+
717
731
  Returns ``sample``'s truthy return value, or ``None`` on timeout.
718
732
  """
719
733
  ws_client = await _get_waiter_ws_client(client)
720
734
  if ws_client is None:
721
735
  return await _legacy_poll_until(
722
- entity_id,
736
+ identifier,
723
737
  sample,
724
738
  timeout=timeout,
725
739
  poll_interval=poll_interval,
@@ -728,14 +742,19 @@ async def _ws_wait_for_condition(
728
742
 
729
743
  nudge = asyncio.Event()
730
744
 
731
- async def handler(event: dict[str, Any]) -> None:
745
+ def _default_filter(event: dict[str, Any]) -> bool:
732
746
  # HA nests ``entity_id`` under ``event["data"]`` for both
733
747
  # state_changed and entity_registry_updated. The top-level fallback
734
748
  # is defensive only — it lets a future schema drift degrade to a
735
749
  # missed nudge rather than an AttributeError.
736
750
  data = event.get("data") or {}
737
751
  evt_entity = data.get("entity_id") or event.get("entity_id")
738
- if evt_entity == entity_id:
752
+ return bool(evt_entity == identifier)
753
+
754
+ filter_fn = event_filter if event_filter is not None else _default_filter
755
+
756
+ async def handler(event: dict[str, Any]) -> None:
757
+ if filter_fn(event):
739
758
  nudge.set()
740
759
 
741
760
  # Track which handlers / subscriptions we actually attached so cleanup
@@ -763,11 +782,11 @@ async def _ws_wait_for_condition(
763
782
  "falling back to REST polling",
764
783
  et,
765
784
  description,
766
- entity_id,
785
+ identifier,
767
786
  e,
768
787
  )
769
788
  return await _legacy_poll_until(
770
- entity_id,
789
+ identifier,
771
790
  sample,
772
791
  timeout=timeout,
773
792
  poll_interval=poll_interval,
@@ -782,7 +801,7 @@ async def _ws_wait_for_condition(
782
801
  result = await sample()
783
802
  if result is not None:
784
803
  logger.debug(
785
- f"WS waiter: {description} for {entity_id} resolved by "
804
+ f"WS waiter: {description} for {identifier} resolved by "
786
805
  f"post-subscribe sample after {time.monotonic() - start:.2f}s"
787
806
  )
788
807
  return result
@@ -798,13 +817,13 @@ async def _ws_wait_for_condition(
798
817
  "WS connection dropped before wait loop for %s on %s — "
799
818
  "completing via REST polling",
800
819
  description,
801
- entity_id,
820
+ identifier,
802
821
  )
803
822
  remaining = timeout - (time.monotonic() - start)
804
823
  if remaining <= 0:
805
824
  return None
806
825
  return await _legacy_poll_until(
807
- entity_id,
826
+ identifier,
808
827
  sample,
809
828
  timeout=remaining,
810
829
  poll_interval=poll_interval,
@@ -834,13 +853,13 @@ async def _ws_wait_for_condition(
834
853
  "WS connection dropped during %s for %s — completing "
835
854
  "wait via REST polling",
836
855
  description,
837
- entity_id,
856
+ identifier,
838
857
  )
839
858
  remaining = timeout - (time.monotonic() - start)
840
859
  if remaining <= 0:
841
860
  return None
842
861
  return await _legacy_poll_until(
843
- entity_id,
862
+ identifier,
844
863
  sample,
845
864
  timeout=remaining,
846
865
  poll_interval=poll_interval,
@@ -851,7 +870,7 @@ async def _ws_wait_for_condition(
851
870
  result = await sample()
852
871
  if result is not None:
853
872
  logger.debug(
854
- f"WS waiter: {description} for {entity_id} resolved "
873
+ f"WS waiter: {description} for {identifier} resolved "
855
874
  f"after {time.monotonic() - start:.2f}s"
856
875
  )
857
876
  return result
@@ -859,7 +878,7 @@ async def _ws_wait_for_condition(
859
878
  raise
860
879
 
861
880
  logger.warning(
862
- f"WS waiter: {description} for {entity_id} timed out after {timeout}s"
881
+ f"WS waiter: {description} for {identifier} timed out after {timeout}s"
863
882
  )
864
883
  return None
865
884
  finally:
@@ -1084,6 +1103,132 @@ async def wait_for_state_change(
1084
1103
  return None
1085
1104
 
1086
1105
 
1106
+ async def wait_for_automation_entity_by_unique_id(
1107
+ client: Any,
1108
+ unique_id: str,
1109
+ timeout: float = 6.0,
1110
+ poll_interval: float = 0.3,
1111
+ ) -> str | None:
1112
+ """
1113
+ Discover the entity_id assigned to a newly-created automation by unique_id.
1114
+
1115
+ Used after ``POST /config/automation/config/{unique_id}`` to resolve the
1116
+ ``automation.<slug>`` entity_id Home Assistant assigned. Listens to
1117
+ ``state_changed`` events filtered to ``automation.*`` entities whose
1118
+ ``new_state.attributes.id`` equals ``unique_id`` — HA's
1119
+ ``BaseAutomationEntity.capability_attributes`` exposes ``unique_id`` as
1120
+ ``CONF_ID`` on every emit, so the first state event for a fresh
1121
+ automation carries the match. Falls back to REST polling of
1122
+ ``get_states()`` when the WebSocket is unavailable. See #1152 / #1395.
1123
+
1124
+ Args:
1125
+ client: HomeAssistantClient instance
1126
+ unique_id: The unique_id passed to ``POST /config/automation/config/{unique_id}``
1127
+ timeout: Maximum time to wait in seconds (preserves the legacy 6s budget)
1128
+ poll_interval: REST poll interval used for the WS-unavailable fallback
1129
+
1130
+ Returns:
1131
+ The discovered entity_id (e.g. ``"automation.morning_routine"``)
1132
+ or ``None`` on timeout.
1133
+ """
1134
+ # Mutable closure cells: ``entity_id`` stashes the discovered
1135
+ # entity_id when the filter sees a matching event (sample() then
1136
+ # short-circuits the full get_states() scan). ``last_api_error``
1137
+ # tracks the most recent transient API failure during sampling so
1138
+ # the final timeout warning can distinguish "automation truly not
1139
+ # found" from "REST channel wedged the whole budget."
1140
+ captured: dict[str, str | None] = {"entity_id": None, "last_api_error": None}
1141
+
1142
+ async def sample() -> str | None:
1143
+ if captured["entity_id"] is not None:
1144
+ return captured["entity_id"]
1145
+ try:
1146
+ states = await client.get_states()
1147
+ except HomeAssistantAPIError as e:
1148
+ # Debug-level here is intentional — the waiter retries on
1149
+ # transient errors. The wedged-channel signal goes in the
1150
+ # final timeout warning via ``captured["last_api_error"]``.
1151
+ logger.debug(
1152
+ f"API error sampling get_states() for unique_id {unique_id}: {e}"
1153
+ )
1154
+ captured["last_api_error"] = str(e)
1155
+ return None
1156
+ for state in states:
1157
+ entity_id = state.get("entity_id")
1158
+ if not isinstance(entity_id, str) or not entity_id.startswith(
1159
+ "automation."
1160
+ ):
1161
+ continue
1162
+ if state.get("attributes", {}).get("id") == unique_id:
1163
+ return entity_id
1164
+ return None
1165
+
1166
+ def event_filter(event: dict[str, Any]) -> bool:
1167
+ # state_changed payload shape:
1168
+ # event["data"] = {"entity_id": ..., "new_state": {"attributes": {...}}}
1169
+ # capability_attributes on BaseAutomationEntity guarantees attributes.id
1170
+ # carries the unique_id on the first state emission (the caller has
1171
+ # always just POSTed /config/automation/config/{unique_id}, so
1172
+ # unique_id is non-None by construction).
1173
+ #
1174
+ # Defensive ``isinstance`` guards mirror the ``sample()`` callback
1175
+ # above — the WS dispatcher swallows handler exceptions broadly
1176
+ # (``websocket_client.py``'s ``except Exception`` in the dispatch
1177
+ # loop), so a malformed payload reaching ``.startswith`` here would
1178
+ # silently fail to nudge and the wait would time out reporting
1179
+ # "not found" when the real cause was schema drift. Same shape-
1180
+ # hardening pattern as ``sample()``.
1181
+ data = event.get("data") or {}
1182
+ evt_entity = data.get("entity_id")
1183
+ if not isinstance(evt_entity, str) or not evt_entity.startswith("automation."):
1184
+ return False
1185
+ new_state = data.get("new_state") or {}
1186
+ attrs = new_state.get("attributes") if isinstance(new_state, dict) else None
1187
+ if not isinstance(attrs, dict) or attrs.get("id") != unique_id:
1188
+ return False
1189
+ # Guard against last-writer-wins: if two events for matching
1190
+ # automations arrived (HA storage forbids duplicate unique_id, but
1191
+ # don't coin-flip silently if it ever happens), keep the first
1192
+ # observed entity_id and log the collision.
1193
+ if captured["entity_id"] is None:
1194
+ captured["entity_id"] = evt_entity
1195
+ elif captured["entity_id"] != evt_entity:
1196
+ logger.warning(
1197
+ "Duplicate automation match for unique_id %s: %s already "
1198
+ "captured, ignoring %s",
1199
+ unique_id,
1200
+ captured["entity_id"],
1201
+ evt_entity,
1202
+ )
1203
+ return True
1204
+
1205
+ result = await _ws_wait_for_condition(
1206
+ client,
1207
+ identifier=f"automation[unique_id={unique_id}]",
1208
+ sample=sample,
1209
+ event_types=("state_changed",),
1210
+ timeout=timeout,
1211
+ poll_interval=poll_interval,
1212
+ description="automation entity discovery",
1213
+ event_filter=event_filter,
1214
+ )
1215
+ if isinstance(result, str):
1216
+ return result
1217
+ # `_ws_wait_for_condition` / `_legacy_poll_until` already logged the
1218
+ # generic "timed out" warning before returning None; just surface the
1219
+ # discovery-specific signal when REST sampling was wedged the whole
1220
+ # budget so operators can distinguish "automation never published"
1221
+ # from "REST channel down."
1222
+ if captured["last_api_error"] is not None:
1223
+ logger.warning(
1224
+ "Automation discovery for unique_id %s timed out with every "
1225
+ "REST sample failing; last error: %s",
1226
+ unique_id,
1227
+ captured["last_api_error"],
1228
+ )
1229
+ return None
1230
+
1231
+
1087
1232
  async def fetch_entity_category(client: Any, entity_id: str, scope: str) -> str | None:
1088
1233
  """Fetch a category ID for an entity from the entity registry.
1089
1234
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev571
3
+ Version: 7.5.0.dev573
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