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,519 @@
1
+ """
2
+ Utility tools for Home Assistant MCP server.
3
+
4
+ This module provides general-purpose utility tools including logbook access,
5
+ template evaluation, and domain documentation retrieval.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from .helpers import log_tool_usage
16
+ from .util_helpers import add_timezone_metadata, coerce_bool_param, coerce_int_param
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def register_utility_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
22
+ """Register Home Assistant utility tools."""
23
+
24
+ # Default and maximum limits for logbook entries
25
+ DEFAULT_LOGBOOK_LIMIT = 50
26
+ MAX_LOGBOOK_LIMIT = 500
27
+
28
+ @mcp.tool(
29
+ annotations={
30
+ "idempotentHint": True,
31
+ "readOnlyHint": True,
32
+ "tags": ["history"],
33
+ "title": "Get Logbook Entries",
34
+ }
35
+ )
36
+ @log_tool_usage
37
+ async def ha_get_logbook(
38
+ hours_back: int | str = 1,
39
+ entity_id: str | None = None,
40
+ end_time: str | None = None,
41
+ limit: int | str | None = None,
42
+ offset: int | str = 0,
43
+ ) -> dict[str, Any]:
44
+ """
45
+ Get Home Assistant logbook entries for the specified time period.
46
+
47
+ Returns paginated logbook entries to prevent excessively large responses.
48
+
49
+ **Parameters:**
50
+ - hours_back: Number of hours to look back (default: 1)
51
+ - entity_id: Optional entity ID to filter entries
52
+ - end_time: Optional end time in ISO format (defaults to now)
53
+ - limit: Maximum number of entries to return (default: 50, max: 500)
54
+ - offset: Number of entries to skip for pagination (default: 0)
55
+
56
+ **Pagination:**
57
+ When the logbook has more entries than the limit, use offset to get
58
+ additional pages. The response includes `has_more` to indicate if
59
+ more entries are available.
60
+
61
+ **IMPORTANT - Pagination Stability:**
62
+ Pagination is performed client-side on the full result set returned
63
+ by Home Assistant. If new logbook entries are created between page
64
+ requests, results may shift and items could be missed or duplicated
65
+ across pages. For best results, use consistent time ranges (start/end)
66
+ and retrieve pages in quick succession.
67
+
68
+ **Example:**
69
+ - First page: ha_get_logbook(hours_back=24, limit=50, offset=0)
70
+ - Second page: ha_get_logbook(hours_back=24, limit=50, offset=50)
71
+ """
72
+
73
+ # Coerce parameters with string handling for AI tools
74
+ try:
75
+ hours_back_int = coerce_int_param(
76
+ hours_back,
77
+ param_name="hours_back",
78
+ default=1,
79
+ min_value=1,
80
+ )
81
+ if hours_back_int is None:
82
+ hours_back_int = 1
83
+ except ValueError as e:
84
+ return {
85
+ "success": False,
86
+ "error": str(e),
87
+ "suggestions": ["Provide hours_back as an integer (e.g., 24)"],
88
+ }
89
+
90
+ try:
91
+ effective_limit = coerce_int_param(
92
+ limit,
93
+ param_name="limit",
94
+ default=DEFAULT_LOGBOOK_LIMIT,
95
+ min_value=1,
96
+ max_value=MAX_LOGBOOK_LIMIT,
97
+ )
98
+ if effective_limit is None:
99
+ effective_limit = DEFAULT_LOGBOOK_LIMIT
100
+ except ValueError as e:
101
+ return {
102
+ "success": False,
103
+ "error": str(e),
104
+ "suggestions": ["Provide limit as an integer (e.g., 50)"],
105
+ }
106
+
107
+ try:
108
+ offset_int = coerce_int_param(
109
+ offset,
110
+ param_name="offset",
111
+ default=0,
112
+ min_value=0,
113
+ )
114
+ if offset_int is None:
115
+ offset_int = 0
116
+ except ValueError as e:
117
+ return {
118
+ "success": False,
119
+ "error": str(e),
120
+ "suggestions": ["Provide offset as an integer (e.g., 0)"],
121
+ }
122
+
123
+ # Calculate start time
124
+ if end_time:
125
+ end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
126
+ else:
127
+ end_dt = datetime.now(UTC)
128
+
129
+ start_dt = end_dt - timedelta(hours=hours_back_int)
130
+ start_timestamp = start_dt.isoformat()
131
+
132
+ try:
133
+ response = await client.get_logbook(
134
+ entity_id=entity_id, start_time=start_timestamp, end_time=end_time
135
+ )
136
+
137
+ if not response:
138
+ no_entries_data = {
139
+ "success": False,
140
+ "error": "No logbook entries found",
141
+ "period": f"{hours_back_int} hours back from {end_dt.isoformat()}",
142
+ "entity_filter": entity_id,
143
+ "total_entries": 0,
144
+ "returned_entries": 0,
145
+ "limit": effective_limit,
146
+ "offset": offset_int,
147
+ "has_more": False,
148
+ }
149
+ return await add_timezone_metadata(client, no_entries_data)
150
+
151
+ # Get total count before pagination
152
+ total_entries = len(response) if isinstance(response, list) else 1
153
+
154
+ # Apply pagination
155
+ if isinstance(response, list):
156
+ paginated_entries = response[offset_int : offset_int + effective_limit]
157
+ has_more = (offset_int + effective_limit) < total_entries
158
+ else:
159
+ paginated_entries = response
160
+ has_more = False
161
+
162
+ logbook_data = {
163
+ "success": True,
164
+ "entries": paginated_entries,
165
+ "period": f"{hours_back_int} hours back from {end_dt.isoformat()}",
166
+ "start_time": start_timestamp,
167
+ "end_time": end_dt.isoformat(),
168
+ "entity_filter": entity_id,
169
+ "total_entries": total_entries,
170
+ "returned_entries": len(paginated_entries)
171
+ if isinstance(paginated_entries, list)
172
+ else 1,
173
+ "limit": effective_limit,
174
+ "offset": offset_int,
175
+ "has_more": has_more,
176
+ }
177
+
178
+ # Add helpful message when results are truncated
179
+ if has_more:
180
+ next_offset = offset_int + effective_limit
181
+ # Build complete parameter string for reproducible pagination
182
+ param_parts = [
183
+ f"hours_back={hours_back_int}",
184
+ f"limit={effective_limit}",
185
+ f"offset={next_offset}",
186
+ ]
187
+ if entity_id:
188
+ param_parts.append(f"entity_id={entity_id}")
189
+ if end_time:
190
+ param_parts.append(f"end_time={end_time}")
191
+
192
+ param_str = ", ".join(param_parts)
193
+ logbook_data["pagination_hint"] = (
194
+ f"Showing entries {offset_int + 1}-{offset_int + len(paginated_entries)} of {total_entries}. "
195
+ f"To get the next page, use: ha_get_logbook({param_str})"
196
+ )
197
+
198
+ return await add_timezone_metadata(client, logbook_data)
199
+
200
+ except Exception as e:
201
+ error_str = str(e)
202
+ suggestions = []
203
+
204
+ # Detect 500 errors (server crash from heavy query)
205
+ if "500" in error_str:
206
+ suggestions = [
207
+ "The query returned too many results causing a server error (500).",
208
+ "This often happens with very active entities or long time periods.",
209
+ "Try reducing 'hours_back' parameter (e.g., from 24 to 1 hour)",
210
+ "Add a specific 'entity_id' filter to narrow down results",
211
+ "If debugging an automation, filter by that automation's entity_id",
212
+ "Use ha_bug_report tool to check Home Assistant logs for crash details",
213
+ ]
214
+
215
+ error_data = {
216
+ "success": False,
217
+ "error": f"Failed to retrieve logbook: {error_str}",
218
+ "period": f"{hours_back_int} hours back from {end_dt.isoformat()}",
219
+ "suggestions": suggestions if suggestions else None,
220
+ }
221
+ return await add_timezone_metadata(client, error_data)
222
+
223
+ @mcp.tool(
224
+ annotations={
225
+ "idempotentHint": True,
226
+ "readOnlyHint": True,
227
+ "tags": ["docs"],
228
+ "title": "Evaluate Template",
229
+ }
230
+ )
231
+ @log_tool_usage
232
+ async def ha_eval_template(
233
+ template: str, timeout: int = 3, report_errors: bool | str = True
234
+ ) -> dict[str, Any]:
235
+ """
236
+ Evaluate Jinja2 templates using Home Assistant's template engine.
237
+
238
+ This tool allows testing and debugging of Jinja2 template expressions that are commonly used in
239
+ Home Assistant automations, scripts, and configurations. It provides real-time evaluation with
240
+ access to all Home Assistant states, functions, and template variables.
241
+
242
+ **Parameters:**
243
+ - template: The Jinja2 template string to evaluate
244
+ - timeout: Maximum evaluation time in seconds (default: 3)
245
+ - report_errors: Whether to return detailed error information (default: True)
246
+
247
+ **Common Template Functions:**
248
+
249
+ **State Access:**
250
+ ```jinja2
251
+ {{ states('sensor.temperature') }} # Get entity state value
252
+ {{ states.sensor.temperature.state }} # Alternative syntax
253
+ {{ state_attr('light.bedroom', 'brightness') }} # Get entity attribute
254
+ {{ is_state('light.living_room', 'on') }} # Check if entity has specific state
255
+ ```
256
+
257
+ **Numeric Operations:**
258
+ ```jinja2
259
+ {{ states('sensor.temperature') | float(0) }} # Convert to float with default
260
+ {{ states('sensor.humidity') | int }} # Convert to integer
261
+ {{ (states('sensor.temp') | float + 5) | round(1) }} # Math operations
262
+ ```
263
+
264
+ **Time and Date:**
265
+ ```jinja2
266
+ {{ now() }} # Current datetime
267
+ {{ now().strftime('%H:%M:%S') }} # Format current time
268
+ {{ as_timestamp(now()) }} # Convert to Unix timestamp
269
+ {{ now().hour }} # Current hour (0-23)
270
+ {{ now().weekday() }} # Day of week (0=Monday)
271
+ ```
272
+
273
+ **Conditional Logic:**
274
+ ```jinja2
275
+ {{ 'Day' if now().hour < 18 else 'Night' }} # Ternary operator
276
+ {% if is_state('sun.sun', 'above_horizon') %}
277
+ It's daytime
278
+ {% else %}
279
+ It's nighttime
280
+ {% endif %}
281
+ ```
282
+
283
+ **Lists and Loops:**
284
+ ```jinja2
285
+ {% for entity in states.light %}
286
+ {{ entity.entity_id }}: {{ entity.state }}
287
+ {% endfor %}
288
+
289
+ {{ states.light | selectattr('state', 'eq', 'on') | list | count }} # Count on lights
290
+ ```
291
+
292
+ **String Operations:**
293
+ ```jinja2
294
+ {{ states('sensor.weather') | title }} # Title case
295
+ {{ 'Hello ' + states('input_text.name') }} # String concatenation
296
+ {{ states('sensor.data') | regex_replace('pattern', 'replacement') }}
297
+ ```
298
+
299
+ **Device and Area Functions:**
300
+ ```jinja2
301
+ {{ device_entities('device_id_here') }} # Get entities for device
302
+ {{ area_entities('living_room') }} # Get entities in area
303
+ {{ device_id('light.bedroom') }} # Get device ID for entity
304
+ ```
305
+
306
+ **Common Use Cases:**
307
+
308
+ **Automation Conditions:**
309
+ ```jinja2
310
+ # Check if it's a workday and after 7 AM
311
+ {{ is_state('binary_sensor.workday', 'on') and now().hour >= 7 }}
312
+
313
+ # Temperature-based condition
314
+ {{ states('sensor.outdoor_temp') | float < 0 }}
315
+ ```
316
+
317
+ **Dynamic Service Data:**
318
+ ```jinja2
319
+ # Dynamic brightness based on time
320
+ {{ 255 if now().hour < 22 else 50 }}
321
+
322
+ # Message with current values
323
+ "Temperature is {{ states('sensor.temp') }}°C, humidity {{ states('sensor.humidity') }}%"
324
+ ```
325
+
326
+ **Examples:**
327
+
328
+ **Test basic state access:**
329
+ ```python
330
+ ha_eval_template("{{ states('light.living_room') }}")
331
+ ```
332
+
333
+ **Test conditional logic:**
334
+ ```python
335
+ ha_eval_template("{{ 'Day' if now().hour < 18 else 'Night' }}")
336
+ ```
337
+
338
+ **Test mathematical operations:**
339
+ ```python
340
+ ha_eval_template("{{ (states('sensor.temperature') | float + 5) | round(1) }}")
341
+ ```
342
+
343
+ **Test complex automation condition:**
344
+ ```python
345
+ ha_eval_template("{{ is_state('binary_sensor.workday', 'on') and now().hour >= 7 and states('sensor.temperature') | float > 20 }}")
346
+ ```
347
+
348
+ **Test entity counting:**
349
+ ```python
350
+ ha_eval_template("{{ states.light | selectattr('state', 'eq', 'on') | list | count }}")
351
+ ```
352
+
353
+ **IMPORTANT NOTES:**
354
+ - Templates have access to all current Home Assistant states and attributes
355
+ - Use this tool to test templates before using them in automations or scripts
356
+ - Template evaluation respects Home Assistant's security model and timeouts
357
+ - Complex templates may affect Home Assistant performance - keep them efficient
358
+ - Use default values (e.g., `| float(0)`) to handle missing or invalid states
359
+
360
+ **For template documentation:** https://www.home-assistant.io/docs/configuration/templating/
361
+ """
362
+ # Coerce boolean parameter that may come as string from XML-style calls
363
+ report_errors_bool = coerce_bool_param(
364
+ report_errors, "report_errors", default=True
365
+ )
366
+ assert report_errors_bool is not None # default=True guarantees non-None
367
+
368
+ try:
369
+ # Generate unique ID for the template evaluation request
370
+ import time
371
+
372
+ request_id = int(time.time() * 1000) % 1000000 # Simple unique ID
373
+
374
+ # Construct WebSocket message following the protocol
375
+ message: dict[str, Any] = {
376
+ "type": "render_template",
377
+ "template": template,
378
+ "timeout": timeout,
379
+ "report_errors": report_errors_bool,
380
+ "id": request_id,
381
+ }
382
+
383
+ # Send WebSocket message and get response
384
+ result = await client.send_websocket_message(message)
385
+
386
+ if result.get("success"):
387
+ # Check if we have an event-type response with the actual result
388
+ if "event" in result and "result" in result["event"]:
389
+ template_result = result["event"]["result"]
390
+ listeners = result["event"].get("listeners", {})
391
+
392
+ return {
393
+ "success": True,
394
+ "template": template,
395
+ "result": template_result,
396
+ "listeners": listeners,
397
+ "request_id": request_id,
398
+ "evaluation_time": timeout,
399
+ }
400
+ else:
401
+ # Handle direct result response
402
+ return {
403
+ "success": True,
404
+ "template": template,
405
+ "result": result.get("result"),
406
+ "request_id": request_id,
407
+ "evaluation_time": timeout,
408
+ }
409
+ else:
410
+ error_info = result.get("error", "Unknown error occurred")
411
+ return {
412
+ "success": False,
413
+ "template": template,
414
+ "error": error_info,
415
+ "request_id": request_id,
416
+ "suggestions": [
417
+ "Check template syntax - ensure proper Jinja2 formatting",
418
+ "Verify entity_ids exist using ha_get_state()",
419
+ "Use default values: {{ states('sensor.temp') | float(0) }}",
420
+ "Check for typos in function names and entity references",
421
+ "Test simpler templates first to isolate issues",
422
+ ],
423
+ }
424
+
425
+ except Exception as e:
426
+ error_str = str(e)
427
+ suggestions = [
428
+ "Check Home Assistant WebSocket connection",
429
+ "Verify template syntax is valid Jinja2",
430
+ "Try a simpler template to test basic functionality",
431
+ "Check if referenced entities exist",
432
+ "Ensure template doesn't exceed timeout limit",
433
+ ]
434
+
435
+ # Add specific suggestions for 403 errors
436
+ if "403" in error_str and "Forbidden" in error_str:
437
+ suggestions = [
438
+ "The request was blocked (403 Forbidden) - this may be caused by:",
439
+ " • Reverse proxy security rules (Apache, Nginx, Traefik)",
440
+ " • Rate limiting from multiple simultaneous requests",
441
+ " • Complex template triggering security filters",
442
+ "Try simplifying the template (remove newlines, reduce complexity)",
443
+ "Break complex templates into multiple simpler calls",
444
+ "Use ha_bug_report tool to check Home Assistant logs for details",
445
+ ] + suggestions
446
+
447
+ return {
448
+ "success": False,
449
+ "template": template,
450
+ "error": f"Template evaluation failed: {error_str}",
451
+ "suggestions": suggestions,
452
+ }
453
+
454
+ @mcp.tool(annotations={"readOnlyHint": True, "title": "Get Domain Docs"})
455
+ async def ha_get_domain_docs(domain: str) -> dict[str, Any]:
456
+ """Get comprehensive documentation for Home Assistant entity domains."""
457
+ domain = domain.lower().strip()
458
+
459
+ # GitHub URL for Home Assistant integration documentation
460
+ github_url = f"https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/{domain}.markdown"
461
+
462
+ try:
463
+ # Fetch documentation from GitHub
464
+ async with httpx.AsyncClient(timeout=30.0) as client_http:
465
+ response = await client_http.get(github_url)
466
+
467
+ if response.status_code == 200:
468
+ # Successfully fetched documentation
469
+ doc_content = response.text
470
+
471
+ # Extract title from the first line if available
472
+ lines = doc_content.split("\n")
473
+ title = lines[0] if lines else f"{domain.title()} Integration"
474
+
475
+ return {
476
+ "domain": domain,
477
+ "source": "Home Assistant Official Documentation",
478
+ "url": github_url,
479
+ "documentation": doc_content,
480
+ "title": title.strip("# "),
481
+ "fetched_at": asyncio.get_event_loop().time(),
482
+ "status": "success",
483
+ }
484
+
485
+ elif response.status_code == 404:
486
+ # Domain documentation not found
487
+ return {
488
+ "error": f"No official documentation found for domain '{domain}'",
489
+ "domain": domain,
490
+ "status": "not_found",
491
+ "suggestion": "Check if the domain name is correct. Common domains include: light, climate, switch, lock, sensor, automation, media_player, cover, fan, binary_sensor, camera, alarm_control_panel, etc.",
492
+ "github_url": github_url,
493
+ }
494
+
495
+ else:
496
+ # Other HTTP errors
497
+ return {
498
+ "error": f"Failed to fetch documentation for '{domain}' (HTTP {response.status_code})",
499
+ "domain": domain,
500
+ "status": "fetch_error",
501
+ "github_url": github_url,
502
+ "suggestion": "Try again later or check the domain name",
503
+ }
504
+
505
+ except httpx.TimeoutException:
506
+ return {
507
+ "error": f"Timeout while fetching documentation for '{domain}'",
508
+ "domain": domain,
509
+ "status": "timeout",
510
+ "suggestion": "Try again later - GitHub may be temporarily unavailable",
511
+ }
512
+
513
+ except Exception as e:
514
+ return {
515
+ "error": f"Unexpected error fetching documentation for '{domain}': {str(e)}",
516
+ "domain": domain,
517
+ "status": "error",
518
+ "suggestion": "Check your internet connection and try again",
519
+ }