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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. 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()