ha-mcp-dev 7.5.0.dev564__tar.gz → 7.5.0.dev565__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.dev564/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev565}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/rest_client.py +14 -0
  4. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/websocket_client.py +52 -13
  5. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/util_helpers.py +432 -86
  6. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  7. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/LICENSE +0 -0
  8. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/MANIFEST.in +0 -0
  9. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/README.md +0 -0
  10. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/setup.cfg +0 -0
  11. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/__init__.py +0 -0
  12. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/__main__.py +0 -0
  13. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/_pypi_marker +0 -0
  14. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/_version.py +0 -0
  15. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/__init__.py +0 -0
  16. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/consent_form.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/provider.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/__init__.py +0 -0
  19. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/supervisor_client.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/py.typed +0 -0
  24. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  25. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  26. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  27. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  28. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  29. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  30. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  31. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  32. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  33. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  34. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  35. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  36. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  37. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  38. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  39. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  40. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  41. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  42. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  43. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  46. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/server.py +0 -0
  47. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/settings_ui.py +0 -0
  48. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/backup.py +0 -0
  51. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  52. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/device_control.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/enhanced.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/helpers.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_addons.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_areas.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_calendar.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_camera.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_categories.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_code.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_energy.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_entities.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_groups.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_hacs.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_history.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_integrations.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_labels.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_registry.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_resources.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_search.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_service.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_services.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_system.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_todo.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_traces.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_updates.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_utility.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_zones.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/__init__.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/categorized_search.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/__init__.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/config_hash.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/data_paths.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/domain_handlers.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/operation_manager.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/python_sandbox.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/usage_logger.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  107. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  108. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  109. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  110. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  111. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/tests/__init__.py +0 -0
  112. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/tests/test_constants.py +0 -0
  113. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/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.dev564
3
+ Version: 7.5.0.dev565
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.dev564"
7
+ version = "7.5.0.dev565"
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"
@@ -82,6 +82,20 @@ class HomeAssistantCommandError(HomeAssistantError):
82
82
  """
83
83
 
84
84
 
85
+ class HomeAssistantCommandTimeout(HomeAssistantError):
86
+ """WebSocket ``send_command`` timed out waiting for HA's response.
87
+
88
+ Sibling of ``HomeAssistantCommandError`` (not a subclass) so existing
89
+ ``except HomeAssistantCommandError`` sites — including the match
90
+ dispatch in ``helpers._classify_exception`` — keep their original
91
+ semantics. Callers that specifically want to handle our 30s WS
92
+ round-trip timeout (e.g. short-lived waiter cleanup that should
93
+ swallow a timeout instead of masking the real wait result) catch
94
+ this type directly. Replaces a bare ``Exception("Command timeout")``
95
+ string-match pattern (#1382 Patch76 review).
96
+ """
97
+
98
+
85
99
  class HomeAssistantClient:
86
100
  """Authenticated HTTP client for Home Assistant API."""
87
101
 
@@ -23,6 +23,7 @@ import websockets
23
23
  from ..config import get_global_settings
24
24
  from .rest_client import (
25
25
  HomeAssistantCommandError,
26
+ HomeAssistantCommandTimeout,
26
27
  HomeAssistantConnectionError,
27
28
  _is_ssl_error,
28
29
  )
@@ -58,6 +59,7 @@ class WebSocketConnectionState:
58
59
  )
59
60
  self._pending_requests[message_id] = future
60
61
  return future
62
+
61
63
  def resolve_pending_request(
62
64
  self, message_id: int
63
65
  ) -> asyncio.Future[dict[str, Any]] | None:
@@ -262,7 +264,9 @@ class HomeAssistantWebSocketClient:
262
264
  message_type="auth_required", timeout=5
263
265
  )
264
266
  if not auth_msg:
265
- raise HomeAssistantConnectionError("Did not receive auth_required message")
267
+ raise HomeAssistantConnectionError(
268
+ "Did not receive auth_required message"
269
+ )
266
270
 
267
271
  # Send authentication
268
272
  await self._send_auth()
@@ -511,7 +515,7 @@ class HomeAssistantWebSocketClient:
511
515
 
512
516
  except TimeoutError as e:
513
517
  self.cancel_pending_response(message_id)
514
- raise Exception("Command timeout") from e
518
+ raise HomeAssistantCommandTimeout("Command timeout") from e
515
519
  except Exception:
516
520
  self.cancel_pending_response(message_id)
517
521
  raise
@@ -572,9 +576,7 @@ class HomeAssistantWebSocketClient:
572
576
  raise HomeAssistantCommandError(f"Command failed: {error_msg}")
573
577
 
574
578
  try:
575
- event_response = await asyncio.wait_for(
576
- event_future, timeout=wait_timeout
577
- )
579
+ event_response = await asyncio.wait_for(event_future, timeout=wait_timeout)
578
580
  except TimeoutError:
579
581
  self.cancel_event_response(message_id)
580
582
  raise
@@ -639,13 +641,50 @@ class HomeAssistantWebSocketClient:
639
641
 
640
642
  error = response.get("error", {})
641
643
  error_msg = (
642
- error.get("message", str(error))
643
- if isinstance(error, dict)
644
- else str(error)
645
- )
646
- raise HomeAssistantCommandError(
647
- f"subscribe_events failed: {error_msg}"
644
+ error.get("message", str(error)) if isinstance(error, dict) else str(error)
648
645
  )
646
+ raise HomeAssistantCommandError(f"subscribe_events failed: {error_msg}")
647
+
648
+ async def unsubscribe_events(self, subscription_id: int) -> None:
649
+ """Release a subscription previously returned by ``subscribe_events``.
650
+
651
+ Used by short-lived waiters (``util_helpers.wait_for_*``) that need
652
+ to drop the subscription as soon as their event arrives so the
653
+ shared socket doesn't accumulate stale ``state_changed`` listeners.
654
+
655
+ Exception policy (narrow, distinct log levels — Gemini #1382):
656
+
657
+ - Transport-level loss (``OSError``): subscription is implicitly
658
+ gone with the connection. Logged at ``debug`` so HA-mid-restart
659
+ cleanup doesn't spam warnings.
660
+ - HA-side rejection (``HomeAssistantCommandError``, e.g. "Subscription
661
+ not found" after a server-side reset): unexpected during normal
662
+ cleanup. Logged at ``warning`` so a real subscription leak is
663
+ discoverable.
664
+ - Everything else: propagates to the caller's ``finally`` so a
665
+ programming bug (TypeError, AttributeError) fails loudly instead
666
+ of being buried under a broad ``except``.
667
+ """
668
+ if not self._state.is_ready:
669
+ logger.debug(
670
+ "unsubscribe_events(%s) skipped: WebSocket not ready",
671
+ subscription_id,
672
+ )
673
+ return
674
+ try:
675
+ await self.send_command("unsubscribe_events", subscription=subscription_id)
676
+ except OSError as e:
677
+ logger.debug(
678
+ "unsubscribe_events(%s): transport lost during cleanup: %s",
679
+ subscription_id,
680
+ e,
681
+ )
682
+ except HomeAssistantCommandError as e:
683
+ logger.warning(
684
+ "unsubscribe_events(%s) rejected by HA: %s",
685
+ subscription_id,
686
+ e,
687
+ )
649
688
 
650
689
  def add_event_handler(
651
690
  self,
@@ -721,7 +760,6 @@ class HomeAssistantWebSocketClient:
721
760
  return self._state.is_ready
722
761
 
723
762
 
724
-
725
763
  MAX_POOL_SIZE = 50
726
764
 
727
765
 
@@ -755,7 +793,8 @@ class WebSocketManager:
755
793
  def configure(
756
794
  self,
757
795
  *,
758
- client_factory: Callable[[str, str], HomeAssistantWebSocketClient] | None = None,
796
+ client_factory: Callable[[str, str], HomeAssistantWebSocketClient]
797
+ | None = None,
759
798
  ) -> None:
760
799
  """Configure the manager with injectable dependencies."""
761
800
  if client_factory is not None:
@@ -9,11 +9,14 @@ import json
9
9
  import logging
10
10
  import re
11
11
  import time
12
+ from collections.abc import Awaitable, Callable
12
13
  from typing import Any, overload
13
14
 
14
15
  from ..client.rest_client import (
15
16
  HomeAssistantAPIError,
16
17
  HomeAssistantAuthError,
18
+ HomeAssistantCommandError,
19
+ HomeAssistantCommandTimeout,
17
20
  HomeAssistantConnectionError,
18
21
  )
19
22
 
@@ -71,9 +74,7 @@ def public_fields(d: dict[str, Any]) -> dict[str, Any]:
71
74
  later mutation of those values would propagate.
72
75
  """
73
76
  return {
74
- k: v
75
- for k, v in d.items()
76
- if not (isinstance(k, str) and k.startswith("_"))
77
+ k: v for k, v in d.items() if not (isinstance(k, str) and k.startswith("_"))
77
78
  }
78
79
 
79
80
 
@@ -439,7 +440,9 @@ async def get_logger_levels(client: Any) -> dict[str, dict[str, Any]]:
439
440
  continue
440
441
  levels[domain] = {
441
442
  "name": name,
442
- "raw": raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else None,
443
+ "raw": raw_level
444
+ if isinstance(raw_level, int) and not isinstance(raw_level, bool)
445
+ else None,
443
446
  }
444
447
  return levels
445
448
 
@@ -470,6 +473,334 @@ async def add_timezone_metadata(client: Any, data: dict[str, Any]) -> dict[str,
470
473
  }
471
474
 
472
475
 
476
+ # --- WS-event-driven wait helpers (#1152) -----------------------------------
477
+ #
478
+ # Background: every config write tool (`ha_config_set_helper`, set_automation,
479
+ # set_script, …) calls one of these three helpers after the API write returns,
480
+ # to confirm the operation reached the entity registry / state machine before
481
+ # the tool itself returns. Until #1152, those checks polled REST every 300ms
482
+ # up to a 10s budget. On a slow HA instance the poll could time out before
483
+ # the entity hydrated, surfacing a "Helper created but … not yet queryable"
484
+ # soft-failure warning even though the write succeeded — see #1152 for the
485
+ # agent-misattribution failure mode.
486
+ #
487
+ # The new pattern is WS-event-driven with a REST sample after subscribe and a
488
+ # slow REST backstop, falling back to pure REST polling when the WebSocket is
489
+ # unavailable:
490
+ #
491
+ # 1. Open a `state_changed` (and, for registry-add/remove waits, an
492
+ # `entity_registry_updated`) subscription via `subscribe_events`. The
493
+ # subscription must be live BEFORE we look at the world so we don't miss
494
+ # the event the write triggered.
495
+ # 2. Take a single REST sample. This closes the "the event fired between
496
+ # the write returning and our subscribe landing" window — if the entity
497
+ # is already in the desired shape, we return immediately and never
498
+ # touch the event loop.
499
+ # 3. Await events for our entity_id, then re-sample. A
500
+ # ``_POLLING_BACKSTOP_INTERVAL`` REST sample also runs every few seconds
501
+ # independently of events, so a silent-broken subscription degrades to
502
+ # a slow-polling REST loop rather than a 10s hang.
503
+ # 4. Drop the subscription and event handler in `finally`.
504
+ #
505
+ # Connection-drop awareness: if `get_websocket_client()` or `subscribe_events`
506
+ # fails, we fall through to ``_legacy_poll_until`` (the pre-#1152 REST loop)
507
+ # transparently, so the helpers still work on REST-only deployments and during
508
+ # HA-mid-restart windows. The legacy loop is also what we call when the WS
509
+ # subscription itself fails to set up — the helpers' contract (return bool or
510
+ # state dict, never raise on the happy path) is identical to before.
511
+
512
+ _POLLING_BACKSTOP_INTERVAL = 2.0
513
+ """Seconds between independent REST samples while a WS subscription is open.
514
+
515
+ Bounded slow-poll backstop so a silent-broken WS subscription still
516
+ resolves within the helper's timeout. A 10s budget with a 2s backstop
517
+ costs at most ~6 REST calls per wait (one post-subscribe sample plus
518
+ ~5 backstop samples), vs. ~33 calls for the previous 300ms loop."""
519
+
520
+
521
+ async def _legacy_poll_until(
522
+ entity_id: str,
523
+ sample: Callable[[], Awaitable[Any]],
524
+ *,
525
+ timeout: float,
526
+ poll_interval: float,
527
+ description: str,
528
+ ) -> Any:
529
+ """REST-polling waiter used as the WS-subscription fallback path.
530
+
531
+ ``sample`` is the same callable the WS path runs after each event /
532
+ backstop tick — it returns a truthy value when the wait should
533
+ succeed, ``None`` otherwise. Connection / auth errors propagate
534
+ (callers care about those); other transient errors raised inside
535
+ ``sample`` are swallowed there.
536
+ """
537
+ start = time.monotonic()
538
+ while time.monotonic() - start < timeout:
539
+ try:
540
+ result = await sample()
541
+ if result is not None:
542
+ logger.debug(
543
+ f"REST waiter: {description} for {entity_id} resolved "
544
+ f"after {time.monotonic() - start:.2f}s"
545
+ )
546
+ return result
547
+ except (HomeAssistantConnectionError, HomeAssistantAuthError):
548
+ raise
549
+ await asyncio.sleep(poll_interval)
550
+ logger.warning(
551
+ f"REST fallback: {description} for {entity_id} timed out after {timeout}s"
552
+ )
553
+ return None
554
+
555
+
556
+ async def _get_waiter_ws_client(client: Any) -> Any:
557
+ """Return a connected WS client to use for waiter subscriptions, or None.
558
+
559
+ Returning ``None`` triggers REST-only fallback in
560
+ ``_ws_wait_for_condition``. Localised import avoids a top-level cycle
561
+ (websocket_client → rest_client → util_helpers → websocket_client).
562
+ """
563
+ try:
564
+ from ..client.websocket_client import get_websocket_client
565
+ except ImportError as e: # pragma: no cover - import-time defence
566
+ logger.debug("WS waiter import failed: %s", e)
567
+ return None
568
+
569
+ base_url = getattr(client, "base_url", None)
570
+ token = getattr(client, "token", None)
571
+ # Per-client credentials are only meaningful when both are strings.
572
+ # If the caller is a test rig passing a ``MagicMock`` client (which
573
+ # returns ``MagicMock`` for any attribute), forwarding those into the
574
+ # WS pool trips URL-parsing TypeErrors deep inside ``WebSocketManager``.
575
+ # Treat any non-string credential as "no WS available" and fall
576
+ # through to REST polling — production callers always have a real
577
+ # string ``base_url`` and ``token``, so this only matters for tests.
578
+ if not (isinstance(base_url, str) and isinstance(token, str)):
579
+ return None
580
+ try:
581
+ ws_client = await get_websocket_client(url=base_url, token=token)
582
+ except HomeAssistantAuthError:
583
+ # Auth failures must reach the caller — a bad token should surface
584
+ # as a real error, not as a 10s "timed out" via REST fallback.
585
+ # silent-failure-hunter #1382.
586
+ raise
587
+ except (HomeAssistantConnectionError, OSError, TimeoutError) as e:
588
+ logger.debug("WS waiter could not obtain ws client: %s", e)
589
+ return None
590
+
591
+ if not getattr(ws_client, "is_connected", False):
592
+ return None
593
+ return ws_client
594
+
595
+
596
+ async def _ws_wait_for_condition(
597
+ client: Any,
598
+ entity_id: str,
599
+ sample: Callable[[], Awaitable[Any]],
600
+ *,
601
+ event_types: tuple[str, ...],
602
+ timeout: float,
603
+ poll_interval: float,
604
+ description: str,
605
+ ) -> Any:
606
+ """Subscribe to ``event_types``, sample after subscribe, wait on event.
607
+
608
+ Implements the standard "subscribe → sample → wait" pattern from #1152:
609
+
610
+ - The handler nudges a single ``asyncio.Event`` whenever HA pushes an
611
+ event for our ``entity_id``. The main loop wakes on that nudge or on
612
+ the polling-backstop timeout, then re-runs ``sample`` (the REST
613
+ source-of-truth check) to decide whether the wait succeeded.
614
+ - Sample-after-subscribe (not before) closes the gap between the
615
+ caller's write returning and our subscription landing on the HA
616
+ side. The event for the write may have already fired by the time we
617
+ subscribe; the post-subscribe sample catches that.
618
+ - If the WS path fails to set up (no WS client, no subscription, …)
619
+ we fall back to ``_legacy_poll_until``. The helpers' contract is
620
+ identical to the pre-#1152 REST loop in that case.
621
+
622
+ Returns ``sample``'s truthy return value, or ``None`` on timeout.
623
+ """
624
+ ws_client = await _get_waiter_ws_client(client)
625
+ if ws_client is None:
626
+ return await _legacy_poll_until(
627
+ entity_id,
628
+ sample,
629
+ timeout=timeout,
630
+ poll_interval=poll_interval,
631
+ description=description,
632
+ )
633
+
634
+ nudge = asyncio.Event()
635
+
636
+ async def handler(event: dict[str, Any]) -> None:
637
+ # HA nests ``entity_id`` under ``event["data"]`` for both
638
+ # state_changed and entity_registry_updated. The top-level fallback
639
+ # is defensive only — it lets a future schema drift degrade to a
640
+ # missed nudge rather than an AttributeError.
641
+ data = event.get("data") or {}
642
+ evt_entity = data.get("entity_id") or event.get("entity_id")
643
+ if evt_entity == entity_id:
644
+ nudge.set()
645
+
646
+ # Track which handlers / subscriptions we actually attached so cleanup
647
+ # is exact even if subscribe_events raises partway through.
648
+ attached_handlers: list[str] = []
649
+ sub_ids: list[int] = []
650
+ try:
651
+ for et in event_types:
652
+ ws_client.add_event_handler(et, handler)
653
+ attached_handlers.append(et)
654
+ for et in event_types:
655
+ try:
656
+ sub_id = await ws_client.subscribe_events(et)
657
+ except HomeAssistantAuthError:
658
+ # Auth errors must surface — see _get_waiter_ws_client.
659
+ raise
660
+ except (
661
+ HomeAssistantConnectionError,
662
+ HomeAssistantCommandError,
663
+ OSError,
664
+ TimeoutError,
665
+ ) as e:
666
+ logger.debug(
667
+ "subscribe_events(%s) failed during %s for %s: %s — "
668
+ "falling back to REST polling",
669
+ et,
670
+ description,
671
+ entity_id,
672
+ e,
673
+ )
674
+ return await _legacy_poll_until(
675
+ entity_id,
676
+ sample,
677
+ timeout=timeout,
678
+ poll_interval=poll_interval,
679
+ description=description,
680
+ )
681
+ sub_ids.append(sub_id)
682
+
683
+ start = time.monotonic()
684
+ # Sample-after-subscribe: covers the "event fired before subscribe
685
+ # landed" race. This is where most happy-path waits resolve.
686
+ try:
687
+ result = await sample()
688
+ if result is not None:
689
+ logger.debug(
690
+ f"WS waiter: {description} for {entity_id} resolved by "
691
+ f"post-subscribe sample after {time.monotonic() - start:.2f}s"
692
+ )
693
+ return result
694
+ except (HomeAssistantConnectionError, HomeAssistantAuthError):
695
+ raise
696
+
697
+ # If the WS dropped between subscribe and the post-subscribe sample,
698
+ # skip the wait loop entirely — we'd burn up to one backstop interval
699
+ # waiting for events that will never arrive. Connection-drop coverage
700
+ # symmetric with the in-loop check below.
701
+ if not ws_client.is_connected:
702
+ logger.debug(
703
+ "WS connection dropped before wait loop for %s on %s — "
704
+ "completing via REST polling",
705
+ description,
706
+ entity_id,
707
+ )
708
+ remaining = timeout - (time.monotonic() - start)
709
+ if remaining <= 0:
710
+ return None
711
+ return await _legacy_poll_until(
712
+ entity_id,
713
+ sample,
714
+ timeout=remaining,
715
+ poll_interval=poll_interval,
716
+ description=description,
717
+ )
718
+
719
+ while time.monotonic() - start < timeout:
720
+ # Wait for either an event nudge or the polling backstop. The
721
+ # backstop guards against silently-broken subscriptions and
722
+ # late-binding state hydration the event stream doesn't
723
+ # advertise.
724
+ remaining = timeout - (time.monotonic() - start)
725
+ wait_budget = min(remaining, _POLLING_BACKSTOP_INTERVAL)
726
+ try:
727
+ await asyncio.wait_for(nudge.wait(), timeout=wait_budget)
728
+ nudge.clear()
729
+ except TimeoutError:
730
+ pass
731
+
732
+ # Connection-drop awareness: if the WS dropped while we were
733
+ # waiting, the OperationManager / pool will reconnect lazily
734
+ # but our subscription is gone. Fall back to REST polling for
735
+ # the remaining budget rather than wait silently for events
736
+ # that will never arrive.
737
+ if not ws_client.is_connected:
738
+ logger.debug(
739
+ "WS connection dropped during %s for %s — completing "
740
+ "wait via REST polling",
741
+ description,
742
+ entity_id,
743
+ )
744
+ remaining = timeout - (time.monotonic() - start)
745
+ if remaining <= 0:
746
+ return None
747
+ return await _legacy_poll_until(
748
+ entity_id,
749
+ sample,
750
+ timeout=remaining,
751
+ poll_interval=poll_interval,
752
+ description=description,
753
+ )
754
+
755
+ try:
756
+ result = await sample()
757
+ if result is not None:
758
+ logger.debug(
759
+ f"WS waiter: {description} for {entity_id} resolved "
760
+ f"after {time.monotonic() - start:.2f}s"
761
+ )
762
+ return result
763
+ except (HomeAssistantConnectionError, HomeAssistantAuthError):
764
+ raise
765
+
766
+ logger.warning(
767
+ f"WS waiter: {description} for {entity_id} timed out after {timeout}s"
768
+ )
769
+ return None
770
+ finally:
771
+ for et in attached_handlers:
772
+ ws_client.remove_event_handler(et, handler)
773
+ for sub_id in sub_ids:
774
+ # ``unsubscribe_events`` narrows internally: OSError → debug,
775
+ # HomeAssistantCommandError → warning, everything else propagates.
776
+ # The outer catch here guards against the round-trip's WS-level
777
+ # failure modes (connection reset by another caller, send_command
778
+ # timeout) so a cleanup hiccup never masks the wait's real result.
779
+ # Narrow set — programming bugs (TypeError, AttributeError) must
780
+ # propagate.
781
+ try:
782
+ await ws_client.unsubscribe_events(sub_id)
783
+ except (HomeAssistantConnectionError, OSError, TimeoutError) as e:
784
+ logger.warning(
785
+ "unsubscribe_events(%s) cleanup failed (subscription "
786
+ "may leak until WS pool reconnects): %s",
787
+ sub_id,
788
+ e,
789
+ )
790
+ except HomeAssistantCommandTimeout:
791
+ # ``send_command`` raises this when the WS round-trip exceeds
792
+ # its own 30s deadline (Patch76 review #1382 — typed
793
+ # replacement for the previous ``str(e) == "Command timeout"``
794
+ # string match). Treated as cleanup noise; the subscription
795
+ # may leak until the WS pool reconnects.
796
+ logger.warning(
797
+ "unsubscribe_events(%s) cleanup timed out on WS "
798
+ "round-trip; subscription may leak until WS pool "
799
+ "reconnects",
800
+ sub_id,
801
+ )
802
+
803
+
473
804
  async def wait_for_entity_registered(
474
805
  client: Any,
475
806
  entity_id: str,
@@ -477,37 +808,48 @@ async def wait_for_entity_registered(
477
808
  poll_interval: float = 0.3,
478
809
  ) -> bool:
479
810
  """
480
- Poll until an entity is registered and accessible via the state API.
811
+ Wait until an entity is registered and accessible via the state API.
481
812
 
482
813
  Used after config create/update operations to confirm the entity is queryable.
814
+ Listens to ``state_changed`` and ``entity_registry_updated`` events on the
815
+ WebSocket and falls back to REST polling (every ``poll_interval`` seconds)
816
+ when the WebSocket is unavailable. See the module-level note above for the
817
+ subscribe→sample→wait pattern and the failure mode it addresses (#1152).
483
818
 
484
819
  Args:
485
820
  client: HomeAssistantClient instance
486
821
  entity_id: Entity ID to wait for (e.g., 'automation.morning_routine')
487
822
  timeout: Maximum time to wait in seconds
488
- poll_interval: Time between polls in seconds
823
+ poll_interval: REST poll interval used for the WS-unavailable fallback
489
824
 
490
825
  Returns:
491
826
  True if entity became accessible, False if timed out
492
827
  """
493
- start = time.monotonic()
494
- while time.monotonic() - start < timeout:
828
+
829
+ async def sample() -> bool | None:
495
830
  try:
496
831
  state = await client.get_entity_state(entity_id)
497
- if state:
498
- logger.debug(
499
- f"Entity {entity_id} registered after {time.monotonic() - start:.1f}s"
500
- )
501
- return True
502
832
  except HomeAssistantAPIError as e:
503
833
  if e.status_code == 404:
504
- pass # Expected: entity not registered yet
505
- else:
506
- logger.warning(f"Unexpected API error polling {entity_id}: {e}")
507
- except (HomeAssistantConnectionError, HomeAssistantAuthError) as e:
508
- logger.warning(f"Connection/auth error polling {entity_id}: {e}")
509
- raise
510
- await asyncio.sleep(poll_interval)
834
+ return None
835
+ logger.warning(f"Unexpected API error sampling {entity_id}: {e}")
836
+ return None
837
+ return True if state else None
838
+
839
+ result = await _ws_wait_for_condition(
840
+ client,
841
+ entity_id,
842
+ sample,
843
+ # entity_registry_updated fires when the registry row is added,
844
+ # state_changed when the state machine row hydrates. We watch both
845
+ # so the post-event sample lands as soon as either side completes.
846
+ event_types=("state_changed", "entity_registry_updated"),
847
+ timeout=timeout,
848
+ poll_interval=poll_interval,
849
+ description="entity registration",
850
+ )
851
+ if result is True:
852
+ return True
511
853
  logger.warning(f"Entity {entity_id} not registered within {timeout}s")
512
854
  return False
513
855
 
@@ -519,39 +861,45 @@ async def wait_for_entity_removed(
519
861
  poll_interval: float = 0.3,
520
862
  ) -> bool:
521
863
  """
522
- Poll until an entity is no longer accessible via the state API.
864
+ Wait until an entity is no longer accessible via the state API.
523
865
 
524
- Used after config delete operations to confirm the entity is gone.
866
+ Used after config delete operations to confirm the entity is gone. Listens
867
+ to ``state_changed`` and ``entity_registry_updated`` removal events on the
868
+ WebSocket and falls back to REST polling (every ``poll_interval`` seconds)
869
+ when the WebSocket is unavailable. See #1152 for context.
525
870
 
526
871
  Args:
527
872
  client: HomeAssistantClient instance
528
873
  entity_id: Entity ID to wait for removal
529
874
  timeout: Maximum time to wait in seconds
530
- poll_interval: Time between polls in seconds
875
+ poll_interval: REST poll interval used for the WS-unavailable fallback
531
876
 
532
877
  Returns:
533
878
  True if entity was removed, False if timed out (entity still exists)
534
879
  """
535
- start = time.monotonic()
536
- while time.monotonic() - start < timeout:
880
+
881
+ async def sample() -> bool | None:
537
882
  try:
538
883
  state = await client.get_entity_state(entity_id)
539
- if not state:
540
- logger.debug(
541
- f"Entity {entity_id} removed after {time.monotonic() - start:.1f}s"
542
- )
543
- return True
544
884
  except HomeAssistantAPIError as e:
545
885
  if e.status_code == 404:
546
- logger.debug(
547
- f"Entity {entity_id} removed (404) after {time.monotonic() - start:.1f}s"
548
- )
549
886
  return True
550
- logger.warning(f"Unexpected API error polling {entity_id} removal: {e}")
551
- except (HomeAssistantConnectionError, HomeAssistantAuthError) as e:
552
- logger.warning(f"Connection/auth error polling {entity_id} removal: {e}")
553
- raise
554
- await asyncio.sleep(poll_interval)
887
+ logger.warning(f"Unexpected API error sampling {entity_id} removal: {e}")
888
+ return None
889
+ # Falsy state == entity is gone from the state machine.
890
+ return True if not state else None
891
+
892
+ result = await _ws_wait_for_condition(
893
+ client,
894
+ entity_id,
895
+ sample,
896
+ event_types=("state_changed", "entity_registry_updated"),
897
+ timeout=timeout,
898
+ poll_interval=poll_interval,
899
+ description="entity removal",
900
+ )
901
+ if result is True:
902
+ return True
555
903
  logger.warning(f"Entity {entity_id} still exists after {timeout}s")
556
904
  return False
557
905
 
@@ -565,9 +913,12 @@ async def wait_for_state_change(
565
913
  initial_state: str | None = None,
566
914
  ) -> dict[str, Any] | None:
567
915
  """
568
- Poll until an entity's state changes (optionally to a specific value).
916
+ Wait until an entity's state changes (optionally to a specific value).
569
917
 
570
- Used after service calls to verify the operation took effect.
918
+ Used after service calls to verify the operation took effect. Listens to
919
+ ``state_changed`` events on the WebSocket and falls back to REST polling
920
+ (every ``poll_interval`` seconds) when the WebSocket is unavailable. See
921
+ #1152 for context.
571
922
 
572
923
  Args:
573
924
  client: HomeAssistantClient instance
@@ -575,14 +926,13 @@ async def wait_for_state_change(
575
926
  expected_state: If set, wait for this specific state value.
576
927
  If None, wait for any change from initial_state.
577
928
  timeout: Maximum time to wait in seconds
578
- poll_interval: Time between polls in seconds
929
+ poll_interval: REST poll interval used for the WS-unavailable fallback
579
930
  initial_state: The state before the operation. If None, it will be
580
931
  fetched automatically.
581
932
 
582
933
  Returns:
583
934
  The entity state dict if the change was detected, None if timed out
584
935
  """
585
- # Capture initial state if not provided
586
936
  if initial_state is None:
587
937
  try:
588
938
  raw_initial = await client.get_entity_state(entity_id)
@@ -598,43 +948,43 @@ async def wait_for_state_change(
598
948
  )
599
949
  raise
600
950
 
601
- start = time.monotonic()
602
- while time.monotonic() - start < timeout:
951
+ # Mutable closure cell so the sampler can adopt the first observed state
952
+ # as the baseline when the initial fetch failed — matches the original
953
+ # REST-loop semantics.
954
+ baseline: dict[str, str | None] = {"state": initial_state}
955
+
956
+ async def sample() -> dict[str, Any] | None:
603
957
  try:
604
958
  raw = await client.get_entity_state(entity_id)
605
- state_data: dict[str, Any] | None = raw if isinstance(raw, dict) else None
606
- if state_data:
607
- current = state_data.get("state")
608
- if expected_state is not None and current == expected_state:
609
- logger.debug(
610
- f"Entity {entity_id} reached state '{expected_state}' "
611
- f"after {time.monotonic() - start:.1f}s"
612
- )
613
- return state_data
614
- if (
615
- expected_state is None
616
- and initial_state is not None
617
- and current != initial_state
618
- ):
619
- logger.debug(
620
- f"Entity {entity_id} changed from '{initial_state}' to '{current}' "
621
- f"after {time.monotonic() - start:.1f}s"
622
- )
623
- return state_data
624
- # If initial state fetch failed, use first successful poll as baseline
625
- if (
626
- expected_state is None
627
- and initial_state is None
628
- and current is not None
629
- ):
630
- initial_state = current
631
959
  except HomeAssistantAPIError as e:
632
- logger.debug(f"API error polling {entity_id} state: {e}")
633
- except (HomeAssistantConnectionError, HomeAssistantAuthError) as e:
634
- logger.warning(f"Connection/auth error polling {entity_id} state: {e}")
635
- raise
636
- await asyncio.sleep(poll_interval)
960
+ logger.debug(f"API error sampling {entity_id} state: {e}")
961
+ return None
962
+ if not isinstance(raw, dict):
963
+ return None
964
+ current = raw.get("state")
965
+ if expected_state is not None and current == expected_state:
966
+ return raw
967
+ if (
968
+ expected_state is None
969
+ and baseline["state"] is not None
970
+ and current != baseline["state"]
971
+ ):
972
+ return raw
973
+ if expected_state is None and baseline["state"] is None and current is not None:
974
+ baseline["state"] = current
975
+ return None
637
976
 
977
+ result = await _ws_wait_for_condition(
978
+ client,
979
+ entity_id,
980
+ sample,
981
+ event_types=("state_changed",),
982
+ timeout=timeout,
983
+ poll_interval=poll_interval,
984
+ description="state change",
985
+ )
986
+ if isinstance(result, dict):
987
+ return result
638
988
  logger.warning(f"Entity {entity_id} state did not change within {timeout}s")
639
989
  return None
640
990
 
@@ -908,7 +1258,9 @@ async def fetch_integration_diagnostics(
908
1258
  )
909
1259
  logger.warning("Diagnostics fetch refused (403): %s", e)
910
1260
  else:
911
- result["error"] = f"Diagnostics fetch failed (HTTP {status or '<status>'}): {e}"
1261
+ result["error"] = (
1262
+ f"Diagnostics fetch failed (HTTP {status or '<status>'}): {e}"
1263
+ )
912
1264
  logger.warning("Diagnostics fetch API error: %s", e)
913
1265
  except HomeAssistantConnectionError as e:
914
1266
  msg = str(e)
@@ -1012,9 +1364,7 @@ def _project_cap_and_paginate_diagnostics(
1012
1364
  # is set without ``data_limit`` (no window to slice). Surface
1013
1365
  # a structured warning rather than silently dropping the kwarg.
1014
1366
  if data_limit is not None:
1015
- type_name = (
1016
- "null" if resolved is None else type(resolved).__name__
1017
- )
1367
+ type_name = "null" if resolved is None else type(resolved).__name__
1018
1368
  result["data_pagination_warning"] = (
1019
1369
  f"data_limit ignored: resolved value at '{data_path}' "
1020
1370
  f"is {type_name}, not a list"
@@ -1034,8 +1384,7 @@ def _project_cap_and_paginate_diagnostics(
1034
1384
  # land together (the whitespace input nulled ``data_path``, dropping
1035
1385
  # us into this elif; the earlier warning takes precedence).
1036
1386
  result["data_pagination_warning"] = (
1037
- "data_offset ignored: data_path not set "
1038
- "(no resolved sub-tree to paginate)"
1387
+ "data_offset ignored: data_path not set (no resolved sub-tree to paginate)"
1039
1388
  )
1040
1389
 
1041
1390
  if truncate_at_bytes is not None and data is not None:
@@ -1065,9 +1414,7 @@ def _project_cap_and_paginate_diagnostics(
1065
1414
  del result["data"]
1066
1415
 
1067
1416
 
1068
- def _resolve_data_path(
1069
- data: Any, path: str
1070
- ) -> tuple[Any, str | None]:
1417
+ def _resolve_data_path(data: Any, path: str) -> tuple[Any, str | None]:
1071
1418
  """Walk ``data`` along the dotted ``path`` and return ``(value, error)``.
1072
1419
 
1073
1420
  Returns ``(value, None)`` on success or ``(None, error_message)`` when
@@ -1091,8 +1438,7 @@ def _resolve_data_path(
1091
1438
  for seg in segments:
1092
1439
  if not seg:
1093
1440
  return None, (
1094
- f"data_path '{path}' has an empty segment "
1095
- f"(after '{'.'.join(walked)}')"
1441
+ f"data_path '{path}' has an empty segment (after '{'.'.join(walked)}')"
1096
1442
  )
1097
1443
  if current is None:
1098
1444
  return None, (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev564
3
+ Version: 7.5.0.dev565
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