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,496 @@
1
+ """
2
+ Trace retrieval tools for debugging Home Assistant automations and scripts.
3
+
4
+ This module provides tools for retrieving execution traces from Home Assistant
5
+ to help debug automation and script issues.
6
+ """
7
+
8
+ import logging
9
+ from typing import Annotated, Any
10
+
11
+ from pydantic import Field
12
+
13
+ from ..client.websocket_client import HomeAssistantWebSocketClient
14
+ from .helpers import get_connected_ws_client, log_tool_usage
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def register_trace_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
20
+ """Register Home Assistant trace debugging tools."""
21
+
22
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["trace"], "title": "Get Automation Traces"})
23
+ @log_tool_usage
24
+ async def ha_get_automation_traces(
25
+ automation_id: Annotated[
26
+ str,
27
+ Field(
28
+ description="Automation or script entity_id (e.g., 'automation.motion_light' or 'script.morning_routine')"
29
+ ),
30
+ ],
31
+ run_id: Annotated[
32
+ str | None,
33
+ Field(
34
+ description="Specific trace run_id to retrieve detailed trace. Omit to list recent traces.",
35
+ default=None,
36
+ ),
37
+ ] = None,
38
+ limit: Annotated[
39
+ int,
40
+ Field(
41
+ description="Maximum number of traces to return when listing (default: 10, max: 50)",
42
+ default=10,
43
+ ge=1,
44
+ le=50,
45
+ ),
46
+ ] = 10,
47
+ ) -> dict[str, Any]:
48
+ """
49
+ Retrieve execution traces for automations and scripts to debug issues.
50
+
51
+ Traces show what happened during automation/script runs:
52
+ - What triggered the automation
53
+ - Which conditions passed or failed
54
+ - What actions were executed
55
+ - Any errors that occurred
56
+ - Variable values during execution
57
+
58
+ USAGE MODES:
59
+
60
+ 1. List recent traces (omit run_id):
61
+ ha_get_automation_traces("automation.motion_light")
62
+ Returns a summary of recent execution runs with timestamps, triggers, and status.
63
+
64
+ 2. Get detailed trace (provide run_id):
65
+ ha_get_automation_traces("automation.motion_light", run_id="1705312800.123456")
66
+ Returns full execution details including trigger info, condition results,
67
+ action trace with timing, and context variables.
68
+
69
+ DEBUGGING EXAMPLES:
70
+
71
+ Automation not triggering:
72
+ - Check if traces exist (automation may not be triggered)
73
+ - Look at trigger info to see what event was received
74
+
75
+ Automation runs but conditions fail:
76
+ - Get detailed trace to see condition_results
77
+ - Each condition shows whether it passed (true) or failed (false)
78
+
79
+ Unexpected behavior in actions:
80
+ - Get detailed trace to see action_trace
81
+ - Shows each action step with result and any errors
82
+ - For 'choose' actions, shows which branch was taken
83
+
84
+ Template debugging:
85
+ - Detailed trace shows evaluated template values in context
86
+ - Trigger variables available under trigger_variables
87
+
88
+ NOTES:
89
+ - Traces are stored for a limited time by Home Assistant
90
+ - Works for both automations and scripts (use full entity_id)
91
+ - The 'state' field shows: 'stopped' (completed), 'running', or error state
92
+ """
93
+ try:
94
+ # Determine domain from entity_id
95
+ if automation_id.startswith("automation."):
96
+ domain = "automation"
97
+ elif automation_id.startswith("script."):
98
+ domain = "script"
99
+ else:
100
+ return {
101
+ "success": False,
102
+ "error": f"Invalid entity_id format: {automation_id}",
103
+ "hint": "Entity ID must start with 'automation.' or 'script.'",
104
+ }
105
+
106
+ # Extract the object_id (part after the domain) as fallback
107
+ object_id = automation_id.split(".", 1)[1]
108
+
109
+ # Connect to WebSocket
110
+ ws_client, error = await get_connected_ws_client(
111
+ client.base_url, client.token
112
+ )
113
+ if error or ws_client is None:
114
+ return error or {
115
+ "success": False,
116
+ "error": "Failed to connect to WebSocket",
117
+ }
118
+
119
+ try:
120
+ # Home Assistant stores traces by unique_id, not entity_id.
121
+ # We need to resolve entity_id -> unique_id via entity registry.
122
+ item_id = await _resolve_trace_item_id(
123
+ ws_client, automation_id, object_id
124
+ )
125
+
126
+ if run_id:
127
+ # Get specific trace details
128
+ result = await ws_client.send_command(
129
+ "trace/get",
130
+ domain=domain,
131
+ item_id=item_id,
132
+ run_id=run_id,
133
+ )
134
+
135
+ if not result.get("success"):
136
+ return {
137
+ "success": False,
138
+ "automation_id": automation_id,
139
+ "run_id": run_id,
140
+ "error": result.get("error", "Failed to retrieve trace"),
141
+ }
142
+
143
+ trace_data = result.get("result", {})
144
+ return _format_detailed_trace(automation_id, run_id, trace_data)
145
+ else:
146
+ # List recent traces
147
+ result = await ws_client.send_command(
148
+ "trace/list",
149
+ domain=domain,
150
+ item_id=item_id,
151
+ )
152
+
153
+ if not result.get("success"):
154
+ return {
155
+ "success": False,
156
+ "automation_id": automation_id,
157
+ "error": result.get("error", "Failed to list traces"),
158
+ }
159
+
160
+ traces_data = result.get("result", [])
161
+
162
+ # If traces are empty, gather diagnostic information
163
+ if not traces_data:
164
+ diagnostics = await _gather_diagnostics(
165
+ ws_client, client, automation_id, domain
166
+ )
167
+ return _format_trace_list(
168
+ automation_id, traces_data, limit, diagnostics
169
+ )
170
+
171
+ return _format_trace_list(automation_id, traces_data, limit)
172
+
173
+ finally:
174
+ await ws_client.disconnect()
175
+
176
+ except Exception as e:
177
+ logger.error(f"Error getting traces for {automation_id}: {e}")
178
+ return {
179
+ "success": False,
180
+ "automation_id": automation_id,
181
+ "error": str(e),
182
+ "suggestions": [
183
+ "Verify the automation/script entity_id exists",
184
+ "Check if traces are available (automation must have run recently)",
185
+ "Ensure Home Assistant connection is working",
186
+ ],
187
+ }
188
+
189
+
190
+ async def _resolve_trace_item_id(
191
+ ws_client: Any, entity_id: str, fallback_object_id: str
192
+ ) -> str:
193
+ """
194
+ Resolve entity_id to the unique_id used for trace storage.
195
+
196
+ Home Assistant stores traces using the automation/script's unique_id,
197
+ not the entity_id. This function looks up the unique_id from the
198
+ entity registry and falls back to object_id if not found.
199
+
200
+ Args:
201
+ ws_client: Connected WebSocket client
202
+ entity_id: Full entity_id (e.g., 'automation.morning_routine')
203
+ fallback_object_id: Object ID to use if unique_id lookup fails
204
+
205
+ Returns:
206
+ The unique_id for trace lookup, or fallback_object_id
207
+ """
208
+ try:
209
+ # Query entity registry to get unique_id
210
+ result = await ws_client.send_command(
211
+ "config/entity_registry/get",
212
+ entity_id=entity_id,
213
+ )
214
+
215
+ if result.get("success") and result.get("result"):
216
+ unique_id = result["result"].get("unique_id")
217
+ if unique_id:
218
+ logger.debug(
219
+ f"Resolved {entity_id} to unique_id: {unique_id}"
220
+ )
221
+ return unique_id
222
+
223
+ # Fallback to object_id if no unique_id found
224
+ logger.debug(
225
+ f"No unique_id found for {entity_id}, using object_id: {fallback_object_id}"
226
+ )
227
+ return fallback_object_id
228
+
229
+ except Exception as e:
230
+ # On any error, fall back to object_id
231
+ logger.warning(
232
+ f"Failed to resolve unique_id for {entity_id}: {e}, "
233
+ f"using object_id: {fallback_object_id}"
234
+ )
235
+ return fallback_object_id
236
+
237
+
238
+ async def _gather_diagnostics(
239
+ ws_client: HomeAssistantWebSocketClient,
240
+ client: Any,
241
+ automation_id: str,
242
+ domain: str,
243
+ ) -> dict[str, Any]:
244
+ """
245
+ Gather diagnostic information when traces are empty.
246
+
247
+ This helps users understand why there are no traces available for
248
+ an automation or script.
249
+
250
+ Args:
251
+ ws_client: Connected WebSocket client
252
+ client: REST API client
253
+ automation_id: Full entity_id (e.g., 'automation.motion_light')
254
+ domain: Either 'automation' or 'script'
255
+
256
+ Returns:
257
+ Dictionary containing diagnostic information:
258
+ - automation_exists: Whether the entity exists
259
+ - automation_enabled: Whether the automation is enabled (on/off state)
260
+ - trace_storage_enabled: Whether trace storage is enabled for this item
261
+ - last_triggered: Last trigger timestamp if available
262
+ - suggestion: Helpful hint based on the diagnostics
263
+ """
264
+ diagnostics: dict[str, Any] = {
265
+ "automation_exists": False,
266
+ "automation_enabled": False,
267
+ "trace_storage_enabled": True, # Default assumption
268
+ "last_triggered": None,
269
+ "suggestion": "",
270
+ }
271
+
272
+ try:
273
+ # Get entity state to check existence and enabled status
274
+ entity_state = await client.get_entity_state(automation_id)
275
+
276
+ if entity_state:
277
+ diagnostics["automation_exists"] = True
278
+
279
+ # Check if enabled (state is 'on' for automations, 'off' is disabled)
280
+ state = entity_state.get("state", "unknown")
281
+ diagnostics["automation_enabled"] = state == "on"
282
+
283
+ # Get last_triggered from attributes
284
+ attributes = entity_state.get("attributes", {})
285
+ last_triggered = attributes.get("last_triggered")
286
+ if last_triggered:
287
+ diagnostics["last_triggered"] = last_triggered
288
+
289
+ # Check if tracing is stored - only for automations
290
+ # (scripts always store traces when enabled)
291
+ if domain == "automation":
292
+ # Try to get automation config to check stored_traces setting
293
+ try:
294
+ unique_id = attributes.get("id")
295
+ if unique_id:
296
+ config_result = await ws_client.send_command(
297
+ "automation/config",
298
+ entity_id=automation_id,
299
+ )
300
+ if config_result.get("success"):
301
+ config = config_result.get("result", {})
302
+ # stored_traces defaults to True if not specified
303
+ stored_traces = config.get("stored_traces")
304
+ if stored_traces is not None and stored_traces <= 0:
305
+ diagnostics["trace_storage_enabled"] = False
306
+ except Exception as e:
307
+ logger.debug(f"Could not get automation config: {e}")
308
+
309
+ # Generate suggestion based on diagnostics
310
+ suggestions = []
311
+
312
+ if not diagnostics["automation_enabled"]:
313
+ suggestions.append(
314
+ f"The {domain} is currently disabled (state: off). "
315
+ "Enable it to start recording traces."
316
+ )
317
+ elif diagnostics["last_triggered"] is None:
318
+ suggestions.append(
319
+ f"The {domain} has never been triggered. "
320
+ "Wait for it to trigger or manually trigger it to generate traces."
321
+ )
322
+ elif not diagnostics["trace_storage_enabled"]:
323
+ suggestions.append(
324
+ "Trace storage is disabled for this automation. "
325
+ "Set 'stored_traces' to a positive number in the automation config."
326
+ )
327
+ else:
328
+ suggestions.append(
329
+ "Traces may have been cleared or expired. "
330
+ "Home Assistant only keeps a limited number of recent traces."
331
+ )
332
+
333
+ diagnostics["suggestion"] = " ".join(suggestions)
334
+
335
+ except Exception as e:
336
+ # Entity doesn't exist or error occurred
337
+ logger.debug(f"Error getting entity state for diagnostics: {e}")
338
+ diagnostics["suggestion"] = (
339
+ f"Could not find {automation_id}. "
340
+ "Verify the entity_id is correct using ha_search_entities()."
341
+ )
342
+
343
+ return diagnostics
344
+
345
+
346
+ def _format_trace_list(
347
+ automation_id: str,
348
+ traces: list[dict[str, Any]],
349
+ limit: int,
350
+ diagnostics: dict[str, Any] | None = None,
351
+ ) -> dict[str, Any]:
352
+ """Format trace list for AI consumption.
353
+
354
+ Args:
355
+ automation_id: The automation or script entity_id
356
+ traces: List of trace data from Home Assistant
357
+ limit: Maximum number of traces to include
358
+ diagnostics: Optional diagnostic information when traces are empty
359
+ """
360
+ formatted_traces = []
361
+
362
+ for trace in traces[:limit]:
363
+ # Extract key information from trace
364
+ trace_info: dict[str, Any] = {
365
+ "run_id": trace.get("run_id"),
366
+ "timestamp": trace.get("timestamp"),
367
+ "state": trace.get("state"),
368
+ }
369
+
370
+ # Extract trigger description if available
371
+ trigger_str = trace.get("trigger")
372
+ if trigger_str:
373
+ trace_info["trigger"] = trigger_str
374
+
375
+ # Check for errors
376
+ error = trace.get("error")
377
+ if error:
378
+ trace_info["error"] = error
379
+
380
+ # Add script-specific execution duration
381
+ if "script_execution" in trace:
382
+ trace_info["execution"] = trace.get("script_execution")
383
+
384
+ formatted_traces.append(trace_info)
385
+
386
+ result: dict[str, Any] = {
387
+ "success": True,
388
+ "automation_id": automation_id,
389
+ "trace_count": len(formatted_traces),
390
+ "total_available": len(traces),
391
+ "traces": formatted_traces,
392
+ "hint": "Use run_id with this tool to get detailed trace information",
393
+ }
394
+
395
+ # Include diagnostics when traces are empty
396
+ if diagnostics is not None and len(traces) == 0:
397
+ result["diagnostics"] = diagnostics
398
+
399
+ return result
400
+
401
+
402
+ def _format_detailed_trace(
403
+ automation_id: str, run_id: str, trace: dict[str, Any]
404
+ ) -> dict[str, Any]:
405
+ """Format detailed trace for AI consumption."""
406
+ result: dict[str, Any] = {
407
+ "success": True,
408
+ "automation_id": automation_id,
409
+ "run_id": run_id,
410
+ "timestamp": trace.get("timestamp"),
411
+ "state": trace.get("state"),
412
+ }
413
+
414
+ # Extract trigger information
415
+ trigger_trace = trace.get("trace", {}).get("trigger", [])
416
+ if trigger_trace:
417
+ trigger_step = trigger_trace[0]
418
+ trigger_vars = trigger_step.get("variables", {}).get("trigger", {})
419
+ result["trigger"] = {
420
+ "platform": trigger_vars.get("platform"),
421
+ "description": trigger_vars.get("description"),
422
+ }
423
+ # Add state change details if present (use safe dictionary access)
424
+ if "to_state" in trigger_vars:
425
+ result["trigger"]["to_state"] = trigger_vars.get("to_state", {}).get("state")
426
+ if "from_state" in trigger_vars:
427
+ result["trigger"]["from_state"] = trigger_vars.get("from_state", {}).get("state")
428
+ if "entity_id" in trigger_vars:
429
+ result["trigger"]["entity_id"] = trigger_vars["entity_id"]
430
+
431
+ # Extract condition results
432
+ condition_trace = trace.get("trace", {}).get("condition", [])
433
+ if condition_trace:
434
+ condition_results = []
435
+ for cond in condition_trace:
436
+ cond_result = {
437
+ "result": cond.get("result", {}).get("result"),
438
+ }
439
+ # Try to get condition type from the path
440
+ if "path" in cond:
441
+ cond_result["path"] = cond["path"]
442
+ condition_results.append(cond_result)
443
+ result["condition_results"] = condition_results
444
+
445
+ # Extract action trace
446
+ action_trace = trace.get("trace", {}).get("action", [])
447
+ if action_trace:
448
+ action_results = []
449
+ for action in action_trace:
450
+ action_info: dict[str, Any] = {
451
+ "path": action.get("path"),
452
+ }
453
+
454
+ # Add timing information
455
+ if "timestamp" in action:
456
+ action_info["timestamp"] = action["timestamp"]
457
+
458
+ # Extract action result
459
+ action_result = action.get("result", {})
460
+ if action_result:
461
+ action_info["result"] = action_result
462
+
463
+ # Check for errors
464
+ if "error" in action:
465
+ action_info["error"] = action["error"]
466
+
467
+ # Extract variables if they contain useful debugging info
468
+ variables = action.get("variables", {})
469
+ if variables and "trigger" not in variables: # Skip trigger vars (already shown)
470
+ # Only include non-empty variable sets
471
+ useful_vars = {k: v for k, v in variables.items() if v is not None}
472
+ if useful_vars:
473
+ action_info["variables"] = useful_vars
474
+
475
+ action_results.append(action_info)
476
+
477
+ result["action_trace"] = action_results
478
+
479
+ # Add context with trigger variables for template debugging
480
+ config = trace.get("config", {})
481
+ if config:
482
+ # Include config summary for context
483
+ result["config_summary"] = {
484
+ "alias": config.get("alias"),
485
+ "mode": config.get("mode", "single"),
486
+ }
487
+
488
+ # Check for overall error
489
+ if trace.get("error"):
490
+ result["error"] = trace["error"]
491
+
492
+ # Add script execution info if present
493
+ if trace.get("script_execution"):
494
+ result["script_execution"] = trace["script_execution"]
495
+
496
+ return result