ha-mcp-dev 6.3.1.dev137__py3-none-any.whl
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/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket client for Home Assistant real-time communication.
|
|
3
|
+
|
|
4
|
+
This module handles WebSocket connections to Home Assistant for:
|
|
5
|
+
- Real-time state change monitoring
|
|
6
|
+
- Async device operation verification
|
|
7
|
+
- Live system updates
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import websockets
|
|
20
|
+
|
|
21
|
+
from ..config import get_global_settings
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WebSocketConnectionState:
|
|
27
|
+
"""Encapsulates mutable state used by the WebSocket client."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.connected = False
|
|
31
|
+
self.authenticated = False
|
|
32
|
+
self._message_id = 0
|
|
33
|
+
self._pending_requests: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
34
|
+
self._auth_messages: dict[str, dict[str, Any]] = {}
|
|
35
|
+
self._render_template_events: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
36
|
+
self._event_handlers: dict[
|
|
37
|
+
str, set[Callable[[dict[str, Any]], Awaitable[None]]]
|
|
38
|
+
] = defaultdict(set)
|
|
39
|
+
|
|
40
|
+
def next_message_id(self) -> int:
|
|
41
|
+
"""Reserve the next available WebSocket message identifier."""
|
|
42
|
+
self._message_id += 1
|
|
43
|
+
return self._message_id
|
|
44
|
+
|
|
45
|
+
def register_pending_request(
|
|
46
|
+
self, message_id: int
|
|
47
|
+
) -> asyncio.Future[dict[str, Any]]:
|
|
48
|
+
"""Create and register a future for a pending command response."""
|
|
49
|
+
future: asyncio.Future[dict[str, Any]] = (
|
|
50
|
+
asyncio.get_running_loop().create_future()
|
|
51
|
+
)
|
|
52
|
+
self._pending_requests[message_id] = future
|
|
53
|
+
return future
|
|
54
|
+
def resolve_pending_request(
|
|
55
|
+
self, message_id: int
|
|
56
|
+
) -> asyncio.Future[dict[str, Any]] | None:
|
|
57
|
+
"""Resolve and remove a pending request future."""
|
|
58
|
+
return self._pending_requests.pop(message_id, None)
|
|
59
|
+
|
|
60
|
+
def cancel_pending_request(self, message_id: int) -> None:
|
|
61
|
+
"""Cancel a pending request future if it exists."""
|
|
62
|
+
future = self._pending_requests.pop(message_id, None)
|
|
63
|
+
if future and not future.done():
|
|
64
|
+
future.cancel()
|
|
65
|
+
|
|
66
|
+
def register_render_template_event(
|
|
67
|
+
self, message_id: int
|
|
68
|
+
) -> asyncio.Future[dict[str, Any]]:
|
|
69
|
+
"""Create and register a future for a render_template follow-up event."""
|
|
70
|
+
future: asyncio.Future[dict[str, Any]] = (
|
|
71
|
+
asyncio.get_running_loop().create_future()
|
|
72
|
+
)
|
|
73
|
+
self._render_template_events[message_id] = future
|
|
74
|
+
return future
|
|
75
|
+
|
|
76
|
+
def resolve_render_template_event(
|
|
77
|
+
self, message_id: int
|
|
78
|
+
) -> asyncio.Future[dict[str, Any]] | None:
|
|
79
|
+
"""Resolve a stored render_template event future."""
|
|
80
|
+
return self._render_template_events.pop(message_id, None)
|
|
81
|
+
|
|
82
|
+
def cancel_render_template_event(self, message_id: int) -> None:
|
|
83
|
+
"""Cancel a stored render_template event future."""
|
|
84
|
+
future = self._render_template_events.pop(message_id, None)
|
|
85
|
+
if future and not future.done():
|
|
86
|
+
future.cancel()
|
|
87
|
+
|
|
88
|
+
def store_auth_message(self, message_type: str, data: dict[str, Any]) -> None:
|
|
89
|
+
"""Store an authentication handshake message."""
|
|
90
|
+
self._auth_messages[message_type] = data
|
|
91
|
+
|
|
92
|
+
def consume_auth_message(self, message_type: str) -> dict[str, Any] | None:
|
|
93
|
+
"""Retrieve and remove an authentication message if present."""
|
|
94
|
+
return self._auth_messages.pop(message_type, None)
|
|
95
|
+
|
|
96
|
+
def reset_connection(self) -> None:
|
|
97
|
+
"""Reset connection-specific state while preserving handlers."""
|
|
98
|
+
self.connected = False
|
|
99
|
+
self.authenticated = False
|
|
100
|
+
self._message_id = 0
|
|
101
|
+
|
|
102
|
+
for future in self._pending_requests.values():
|
|
103
|
+
if not future.done():
|
|
104
|
+
future.cancel()
|
|
105
|
+
self._pending_requests.clear()
|
|
106
|
+
|
|
107
|
+
for future in self._render_template_events.values():
|
|
108
|
+
if not future.done():
|
|
109
|
+
future.cancel()
|
|
110
|
+
self._render_template_events.clear()
|
|
111
|
+
|
|
112
|
+
self._auth_messages.clear()
|
|
113
|
+
|
|
114
|
+
def mark_connected(self) -> None:
|
|
115
|
+
"""Mark the socket as connected but not yet authenticated."""
|
|
116
|
+
self.connected = True
|
|
117
|
+
self.authenticated = False
|
|
118
|
+
|
|
119
|
+
def mark_authenticated(self) -> None:
|
|
120
|
+
"""Mark the socket as authenticated and ready for commands."""
|
|
121
|
+
self.authenticated = True
|
|
122
|
+
|
|
123
|
+
def mark_disconnected(self) -> None:
|
|
124
|
+
"""Reset connection state when the socket is closed."""
|
|
125
|
+
self.reset_connection()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_ready(self) -> bool:
|
|
129
|
+
"""Whether the connection is active and authenticated."""
|
|
130
|
+
return self.connected and self.authenticated
|
|
131
|
+
|
|
132
|
+
def add_event_handler(
|
|
133
|
+
self, event_type: str, handler: Callable[[dict[str, Any]], Awaitable[None]]
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Register an async handler for a Home Assistant event type."""
|
|
136
|
+
self._event_handlers[event_type].add(handler)
|
|
137
|
+
|
|
138
|
+
def remove_event_handler(
|
|
139
|
+
self, event_type: str, handler: Callable[[dict[str, Any]], Awaitable[None]]
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Remove an event handler and prune empty handler sets."""
|
|
142
|
+
if event_type in self._event_handlers:
|
|
143
|
+
self._event_handlers[event_type].discard(handler)
|
|
144
|
+
if not self._event_handlers[event_type]:
|
|
145
|
+
self._event_handlers.pop(event_type, None)
|
|
146
|
+
|
|
147
|
+
def get_event_handlers(
|
|
148
|
+
self, event_type: str
|
|
149
|
+
) -> tuple[Callable[[dict[str, Any]], Awaitable[None]], ...]:
|
|
150
|
+
"""Return registered handlers for a given event type."""
|
|
151
|
+
if event_type not in self._event_handlers:
|
|
152
|
+
return ()
|
|
153
|
+
return tuple(self._event_handlers[event_type])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class HomeAssistantWebSocketClient:
|
|
157
|
+
"""WebSocket client for Home Assistant real-time communication."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, url: str, token: str):
|
|
160
|
+
"""Initialize WebSocket client.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
url: Home Assistant URL (e.g., 'https://homeassistant.local:8123')
|
|
164
|
+
token: Home Assistant long-lived access token
|
|
165
|
+
"""
|
|
166
|
+
self.base_url = url.rstrip("/")
|
|
167
|
+
self.token = token
|
|
168
|
+
self.websocket: websockets.ClientConnection | None = None
|
|
169
|
+
self.background_task: asyncio.Task | None = None
|
|
170
|
+
self._send_lock: asyncio.Lock | None = None
|
|
171
|
+
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
|
172
|
+
self._state = WebSocketConnectionState()
|
|
173
|
+
|
|
174
|
+
# Parse URL to get WebSocket endpoint
|
|
175
|
+
parsed = urlparse(self.base_url)
|
|
176
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
177
|
+
|
|
178
|
+
# Handle Supervisor proxy case: http://supervisor/core -> ws://supervisor/core/websocket
|
|
179
|
+
# For regular HA URLs: http://ha.local:8123 -> ws://ha.local:8123/api/websocket
|
|
180
|
+
if parsed.path and parsed.path != "/":
|
|
181
|
+
# Supervisor proxy or URL with path - use path + /websocket
|
|
182
|
+
base_path = parsed.path.rstrip("/")
|
|
183
|
+
self.ws_url = f"{scheme}://{parsed.netloc}{base_path}/websocket"
|
|
184
|
+
else:
|
|
185
|
+
# Standard Home Assistant URL - use /api/websocket
|
|
186
|
+
self.ws_url = f"{scheme}://{parsed.netloc}/api/websocket"
|
|
187
|
+
|
|
188
|
+
async def connect(self) -> bool:
|
|
189
|
+
"""Connect to Home Assistant WebSocket API.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if connection and authentication successful
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
logger.info(f"Connecting to Home Assistant WebSocket: {self.ws_url}")
|
|
196
|
+
self._state.reset_connection()
|
|
197
|
+
|
|
198
|
+
# Connect to WebSocket
|
|
199
|
+
# Include Authorization header for Supervisor proxy compatibility
|
|
200
|
+
# (required when connecting via http://supervisor/core/websocket)
|
|
201
|
+
self.websocket = await websockets.connect(
|
|
202
|
+
self.ws_url,
|
|
203
|
+
ping_interval=30,
|
|
204
|
+
ping_timeout=10,
|
|
205
|
+
additional_headers={"Authorization": f"Bearer {self.token}"},
|
|
206
|
+
# Increase max message size to 20MB for large responses
|
|
207
|
+
# (e.g., HACS repository list can be 2MB+)
|
|
208
|
+
max_size=20 * 1024 * 1024,
|
|
209
|
+
)
|
|
210
|
+
self._state.mark_connected()
|
|
211
|
+
|
|
212
|
+
# Start message handling task
|
|
213
|
+
self.background_task = asyncio.create_task(self._message_handler())
|
|
214
|
+
|
|
215
|
+
# Wait for auth_required message
|
|
216
|
+
auth_msg = await self._wait_for_auth_message(
|
|
217
|
+
message_type="auth_required", timeout=5
|
|
218
|
+
)
|
|
219
|
+
if not auth_msg:
|
|
220
|
+
raise Exception("Did not receive auth_required message")
|
|
221
|
+
|
|
222
|
+
# Send authentication
|
|
223
|
+
await self._send_auth()
|
|
224
|
+
|
|
225
|
+
# Wait for auth response
|
|
226
|
+
auth_response = await self._wait_for_auth_message(
|
|
227
|
+
message_type="auth_ok", timeout=5
|
|
228
|
+
)
|
|
229
|
+
if not auth_response:
|
|
230
|
+
auth_invalid = await self._wait_for_auth_message(
|
|
231
|
+
message_type="auth_invalid", timeout=1
|
|
232
|
+
)
|
|
233
|
+
if auth_invalid:
|
|
234
|
+
raise Exception("Authentication failed: Invalid token")
|
|
235
|
+
raise Exception("Authentication timeout")
|
|
236
|
+
|
|
237
|
+
self._state.mark_authenticated()
|
|
238
|
+
logger.info("WebSocket connected and authenticated successfully")
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"WebSocket connection failed: {e}")
|
|
243
|
+
await self.disconnect()
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
async def disconnect(self) -> None:
|
|
247
|
+
"""Disconnect from WebSocket."""
|
|
248
|
+
if self.background_task:
|
|
249
|
+
self.background_task.cancel()
|
|
250
|
+
try:
|
|
251
|
+
await self.background_task
|
|
252
|
+
except asyncio.CancelledError:
|
|
253
|
+
pass
|
|
254
|
+
finally:
|
|
255
|
+
self.background_task = None
|
|
256
|
+
|
|
257
|
+
if self.websocket:
|
|
258
|
+
await self.websocket.close()
|
|
259
|
+
self.websocket = None
|
|
260
|
+
|
|
261
|
+
self._state.mark_disconnected()
|
|
262
|
+
logger.info("WebSocket disconnected")
|
|
263
|
+
|
|
264
|
+
async def _send_auth(self) -> None:
|
|
265
|
+
"""Send authentication message."""
|
|
266
|
+
if not self.websocket:
|
|
267
|
+
raise Exception("WebSocket not connected")
|
|
268
|
+
auth_message = {"type": "auth", "access_token": self.token}
|
|
269
|
+
await self.websocket.send(json.dumps(auth_message))
|
|
270
|
+
|
|
271
|
+
async def _wait_for_auth_message(
|
|
272
|
+
self, message_type: str, timeout: float = 5.0
|
|
273
|
+
) -> dict[str, Any] | None:
|
|
274
|
+
"""Wait for an authentication message type with timeout."""
|
|
275
|
+
start_time = time.time()
|
|
276
|
+
|
|
277
|
+
while time.time() - start_time < timeout:
|
|
278
|
+
message = self._state.consume_auth_message(message_type)
|
|
279
|
+
if message:
|
|
280
|
+
return message
|
|
281
|
+
await asyncio.sleep(0.01) # Small delay to prevent busy waiting
|
|
282
|
+
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
async def _message_handler(self) -> None:
|
|
286
|
+
"""Background task to handle incoming WebSocket messages."""
|
|
287
|
+
if not self.websocket:
|
|
288
|
+
raise Exception("WebSocket not connected")
|
|
289
|
+
try:
|
|
290
|
+
async for message in self.websocket:
|
|
291
|
+
try:
|
|
292
|
+
data = json.loads(message)
|
|
293
|
+
logger.debug(f"WebSocket received: {data}")
|
|
294
|
+
await self._process_message(data)
|
|
295
|
+
except json.JSONDecodeError as e:
|
|
296
|
+
logger.error(f"Invalid JSON received: {e}")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(f"Error processing message: {e}")
|
|
299
|
+
except websockets.exceptions.ConnectionClosed:
|
|
300
|
+
logger.info("WebSocket connection closed")
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.error(f"WebSocket message handler error: {e}")
|
|
303
|
+
finally:
|
|
304
|
+
self._state.mark_disconnected()
|
|
305
|
+
|
|
306
|
+
async def _process_message(self, data: dict[str, Any]) -> None:
|
|
307
|
+
"""Process incoming WebSocket message."""
|
|
308
|
+
message_type = data.get("type")
|
|
309
|
+
message_id = data.get("id")
|
|
310
|
+
|
|
311
|
+
# Handle authentication messages (store for auth sequence)
|
|
312
|
+
if message_type in ["auth_required", "auth_ok", "auth_invalid"]:
|
|
313
|
+
self._state.store_auth_message(message_type, data)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
# Handle command responses
|
|
317
|
+
if message_id is not None:
|
|
318
|
+
future = self._state.resolve_pending_request(message_id)
|
|
319
|
+
if future:
|
|
320
|
+
if not future.cancelled():
|
|
321
|
+
future.set_result(data)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# Handle events
|
|
325
|
+
if message_type == "event":
|
|
326
|
+
if message_id is not None:
|
|
327
|
+
render_future = self._state.resolve_render_template_event(message_id)
|
|
328
|
+
if render_future:
|
|
329
|
+
if not render_future.cancelled():
|
|
330
|
+
render_future.set_result(data)
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
event_type = data.get("event", {}).get("event_type")
|
|
334
|
+
if event_type:
|
|
335
|
+
for handler in self._state.get_event_handlers(event_type):
|
|
336
|
+
try:
|
|
337
|
+
await handler(data["event"])
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"Error in event handler: {e}")
|
|
340
|
+
|
|
341
|
+
def _ensure_send_lock(self) -> None:
|
|
342
|
+
"""Ensure the send lock belongs to the current event loop."""
|
|
343
|
+
try:
|
|
344
|
+
current_loop = asyncio.get_running_loop()
|
|
345
|
+
except RuntimeError:
|
|
346
|
+
current_loop = None
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
self._send_lock is not None
|
|
350
|
+
and self._lock_loop is not None
|
|
351
|
+
and self._lock_loop != current_loop
|
|
352
|
+
):
|
|
353
|
+
logger.debug("Event loop changed, resetting WebSocket send lock")
|
|
354
|
+
self._send_lock = None
|
|
355
|
+
|
|
356
|
+
if self._send_lock is None:
|
|
357
|
+
self._send_lock = asyncio.Lock()
|
|
358
|
+
self._lock_loop = current_loop
|
|
359
|
+
|
|
360
|
+
async def send_json_message(self, message: dict[str, Any]) -> None:
|
|
361
|
+
"""Send a raw JSON message over the WebSocket connection."""
|
|
362
|
+
self._ensure_send_lock()
|
|
363
|
+
if not self._send_lock:
|
|
364
|
+
raise Exception("Send lock not initialized")
|
|
365
|
+
|
|
366
|
+
async with self._send_lock:
|
|
367
|
+
if not self.websocket:
|
|
368
|
+
raise Exception("WebSocket not connected")
|
|
369
|
+
logger.debug(f"WebSocket sending: {message}")
|
|
370
|
+
await self.websocket.send(json.dumps(message))
|
|
371
|
+
|
|
372
|
+
def get_next_message_id(self) -> int:
|
|
373
|
+
"""Expose the next WebSocket message ID for external callers."""
|
|
374
|
+
return self._state.next_message_id()
|
|
375
|
+
|
|
376
|
+
def register_pending_response(
|
|
377
|
+
self, message_id: int
|
|
378
|
+
) -> asyncio.Future[dict[str, Any]]:
|
|
379
|
+
"""Register a future that will resolve when the response arrives."""
|
|
380
|
+
return self._state.register_pending_request(message_id)
|
|
381
|
+
|
|
382
|
+
def cancel_pending_response(self, message_id: int) -> None:
|
|
383
|
+
"""Cancel and drop a pending response future."""
|
|
384
|
+
self._state.cancel_pending_request(message_id)
|
|
385
|
+
|
|
386
|
+
def register_render_template_event(
|
|
387
|
+
self, message_id: int
|
|
388
|
+
) -> asyncio.Future[dict[str, Any]]:
|
|
389
|
+
"""Register a future for a render_template follow-up event."""
|
|
390
|
+
return self._state.register_render_template_event(message_id)
|
|
391
|
+
|
|
392
|
+
def cancel_render_template_event(self, message_id: int) -> None:
|
|
393
|
+
"""Cancel and drop a stored render_template event future."""
|
|
394
|
+
self._state.cancel_render_template_event(message_id)
|
|
395
|
+
|
|
396
|
+
async def send_command(self, command_type: str, **kwargs: Any) -> dict[str, Any]:
|
|
397
|
+
"""Send command and wait for response.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
command_type: Type of command to send
|
|
401
|
+
**kwargs: Command parameters
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Response from Home Assistant
|
|
405
|
+
"""
|
|
406
|
+
if not self._state.is_ready:
|
|
407
|
+
raise Exception("WebSocket not authenticated")
|
|
408
|
+
|
|
409
|
+
message_id = self.get_next_message_id()
|
|
410
|
+
message = {"id": message_id, "type": command_type, **kwargs}
|
|
411
|
+
|
|
412
|
+
# Create future for response
|
|
413
|
+
future = self.register_pending_response(message_id)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
await self.send_json_message(message)
|
|
417
|
+
except Exception:
|
|
418
|
+
self.cancel_pending_response(message_id)
|
|
419
|
+
raise
|
|
420
|
+
|
|
421
|
+
# Wait for response outside the lock (30 second timeout)
|
|
422
|
+
try:
|
|
423
|
+
response = await asyncio.wait_for(future, timeout=30.0)
|
|
424
|
+
logger.debug(f"WebSocket response for id {message_id}: {response}")
|
|
425
|
+
|
|
426
|
+
# Process standard Home Assistant WebSocket response
|
|
427
|
+
if response.get("type") == "result":
|
|
428
|
+
if response.get("success") is False:
|
|
429
|
+
error = response.get("error", {})
|
|
430
|
+
error_msg = (
|
|
431
|
+
error.get("message", str(error))
|
|
432
|
+
if isinstance(error, dict)
|
|
433
|
+
else str(error)
|
|
434
|
+
)
|
|
435
|
+
raise Exception(f"Command failed: {error_msg}")
|
|
436
|
+
|
|
437
|
+
# Return success response according to HA WebSocket format
|
|
438
|
+
return {
|
|
439
|
+
"success": response.get("success", True),
|
|
440
|
+
"result": response.get("result"),
|
|
441
|
+
}
|
|
442
|
+
elif response.get("type") == "pong":
|
|
443
|
+
# Pong responses are normal keep-alive messages, handle silently
|
|
444
|
+
return {"success": True, "type": "pong"}
|
|
445
|
+
else:
|
|
446
|
+
# Log unexpected response format
|
|
447
|
+
logger.warning(
|
|
448
|
+
f"Unexpected WebSocket response type: {response.get('type')}"
|
|
449
|
+
)
|
|
450
|
+
return {"success": True, **response}
|
|
451
|
+
|
|
452
|
+
except TimeoutError:
|
|
453
|
+
self.cancel_pending_response(message_id)
|
|
454
|
+
raise Exception("Command timeout")
|
|
455
|
+
except Exception:
|
|
456
|
+
self.cancel_pending_response(message_id)
|
|
457
|
+
raise
|
|
458
|
+
|
|
459
|
+
async def subscribe_events(self, event_type: str | None = None) -> int:
|
|
460
|
+
"""Subscribe to Home Assistant events.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
event_type: Specific event type to subscribe to (None for all)
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Subscription ID
|
|
467
|
+
"""
|
|
468
|
+
kwargs = {}
|
|
469
|
+
if event_type:
|
|
470
|
+
kwargs["event_type"] = event_type
|
|
471
|
+
|
|
472
|
+
response = await self.send_command("subscribe_events", **kwargs)
|
|
473
|
+
result = response.get("result")
|
|
474
|
+
if isinstance(result, dict):
|
|
475
|
+
subscription_id = result.get("subscription")
|
|
476
|
+
if isinstance(subscription_id, int):
|
|
477
|
+
return subscription_id
|
|
478
|
+
|
|
479
|
+
raise Exception("Failed to get subscription ID")
|
|
480
|
+
|
|
481
|
+
def add_event_handler(
|
|
482
|
+
self,
|
|
483
|
+
event_type: str,
|
|
484
|
+
handler: Callable[[dict[str, Any]], Awaitable[None]],
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Add event handler for specific event type.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
event_type: Event type to handle (e.g., 'state_changed')
|
|
490
|
+
handler: Async function to handle events
|
|
491
|
+
"""
|
|
492
|
+
self._state.add_event_handler(event_type, handler)
|
|
493
|
+
|
|
494
|
+
def remove_event_handler(
|
|
495
|
+
self,
|
|
496
|
+
event_type: str,
|
|
497
|
+
handler: Callable[[dict[str, Any]], Awaitable[None]],
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Remove event handler."""
|
|
500
|
+
self._state.remove_event_handler(event_type, handler)
|
|
501
|
+
|
|
502
|
+
async def get_states(self) -> dict[str, Any]:
|
|
503
|
+
"""Get all entity states via WebSocket."""
|
|
504
|
+
return await self.send_command("get_states")
|
|
505
|
+
|
|
506
|
+
async def get_config(self) -> dict[str, Any]:
|
|
507
|
+
"""Get Home Assistant configuration via WebSocket."""
|
|
508
|
+
return await self.send_command("get_config")
|
|
509
|
+
|
|
510
|
+
async def call_service(
|
|
511
|
+
self,
|
|
512
|
+
domain: str,
|
|
513
|
+
service: str,
|
|
514
|
+
service_data: dict[str, Any] | None = None,
|
|
515
|
+
target: dict[str, Any] | None = None,
|
|
516
|
+
) -> dict[str, Any]:
|
|
517
|
+
"""Call Home Assistant service via WebSocket.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
domain: Service domain (e.g., 'light')
|
|
521
|
+
service: Service name (e.g., 'turn_on')
|
|
522
|
+
service_data: Service parameters
|
|
523
|
+
target: Service target (entity_id, area_id, etc.)
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Service call response
|
|
527
|
+
"""
|
|
528
|
+
kwargs: dict[str, Any] = {"domain": domain, "service": service}
|
|
529
|
+
|
|
530
|
+
if service_data:
|
|
531
|
+
kwargs["service_data"] = service_data
|
|
532
|
+
if target:
|
|
533
|
+
kwargs["target"] = target
|
|
534
|
+
|
|
535
|
+
return await self.send_command("call_service", **kwargs)
|
|
536
|
+
|
|
537
|
+
async def ping(self) -> bool:
|
|
538
|
+
"""Ping Home Assistant to check connection health.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
True if ping successful
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
response = await self.send_command("ping")
|
|
545
|
+
return response.get("type") == "pong"
|
|
546
|
+
except Exception:
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def is_connected(self) -> bool:
|
|
551
|
+
"""Check if WebSocket is connected and authenticated."""
|
|
552
|
+
return self._state.is_ready
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class WebSocketManager:
|
|
557
|
+
"""Singleton manager for Home Assistant WebSocket connections."""
|
|
558
|
+
|
|
559
|
+
_instance = None
|
|
560
|
+
_client = None
|
|
561
|
+
_current_loop: asyncio.AbstractEventLoop | None = None
|
|
562
|
+
_lock: asyncio.Lock | None = None
|
|
563
|
+
_lock_loop: asyncio.AbstractEventLoop | None = None
|
|
564
|
+
_client_factory: Callable[[str, str], HomeAssistantWebSocketClient] | None = None
|
|
565
|
+
|
|
566
|
+
def __new__(cls) -> "WebSocketManager":
|
|
567
|
+
if cls._instance is None:
|
|
568
|
+
cls._instance = super().__new__(cls)
|
|
569
|
+
cls._instance._lock = None
|
|
570
|
+
cls._instance._lock_loop = None
|
|
571
|
+
cls._instance._client_factory = HomeAssistantWebSocketClient
|
|
572
|
+
return cls._instance
|
|
573
|
+
|
|
574
|
+
def configure(
|
|
575
|
+
self,
|
|
576
|
+
*,
|
|
577
|
+
client_factory: Callable[[str, str], HomeAssistantWebSocketClient] | None = None,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Configure the manager with injectable dependencies."""
|
|
580
|
+
if client_factory is not None:
|
|
581
|
+
self._client_factory = client_factory
|
|
582
|
+
|
|
583
|
+
def _ensure_lock(self) -> None:
|
|
584
|
+
"""Ensure lock is created in the current event loop."""
|
|
585
|
+
try:
|
|
586
|
+
current_loop = asyncio.get_running_loop()
|
|
587
|
+
except RuntimeError:
|
|
588
|
+
current_loop = None
|
|
589
|
+
|
|
590
|
+
if (
|
|
591
|
+
self._lock is not None
|
|
592
|
+
and self._lock_loop is not None
|
|
593
|
+
and self._lock_loop != current_loop
|
|
594
|
+
):
|
|
595
|
+
logger.debug("Event loop changed, resetting WebSocketManager lock")
|
|
596
|
+
self._lock = None
|
|
597
|
+
|
|
598
|
+
if self._lock is None:
|
|
599
|
+
self._lock = asyncio.Lock()
|
|
600
|
+
self._lock_loop = current_loop
|
|
601
|
+
logger.debug("Created new WebSocketManager lock for current event loop")
|
|
602
|
+
|
|
603
|
+
async def get_client(self) -> HomeAssistantWebSocketClient:
|
|
604
|
+
"""Get WebSocket client, creating connection if needed."""
|
|
605
|
+
current_loop = asyncio.get_event_loop()
|
|
606
|
+
|
|
607
|
+
self._ensure_lock()
|
|
608
|
+
|
|
609
|
+
if not self._lock:
|
|
610
|
+
raise Exception("Lock not initialized")
|
|
611
|
+
async with self._lock:
|
|
612
|
+
if self._current_loop is not None and self._current_loop != current_loop:
|
|
613
|
+
if self._client:
|
|
614
|
+
try:
|
|
615
|
+
await self._client.disconnect()
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
self._client = None
|
|
619
|
+
|
|
620
|
+
self._current_loop = current_loop
|
|
621
|
+
|
|
622
|
+
if self._client and self._client.is_connected:
|
|
623
|
+
return self._client
|
|
624
|
+
|
|
625
|
+
settings = get_global_settings()
|
|
626
|
+
factory = self._client_factory or HomeAssistantWebSocketClient
|
|
627
|
+
self._client = factory(
|
|
628
|
+
settings.homeassistant_url, settings.homeassistant_token
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
connected = await self._client.connect()
|
|
632
|
+
if not connected:
|
|
633
|
+
raise Exception("Failed to connect to Home Assistant WebSocket")
|
|
634
|
+
|
|
635
|
+
return self._client
|
|
636
|
+
|
|
637
|
+
async def disconnect(self) -> None:
|
|
638
|
+
"""Disconnect WebSocket client."""
|
|
639
|
+
self._ensure_lock()
|
|
640
|
+
|
|
641
|
+
if not self._lock:
|
|
642
|
+
raise Exception("Lock not initialized")
|
|
643
|
+
async with self._lock:
|
|
644
|
+
if self._client:
|
|
645
|
+
await self._client.disconnect()
|
|
646
|
+
self._client = None
|
|
647
|
+
self._current_loop = None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# Global WebSocket manager instance
|
|
651
|
+
websocket_manager = WebSocketManager()
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
async def get_websocket_client() -> HomeAssistantWebSocketClient:
|
|
655
|
+
"""Get the global WebSocket client instance."""
|
|
656
|
+
return await websocket_manager.get_client()
|