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.
- {ha_mcp_dev-7.5.0.dev564/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev565}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/rest_client.py +14 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/websocket_client.py +52 -13
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/util_helpers.py +432 -86
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/py.typed +0 -0
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/tests/test_env_manager.py +0 -0
|
@@ -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.
|
|
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(
|
|
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
|
|
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]
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
536
|
-
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
602
|
-
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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"] =
|
|
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, (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/transforms/lite_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp/utils/kill_signal_diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev565}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|