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,924 @@
1
+ """
2
+ Home Assistant HTTP client with authentication and error handling.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ..config import get_global_settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class HomeAssistantError(Exception):
18
+ """Base exception for Home Assistant API errors."""
19
+
20
+ pass
21
+
22
+
23
+ class HomeAssistantConnectionError(HomeAssistantError):
24
+ """Connection error to Home Assistant."""
25
+
26
+ pass
27
+
28
+
29
+ class HomeAssistantAuthError(HomeAssistantError):
30
+ """Authentication error with Home Assistant."""
31
+
32
+ pass
33
+
34
+
35
+ class HomeAssistantAPIError(HomeAssistantError):
36
+ """API error from Home Assistant."""
37
+
38
+ def __init__(
39
+ self,
40
+ message: str,
41
+ status_code: int | None = None,
42
+ response_data: dict[str, Any] | None = None,
43
+ ):
44
+ super().__init__(message)
45
+ self.status_code = status_code
46
+ self.response_data = response_data
47
+
48
+
49
+ class HomeAssistantClient:
50
+ """Authenticated HTTP client for Home Assistant API."""
51
+
52
+ def __init__(
53
+ self,
54
+ base_url: str | None = None,
55
+ token: str | None = None,
56
+ timeout: int | None = None,
57
+ ):
58
+ """
59
+ Initialize Home Assistant client.
60
+
61
+ Args:
62
+ base_url: Home Assistant URL (defaults to config)
63
+ token: Long-lived access token (defaults to config)
64
+ timeout: Request timeout in seconds (defaults to config)
65
+ """
66
+ # Only load settings if we need to use fallback values
67
+ if base_url is None or token is None:
68
+ settings = get_global_settings()
69
+ self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
70
+ self.token = token or settings.homeassistant_token
71
+ self.timeout = timeout if timeout is not None else settings.timeout
72
+ else:
73
+ # All required parameters provided, use them directly without loading settings
74
+ self.base_url = base_url.rstrip("/")
75
+ self.token = token
76
+ self.timeout = timeout if timeout is not None else 30 # Default timeout
77
+
78
+ # Create HTTP client with authentication headers
79
+ self.httpx_client = httpx.AsyncClient(
80
+ base_url=f"{self.base_url}/api",
81
+ headers={
82
+ "Authorization": f"Bearer {self.token}",
83
+ "Content-Type": "application/json",
84
+ },
85
+ timeout=httpx.Timeout(self.timeout),
86
+ )
87
+
88
+ logger.info(f"Initialized Home Assistant client for {self.base_url}")
89
+
90
+ async def __aenter__(self) -> 'HomeAssistantClient':
91
+ """Async context manager entry."""
92
+ return self
93
+
94
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
95
+ """Async context manager exit."""
96
+ await self.close()
97
+
98
+ async def close(self) -> None:
99
+ """Close HTTP client."""
100
+ await self.httpx_client.aclose()
101
+ logger.debug("Closed Home Assistant client")
102
+
103
+ async def _request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
104
+ """
105
+ Make authenticated request to Home Assistant API.
106
+
107
+ Args:
108
+ method: HTTP method (GET, POST, etc.)
109
+ endpoint: API endpoint (without /api prefix)
110
+ **kwargs: Additional arguments for httpx request
111
+
112
+ Returns:
113
+ Response data as dictionary
114
+
115
+ Raises:
116
+ HomeAssistantConnectionError: Connection failed
117
+ HomeAssistantAuthError: Authentication failed
118
+ HomeAssistantAPIError: API error
119
+ """
120
+ try:
121
+ response = await self.httpx_client.request(method, endpoint, **kwargs)
122
+
123
+ # Handle authentication errors
124
+ if response.status_code == 401:
125
+ raise HomeAssistantAuthError("Invalid authentication token")
126
+
127
+ # Handle other HTTP errors
128
+ if response.status_code >= 400:
129
+ try:
130
+ error_data = response.json()
131
+ except Exception:
132
+ error_data = {"message": response.text}
133
+
134
+ raise HomeAssistantAPIError(
135
+ f"API error: {response.status_code} - {error_data.get('message', 'Unknown error')}",
136
+ status_code=response.status_code,
137
+ response_data=error_data,
138
+ )
139
+
140
+ # Parse JSON response
141
+ try:
142
+ result: dict[str, Any] = response.json()
143
+ return result
144
+ except json.JSONDecodeError:
145
+ # Some endpoints return empty responses
146
+ return {}
147
+
148
+ except httpx.ConnectError as e:
149
+ raise HomeAssistantConnectionError(
150
+ f"Failed to connect to Home Assistant: {e}"
151
+ ) from e
152
+ except httpx.TimeoutException as e:
153
+ raise HomeAssistantConnectionError(f"Request timeout: {e}") from e
154
+ except httpx.HTTPError as e:
155
+ raise HomeAssistantConnectionError(f"HTTP error: {e}") from e
156
+
157
+ async def get_config(self) -> dict[str, Any]:
158
+ """Get Home Assistant configuration."""
159
+ logger.debug("Fetching Home Assistant configuration")
160
+ return await self._request("GET", "/config")
161
+
162
+ async def get_states(self) -> list[dict[str, Any]]:
163
+ """Get all entity states."""
164
+ logger.debug("Fetching all entity states")
165
+ result = await self._request("GET", "/states")
166
+ if isinstance(result, list):
167
+ return result
168
+ else:
169
+ return []
170
+
171
+ async def get_entity_state(self, entity_id: str) -> dict[str, Any]:
172
+ """
173
+ Get specific entity state.
174
+
175
+ Args:
176
+ entity_id: Entity ID (e.g., 'light.living_room')
177
+
178
+ Returns:
179
+ Entity state data
180
+ """
181
+ logger.debug(f"Fetching state for entity: {entity_id}")
182
+ return await self._request("GET", f"/states/{entity_id}")
183
+
184
+ async def set_entity_state(
185
+ self, entity_id: str, state: str, attributes: dict[str, Any] | None = None
186
+ ) -> dict[str, Any]:
187
+ """
188
+ Set entity state.
189
+
190
+ Args:
191
+ entity_id: Entity ID
192
+ state: New state value
193
+ attributes: Optional attributes dictionary
194
+
195
+ Returns:
196
+ Updated entity state
197
+ """
198
+ logger.debug(f"Setting state for entity {entity_id} to {state}")
199
+
200
+ payload: dict[str, Any] = {"state": state}
201
+ if attributes:
202
+ payload["attributes"] = attributes
203
+
204
+ return await self._request("POST", f"/states/{entity_id}", json=payload)
205
+
206
+ async def call_service(
207
+ self, domain: str, service: str, data: dict[str, Any] | None = None,
208
+ return_response: bool = False
209
+ ) -> list[dict[str, Any]] | dict[str, Any]:
210
+ """
211
+ Call Home Assistant service.
212
+
213
+ Args:
214
+ domain: Service domain (e.g., 'light', 'climate')
215
+ service: Service name (e.g., 'turn_on', 'set_temperature')
216
+ data: Optional service data
217
+ return_response: If True, returns the service response data (for services
218
+ that support SupportsResponse.ONLY or SupportsResponse.OPTIONAL)
219
+
220
+ Returns:
221
+ Service response data - list of affected states normally, or dict with
222
+ service response if return_response=True
223
+ """
224
+ logger.debug(f"Calling service {domain}.{service} (return_response={return_response})")
225
+
226
+ payload = data or {}
227
+
228
+ # Build query params for return_response
229
+ params = {}
230
+ if return_response:
231
+ params["return_response"] = "true"
232
+
233
+ result = await self._request(
234
+ "POST", f"/services/{domain}/{service}", json=payload, params=params if params else None
235
+ )
236
+
237
+ # When return_response is True, HA returns a dict with service_response key
238
+ if return_response:
239
+ if isinstance(result, dict):
240
+ return result
241
+ return {"service_response": result}
242
+
243
+ # Normal behavior: return list of affected states
244
+ if isinstance(result, list):
245
+ return result
246
+ else:
247
+ return []
248
+
249
+ async def get_services(self) -> dict[str, Any]:
250
+ """Get all available services."""
251
+ logger.debug("Fetching available services")
252
+ return await self._request("GET", "/services")
253
+
254
+ async def get_history(
255
+ self,
256
+ entity_id: str | None = None,
257
+ start_time: str | None = None,
258
+ end_time: str | None = None,
259
+ ) -> list[list[dict[str, Any]]]:
260
+ """
261
+ Get historical data.
262
+
263
+ Args:
264
+ entity_id: Optional entity ID to filter
265
+ start_time: Optional start time (ISO format)
266
+ end_time: Optional end time (ISO format)
267
+
268
+ Returns:
269
+ Historical data
270
+ """
271
+ logger.debug(f"Fetching history for entity: {entity_id}")
272
+
273
+ params = {}
274
+ if start_time:
275
+ params["start_time"] = start_time
276
+ if end_time:
277
+ params["end_time"] = end_time
278
+
279
+ endpoint = "/history/period"
280
+ if entity_id:
281
+ endpoint += f"/{entity_id}"
282
+
283
+ result = await self._request("GET", endpoint, params=params)
284
+ if isinstance(result, list):
285
+ return result
286
+ else:
287
+ return []
288
+
289
+ async def get_logbook(
290
+ self,
291
+ entity_id: str | None = None,
292
+ start_time: str | None = None,
293
+ end_time: str | None = None,
294
+ ) -> list[dict[str, Any]]:
295
+ """
296
+ Get logbook entries.
297
+
298
+ Args:
299
+ entity_id: Optional entity ID to filter
300
+ start_time: Optional start time (ISO format) - used as URL path component
301
+ end_time: Optional end time (ISO format) - used as query parameter
302
+
303
+ Returns:
304
+ Logbook entries
305
+ """
306
+ logger.debug(f"Fetching logbook entries for entity: {entity_id}, start: {start_time}, end: {end_time}")
307
+
308
+ # Build endpoint - start_time goes in URL path if provided
309
+ if start_time:
310
+ endpoint = f"/logbook/{start_time}"
311
+ else:
312
+ endpoint = "/logbook"
313
+
314
+ # Build query parameters
315
+ params = {}
316
+ if entity_id:
317
+ params["entity"] = entity_id
318
+ if end_time:
319
+ params["end_time"] = end_time
320
+
321
+ result = await self._request("GET", endpoint, params=params)
322
+ if isinstance(result, list):
323
+ return result
324
+ else:
325
+ return []
326
+
327
+ async def fire_event(
328
+ self, event_type: str, data: dict[str, Any] | None = None
329
+ ) -> dict[str, Any]:
330
+ """
331
+ Fire Home Assistant event.
332
+
333
+ Args:
334
+ event_type: Event type name
335
+ data: Optional event data
336
+
337
+ Returns:
338
+ Event response
339
+ """
340
+ logger.debug(f"Firing event: {event_type}")
341
+
342
+ payload = data or {}
343
+ return await self._request("POST", f"/events/{event_type}", json=payload)
344
+
345
+ async def render_template(self, template: str) -> str:
346
+ """
347
+ Render Home Assistant template.
348
+
349
+ Args:
350
+ template: Template string
351
+
352
+ Returns:
353
+ Rendered template
354
+ """
355
+ logger.debug("Rendering template")
356
+
357
+ payload = {"template": template}
358
+ response = await self._request("POST", "/template", json=payload)
359
+ result = response.get("result")
360
+ return str(result) if result is not None else ""
361
+
362
+ async def check_config(self) -> dict[str, Any]:
363
+ """Check Home Assistant configuration."""
364
+ logger.debug("Checking configuration")
365
+ return await self._request("POST", "/config/core/check_config")
366
+
367
+ async def get_error_log(self) -> str:
368
+ """Get Home Assistant error log."""
369
+ logger.debug("Fetching error log")
370
+ response = await self._request("GET", "/error_log")
371
+ return response if isinstance(response, str) else str(response)
372
+
373
+ async def test_connection(self) -> tuple[bool, str | None]:
374
+ """
375
+ Test connection to Home Assistant.
376
+
377
+ Returns:
378
+ tuple: (success, error_message)
379
+ """
380
+ try:
381
+ config = await self.get_config()
382
+ if config.get("location_name"):
383
+ logger.info(
384
+ f"Successfully connected to Home Assistant: {config['location_name']}"
385
+ )
386
+ return True, None
387
+ else:
388
+ return False, "Invalid response from Home Assistant"
389
+ except Exception as e:
390
+ logger.error(f"Failed to connect to Home Assistant: {e}")
391
+ return False, str(e)
392
+
393
+ async def get_system_health(self) -> dict[str, Any]:
394
+ """Get system health information."""
395
+ logger.debug("Fetching system health")
396
+ try:
397
+ return await self._request("GET", "/system_health/info")
398
+ except HomeAssistantAPIError:
399
+ # System health might not be available in all HA instances
400
+ return {"status": "unknown", "message": "System health not available"}
401
+
402
+ # Automation Configuration Management
403
+
404
+ async def _resolve_automation_id(self, identifier: str) -> str:
405
+ """
406
+ Convert entity_id to unique_id if needed, or return unique_id as-is.
407
+
408
+ Args:
409
+ identifier: Either entity_id (automation.xxx) or unique_id
410
+
411
+ Returns:
412
+ The unique_id for configuration API
413
+
414
+ Raises:
415
+ HomeAssistantAPIError: If automation not found
416
+ """
417
+ # If it looks like an entity_id, convert to unique_id
418
+ if identifier.startswith("automation."):
419
+ try:
420
+ state = await self.get_entity_state(identifier)
421
+ unique_id = state.get("attributes", {}).get("id")
422
+ if not unique_id:
423
+ raise HomeAssistantAPIError(
424
+ f"Automation {identifier} has no unique_id attribute",
425
+ status_code=404,
426
+ )
427
+ logger.debug(
428
+ f"Converted entity_id {identifier} to unique_id {unique_id}"
429
+ )
430
+ return str(unique_id)
431
+ except Exception as e:
432
+ raise HomeAssistantAPIError(
433
+ f"Failed to resolve automation {identifier}: {str(e)}",
434
+ status_code=404,
435
+ )
436
+ else:
437
+ # Assume it's already a unique_id
438
+ return identifier
439
+
440
+ async def get_automation_config(self, identifier: str) -> dict[str, Any]:
441
+ """
442
+ Get automation configuration by unique_id or entity_id.
443
+
444
+ Args:
445
+ identifier: Either automation entity_id (automation.xxx) or unique_id
446
+
447
+ Returns:
448
+ Automation configuration dictionary
449
+
450
+ Raises:
451
+ HomeAssistantAPIError: If automation not found or API error
452
+ """
453
+ unique_id = await self._resolve_automation_id(identifier)
454
+ logger.debug(f"Fetching automation config for unique_id: {unique_id}")
455
+
456
+ try:
457
+ response = await self._request(
458
+ "GET", f"/config/automation/config/{unique_id}"
459
+ )
460
+ return response
461
+ except Exception as e:
462
+ if "404" in str(e):
463
+ raise HomeAssistantAPIError(
464
+ f"Automation not found: {identifier} (unique_id: {unique_id})",
465
+ status_code=404,
466
+ )
467
+ raise
468
+
469
+ async def upsert_automation_config(
470
+ self, config: dict[str, Any], identifier: str | None = None
471
+ ) -> dict[str, Any]:
472
+ """
473
+ Create new automation or update existing one.
474
+
475
+ Args:
476
+ config: Automation configuration dictionary
477
+ identifier: Optional automation entity_id or unique_id (None = create new)
478
+
479
+ Returns:
480
+ Result with automation unique_id and status
481
+
482
+ Raises:
483
+ HomeAssistantAPIError: If configuration invalid or API error
484
+ """
485
+ import time
486
+
487
+ # Generate unique_id for new automation if not provided
488
+ if identifier is None:
489
+ unique_id = str(int(time.time() * 1000))
490
+ operation = "created"
491
+ logger.debug(f"Creating new automation with unique_id: {unique_id}")
492
+ else:
493
+ unique_id = await self._resolve_automation_id(identifier)
494
+ operation = "updated"
495
+ logger.debug(f"Updating automation with unique_id: {unique_id}")
496
+
497
+ # Add unique_id to config for updates
498
+ if unique_id and "id" not in config:
499
+ config = {**config, "id": unique_id}
500
+
501
+ try:
502
+ response = await self._request(
503
+ "POST", f"/config/automation/config/{unique_id}", json=config
504
+ )
505
+
506
+ # For new automations, query Home Assistant to get the actual entity_id that was assigned
507
+ actual_entity_id = None
508
+ if operation == "created":
509
+ try:
510
+ # Give Home Assistant a moment to register the entity
511
+ import asyncio
512
+
513
+ await asyncio.sleep(1)
514
+
515
+ # Get all automations and find the one with our unique_id
516
+ states = await self.get_states()
517
+ for state in states:
518
+ if state.get("entity_id", "").startswith("automation."):
519
+ attributes = state.get("attributes", {})
520
+ if attributes.get("id") == unique_id:
521
+ actual_entity_id = state.get("entity_id")
522
+ logger.debug(
523
+ f"Found actual entity_id for unique_id {unique_id}: {actual_entity_id}"
524
+ )
525
+ break
526
+
527
+ if not actual_entity_id:
528
+ # Fallback to predicted entity_id if we can't find it
529
+ actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
530
+ logger.warning(
531
+ f"Could not find actual entity_id for unique_id {unique_id}, using predicted: {actual_entity_id}"
532
+ )
533
+
534
+ except Exception as e:
535
+ logger.warning(
536
+ f"Failed to query actual entity_id for unique_id {unique_id}: {e}"
537
+ )
538
+ # Fallback to predicted entity_id
539
+ actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
540
+
541
+ return {
542
+ "unique_id": unique_id,
543
+ "entity_id": actual_entity_id,
544
+ "result": response.get("result", "ok"),
545
+ "operation": operation,
546
+ }
547
+ except Exception as e:
548
+ if "400" in str(e):
549
+ raise HomeAssistantAPIError(
550
+ f"Invalid automation configuration: {str(e)}", status_code=400
551
+ )
552
+ raise
553
+
554
+ async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
555
+ """
556
+ Delete automation configuration by entity_id or unique_id.
557
+
558
+ Args:
559
+ identifier: Either automation entity_id (automation.xxx) or unique_id
560
+
561
+ Returns:
562
+ Deletion result
563
+
564
+ Raises:
565
+ HomeAssistantAPIError: If automation not found or API error
566
+ """
567
+ unique_id = await self._resolve_automation_id(identifier)
568
+ logger.debug(f"Deleting automation config for unique_id: {unique_id}")
569
+
570
+ try:
571
+ response = await self._request(
572
+ "DELETE", f"/config/automation/config/{unique_id}"
573
+ )
574
+ return {
575
+ "identifier": identifier,
576
+ "unique_id": unique_id,
577
+ "result": response.get("result", "ok"),
578
+ "operation": "deleted",
579
+ }
580
+ except HomeAssistantAPIError as e:
581
+ if e.status_code == 404:
582
+ raise HomeAssistantAPIError(
583
+ f"Automation not found: {identifier} (unique_id: {unique_id})",
584
+ status_code=404,
585
+ )
586
+ elif e.status_code == 405:
587
+ raise HomeAssistantAPIError(
588
+ f"Cannot delete automation '{identifier}': The HTTP DELETE method is blocked. "
589
+ f"This typically occurs when running ha-mcp as a Home Assistant add-on, because "
590
+ f"the Supervisor ingress proxy only allows GET and POST requests. "
591
+ f"WORKAROUNDS: "
592
+ f"(1) Use ha-mcp via pip, Docker, or as an external MCP server instead of the add-on. "
593
+ f"(2) Use a long-lived access token to connect directly to Home Assistant's API. "
594
+ f"(3) As a fallback, disable the automation and rename it with a 'DELETE_' prefix "
595
+ f"(e.g., 'DELETE_{identifier}') so you can identify and manually delete it later "
596
+ f"via the Home Assistant UI (Settings > Automations & Scenes).",
597
+ status_code=405,
598
+ )
599
+ raise
600
+ except Exception as e:
601
+ if "404" in str(e):
602
+ raise HomeAssistantAPIError(
603
+ f"Automation not found: {identifier} (unique_id: {unique_id})",
604
+ status_code=404,
605
+ )
606
+ raise
607
+
608
+ async def start_config_flow(
609
+ self, handler: str, context: dict[str, Any] | None = None
610
+ ) -> dict[str, Any]:
611
+ """
612
+ Start a config entry flow.
613
+
614
+ Args:
615
+ handler: Integration domain (e.g., "template", "group")
616
+ context: Optional context (e.g., {"source": "user"})
617
+
618
+ Returns:
619
+ Flow data with flow_id, step_id, data_schema
620
+
621
+ Raises:
622
+ HomeAssistantAPIError: If flow start fails
623
+ """
624
+ payload = {"handler": handler}
625
+ if context:
626
+ payload["context"] = context
627
+
628
+ logger.debug(f"Starting config flow for handler: {handler}")
629
+ return await self._request("POST", "/config/config_entries/flow", json=payload)
630
+
631
+ async def submit_config_flow_step(
632
+ self, flow_id: str, user_input: dict[str, Any]
633
+ ) -> dict[str, Any]:
634
+ """
635
+ Submit data for a config flow step.
636
+
637
+ Args:
638
+ flow_id: Flow ID from start_config_flow or previous step
639
+ user_input: Form data for current step
640
+
641
+ Returns:
642
+ Flow result: type = "create_entry" | "form" | "abort"
643
+
644
+ Raises:
645
+ HomeAssistantAPIError: If flow submission fails
646
+ """
647
+ logger.debug(f"Submitting flow step for flow_id: {flow_id}")
648
+ return await self._request(
649
+ "POST", f"/config/config_entries/flow/{flow_id}", json=user_input
650
+ )
651
+
652
+ async def get_config_entry(self, entry_id: str) -> dict[str, Any]:
653
+ """
654
+ Get config entry details.
655
+
656
+ Note: Home Assistant doesn't have a direct REST API endpoint for individual
657
+ config entries. This method lists all entries and filters by entry_id.
658
+
659
+ Args:
660
+ entry_id: Config entry ID
661
+
662
+ Returns:
663
+ Full config entry data
664
+
665
+ Raises:
666
+ HomeAssistantAPIError: If entry not found or API error
667
+ """
668
+ logger.debug(f"Getting config entry: {entry_id}")
669
+ # List all entries and filter by entry_id
670
+ entries = await self._request("GET", "/config/config_entries/entry")
671
+
672
+ if not isinstance(entries, list):
673
+ raise HomeAssistantAPIError(
674
+ "Unexpected response format from config entries API",
675
+ status_code=500,
676
+ )
677
+
678
+ for entry in entries:
679
+ if entry.get("entry_id") == entry_id:
680
+ return entry
681
+
682
+ raise HomeAssistantAPIError(
683
+ f"Config entry not found: {entry_id}",
684
+ status_code=404,
685
+ )
686
+
687
+ async def send_websocket_message(self, message: dict[str, Any]) -> dict[str, Any]:
688
+ """Send message via WebSocket and wait for response.
689
+
690
+ Uses the global WebSocket singleton to avoid race conditions from
691
+ parallel tool calls creating multiple simultaneous connections.
692
+ """
693
+ from .websocket_client import get_websocket_client
694
+
695
+ max_retries = 2
696
+ retry_delay = 0.5 # seconds
697
+
698
+ for attempt in range(max_retries):
699
+ try:
700
+ # Use singleton WebSocket client (shared, reused connection)
701
+ ws_client = await get_websocket_client()
702
+
703
+ # Special handling for render_template which returns an event with the actual result
704
+ if message.get("type") == "render_template":
705
+ return await self._handle_render_template(ws_client, message)
706
+
707
+ # Extract command type and parameters for other commands
708
+ message_copy = message.copy()
709
+ command_type = message_copy.pop("type")
710
+ result = await ws_client.send_command(command_type, **message_copy)
711
+
712
+ return result
713
+
714
+ except Exception as e:
715
+ error_str = str(e)
716
+
717
+ # Detect transient 403 errors (rate limiting / reverse proxy throttling)
718
+ if "403" in error_str and "Forbidden" in error_str:
719
+ if attempt < max_retries - 1:
720
+ logger.warning(
721
+ f"WebSocket 403 error (attempt {attempt + 1}/{max_retries}), "
722
+ f"retrying after {retry_delay}s: {error_str}"
723
+ )
724
+ await asyncio.sleep(retry_delay)
725
+ continue
726
+ else:
727
+ logger.error(f"WebSocket 403 error after {max_retries} attempts: {error_str}")
728
+ return {
729
+ "success": False,
730
+ "error": f"WebSocket request blocked (403 Forbidden): {error_str}",
731
+ "suggestions": [
732
+ "This may be caused by a reverse proxy or security filter",
733
+ "Try simplifying the request (e.g., shorter templates, fewer parameters)",
734
+ "If using complex templates, try breaking them into smaller parts",
735
+ "Check if your Home Assistant is behind a reverse proxy with security rules",
736
+ ],
737
+ }
738
+
739
+ logger.error(f"WebSocket message failed: {e}")
740
+ return {"success": False, "error": str(e)}
741
+
742
+ async def _handle_render_template(
743
+ self, ws_client: Any, message: dict[str, Any]
744
+ ) -> dict[str, Any]:
745
+ """Handle render_template WebSocket command with event-based response."""
746
+
747
+ # Generate our own message ID to track the response
748
+ message_id = ws_client.get_next_message_id()
749
+
750
+ # Construct the full message with proper ID
751
+ full_message = {
752
+ "id": message_id,
753
+ "type": "render_template",
754
+ "template": message.get("template"),
755
+ "timeout": message.get("timeout", 3),
756
+ "report_errors": message.get("report_errors", True),
757
+ }
758
+
759
+ # Create futures for both result and event responses
760
+ result_future = ws_client.register_pending_response(message_id)
761
+ event_future = ws_client.register_render_template_event(message_id)
762
+
763
+ # Use WebSocket client's send helper to transmit the message
764
+ try:
765
+ await ws_client.send_json_message(full_message)
766
+ except Exception as e:
767
+ ws_client.cancel_pending_response(message_id)
768
+ ws_client.cancel_render_template_event(message_id)
769
+ raise e
770
+
771
+ try:
772
+ # Wait for the initial result response (should be success with null result)
773
+ result_response = await asyncio.wait_for(
774
+ result_future, timeout=message.get("timeout", 3) + 2
775
+ )
776
+ logger.debug(f"WebSocket render_template result: {result_response}")
777
+
778
+ if not result_response.get("success"):
779
+ ws_client.cancel_render_template_event(message_id)
780
+ error = result_response.get("error", "Unknown error")
781
+ return {
782
+ "success": False,
783
+ "error": str(error),
784
+ "template": message.get("template"),
785
+ }
786
+
787
+ # Wait for the event with the actual template result
788
+ try:
789
+ event_response = await asyncio.wait_for(
790
+ event_future, timeout=message.get("timeout", 3) + 1
791
+ )
792
+ logger.debug(f"WebSocket render_template event: {event_response}")
793
+
794
+ # Extract template result from event
795
+ if "event" in event_response and "result" in event_response["event"]:
796
+ template_result = event_response["event"]["result"]
797
+ listeners_info = event_response["event"].get("listeners", {})
798
+
799
+ return {
800
+ "success": True,
801
+ "result": template_result,
802
+ "template": message.get("template"),
803
+ "listeners": listeners_info,
804
+ }
805
+ else:
806
+ return {
807
+ "success": False,
808
+ "error": "Invalid event response format",
809
+ "template": message.get("template"),
810
+ }
811
+
812
+ except TimeoutError:
813
+ ws_client.cancel_render_template_event(message_id)
814
+ return {
815
+ "success": False,
816
+ "error": "Event timeout - template result not received",
817
+ "template": message.get("template"),
818
+ }
819
+
820
+ except TimeoutError:
821
+ ws_client.cancel_pending_response(message_id)
822
+ ws_client.cancel_render_template_event(message_id)
823
+ return {
824
+ "success": False,
825
+ "error": "Command timeout",
826
+ "template": message.get("template"),
827
+ }
828
+ except Exception as e:
829
+ ws_client.cancel_pending_response(message_id)
830
+ ws_client.cancel_render_template_event(message_id)
831
+ return {
832
+ "success": False,
833
+ "error": str(e),
834
+ "template": message.get("template"),
835
+ }
836
+
837
+ async def get_script_config(self, script_id: str) -> dict[str, Any]:
838
+ """Get Home Assistant script configuration by script_id."""
839
+ try:
840
+ endpoint = f"config/script/config/{script_id}"
841
+ response = await self._request("GET", endpoint)
842
+
843
+ return {"success": True, "script_id": script_id, "config": response}
844
+ except HomeAssistantAPIError as e:
845
+ if e.status_code == 404:
846
+ raise HomeAssistantAPIError(
847
+ f"Script not found: {script_id}", status_code=404
848
+ )
849
+ raise
850
+ except Exception as e:
851
+ logger.error(f"Failed to get script config for {script_id}: {e}")
852
+ raise
853
+
854
+ async def upsert_script_config(
855
+ self, config: dict[str, Any], script_id: str
856
+ ) -> dict[str, Any]:
857
+ """Create or update Home Assistant script configuration."""
858
+ try:
859
+ endpoint = f"config/script/config/{script_id}"
860
+
861
+ # Validate required fields
862
+ if "alias" not in config:
863
+ config["alias"] = script_id
864
+ if "sequence" not in config:
865
+ raise ValueError("Script configuration must include 'sequence'")
866
+
867
+ response = await self._request("POST", endpoint, json=config)
868
+
869
+ return {
870
+ "success": True,
871
+ "script_id": script_id,
872
+ "result": response.get("result", "ok"),
873
+ "operation": "created" if response.get("result") == "ok" else "updated",
874
+ }
875
+ except Exception as e:
876
+ logger.error(f"Failed to upsert script config for {script_id}: {e}")
877
+ raise
878
+
879
+ async def delete_script_config(self, script_id: str) -> dict[str, Any]:
880
+ """Delete Home Assistant script configuration."""
881
+ try:
882
+ endpoint = f"config/script/config/{script_id}"
883
+ response = await self._request("DELETE", endpoint)
884
+
885
+ return {
886
+ "success": True,
887
+ "script_id": script_id,
888
+ "result": response.get("result", "ok"),
889
+ "operation": "deleted",
890
+ }
891
+ except HomeAssistantAPIError as e:
892
+ if e.status_code == 404:
893
+ raise HomeAssistantAPIError(
894
+ f"Script not found: {script_id}", status_code=404
895
+ )
896
+ elif e.status_code == 405:
897
+ raise HomeAssistantAPIError(
898
+ f"Cannot delete script '{script_id}': The HTTP DELETE method is blocked. "
899
+ f"This typically occurs when running ha-mcp as a Home Assistant add-on, because "
900
+ f"the Supervisor ingress proxy only allows GET and POST requests. "
901
+ f"It may also occur if the script is defined in YAML configuration files. "
902
+ f"WORKAROUNDS: "
903
+ f"(1) Use ha-mcp via pip, Docker, or as an external MCP server instead of the add-on. "
904
+ f"(2) Use a long-lived access token to connect directly to Home Assistant's API. "
905
+ f"(3) If the script is YAML-defined, edit the configuration file directly. "
906
+ f"(4) As a fallback, disable the script and rename it with a 'DELETE_' prefix "
907
+ f"(e.g., 'DELETE_{script_id}') so you can identify and manually delete it later "
908
+ f"via the Home Assistant UI (Settings > Automations & Scenes > Scripts).",
909
+ status_code=405,
910
+ )
911
+ raise
912
+ except Exception as e:
913
+ raise
914
+
915
+
916
+ async def create_client() -> HomeAssistantClient:
917
+ """Create and return a new Home Assistant client."""
918
+ return HomeAssistantClient()
919
+
920
+
921
+ async def test_connection_with_config() -> tuple[bool, str | None]:
922
+ """Test connection using configuration settings."""
923
+ async with HomeAssistantClient() as client:
924
+ return await client.test_connection()