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,714 @@
1
+ """
2
+ Historical data access tools for Home Assistant MCP server.
3
+
4
+ This module provides tools for accessing historical data from Home Assistant's
5
+ recorder component. It includes:
6
+
7
+ 1. ha_get_history - Retrieve raw state change history (short-term, full resolution)
8
+ 2. ha_get_statistics - Retrieve pre-aggregated long-term statistics for trend analysis
9
+
10
+ These tools serve different but complementary purposes:
11
+ - History: Raw state changes, ~10 days retention, full resolution
12
+ - Statistics: Pre-aggregated data, permanent retention, hourly minimum resolution
13
+ """
14
+
15
+ import logging
16
+ import re
17
+ from datetime import UTC, datetime, timedelta
18
+ from typing import Annotated, Any
19
+
20
+ from pydantic import Field
21
+
22
+ from .helpers import get_connected_ws_client, log_tool_usage
23
+ from .util_helpers import add_timezone_metadata, coerce_int_param, parse_string_list_param
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _convert_timestamp(value: Any) -> str | None:
29
+ """Convert a timestamp value to ISO format string.
30
+
31
+ Handles both Unix epoch floats (from WebSocket short-form responses)
32
+ and string timestamps (from long-form responses).
33
+
34
+ Args:
35
+ value: Timestamp as Unix epoch float, ISO string, or None
36
+
37
+ Returns:
38
+ ISO format string or None if value is None/invalid
39
+ """
40
+ if value is None:
41
+ return None
42
+ if isinstance(value, (int, float)):
43
+ return datetime.fromtimestamp(value, tz=UTC).isoformat()
44
+ if isinstance(value, str):
45
+ return value
46
+ return None
47
+
48
+
49
+ def parse_relative_time(time_str: str | None, default_hours: int = 24) -> datetime:
50
+ """
51
+ Parse a time string that can be either ISO format or relative (e.g., '24h', '7d').
52
+
53
+ Args:
54
+ time_str: Time string in ISO format or relative format (e.g., "24h", "7d", "2w", "1m" where 1m = 30 days)
55
+ default_hours: Default hours to go back if time_str is None
56
+
57
+ Returns:
58
+ datetime object in UTC
59
+ """
60
+ if time_str is None:
61
+ return datetime.now(UTC) - timedelta(hours=default_hours)
62
+
63
+ # Check for relative time format
64
+ relative_pattern = r"^(\d+)([hdwm])$"
65
+ match = re.match(relative_pattern, time_str.lower().strip())
66
+
67
+ if match:
68
+ value = int(match.group(1))
69
+ unit = match.group(2)
70
+
71
+ if unit == "h":
72
+ return datetime.now(UTC) - timedelta(hours=value)
73
+ elif unit == "d":
74
+ return datetime.now(UTC) - timedelta(days=value)
75
+ elif unit == "w":
76
+ return datetime.now(UTC) - timedelta(weeks=value)
77
+ elif unit == "m":
78
+ # Approximate month as 30 days
79
+ return datetime.now(UTC) - timedelta(days=value * 30)
80
+
81
+ # Try parsing as ISO format
82
+ try:
83
+ # Handle various ISO formats
84
+ if time_str.endswith("Z"):
85
+ time_str = time_str[:-1] + "+00:00"
86
+ dt = datetime.fromisoformat(time_str)
87
+ # Ensure timezone awareness
88
+ if dt.tzinfo is None:
89
+ dt = dt.replace(tzinfo=UTC)
90
+ return dt
91
+ except ValueError as e:
92
+ raise ValueError(
93
+ f"Invalid time format: {time_str}. Use ISO format or relative (e.g., '24h', '7d', '2w', '1m')"
94
+ ) from e
95
+
96
+
97
+ def register_history_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
98
+ """Register historical data access tools with the MCP server."""
99
+
100
+ # Default and maximum limits for history entries
101
+ DEFAULT_HISTORY_LIMIT = 100
102
+ MAX_HISTORY_LIMIT = 1000
103
+
104
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["history"], "title": "Get Entity History"})
105
+ @log_tool_usage
106
+ async def ha_get_history(
107
+ entity_ids: Annotated[
108
+ str | list[str],
109
+ Field(
110
+ description="Entity ID(s) to query. Can be a single ID, comma-separated string, or JSON array."
111
+ ),
112
+ ],
113
+ start_time: Annotated[
114
+ str | None,
115
+ Field(
116
+ description="Start time: ISO datetime or relative (e.g., '24h', '7d', '2w'). Default: 24h ago",
117
+ default=None,
118
+ ),
119
+ ] = None,
120
+ end_time: Annotated[
121
+ str | None,
122
+ Field(
123
+ description="End time: ISO datetime. Default: now",
124
+ default=None,
125
+ ),
126
+ ] = None,
127
+ minimal_response: Annotated[
128
+ bool,
129
+ Field(
130
+ description="Return only states/timestamps without attributes. Default: true",
131
+ default=True,
132
+ ),
133
+ ] = True,
134
+ significant_changes_only: Annotated[
135
+ bool,
136
+ Field(
137
+ description="Filter to significant state changes only. Default: true",
138
+ default=True,
139
+ ),
140
+ ] = True,
141
+ limit: Annotated[
142
+ int | str | None,
143
+ Field(
144
+ description="Max state changes per entity. Default: 100, Max: 1000",
145
+ default=None,
146
+ ),
147
+ ] = None,
148
+ ) -> dict[str, Any]:
149
+ """
150
+ Retrieve raw state change history for entities (last ~10 days).
151
+
152
+ Returns the full-resolution state history from Home Assistant's recorder.
153
+ This data shows every individual state change for the specified entities.
154
+
155
+ **Data Characteristics:**
156
+ - Full resolution: Every state transition captured
157
+ - Retention: ~10 days (configurable via recorder.purge_keep_days)
158
+ - Best for: Troubleshooting, pattern analysis, specific event queries
159
+
160
+ **Parameters:**
161
+ - entity_ids: Entity ID(s) to query (required)
162
+ - start_time: Start of period - ISO datetime or relative ('24h', '7d', '2w'). Default: 24h ago
163
+ - end_time: End of period - ISO datetime. Default: now
164
+ - minimal_response: Omit attributes for smaller response. Default: true
165
+ - significant_changes_only: Filter to actual state changes. Default: true
166
+ - limit: Max entries per entity. Default: 100, Max: 1000
167
+
168
+ **Use Cases:**
169
+ - "Why was my bedroom cold last night?" - Query temperature sensor history
170
+ - "Did my garage door open while I was away?" - Check cover state changes
171
+ - "What time does motion usually trigger?" - Analyze binary sensor patterns
172
+ - "Debug automation triggers" - See exact state change sequence
173
+
174
+ **Example:**
175
+ ```python
176
+ # Get temperature history for the last 24 hours
177
+ ha_get_history(entity_ids="sensor.bedroom_temperature")
178
+
179
+ # Get multiple entity history for last 7 days
180
+ ha_get_history(
181
+ entity_ids=["sensor.temperature", "sensor.humidity"],
182
+ start_time="7d",
183
+ limit=500
184
+ )
185
+
186
+ # Get full attributes for debugging
187
+ ha_get_history(
188
+ entity_ids="light.living_room",
189
+ start_time="2025-01-25T00:00:00Z",
190
+ end_time="2025-01-26T00:00:00Z",
191
+ minimal_response=False
192
+ )
193
+ ```
194
+
195
+ **Note:** For long-term trends (>10 days), use ha_get_statistics() instead.
196
+
197
+ **Returns:**
198
+ - List of entities with their state history
199
+ - Each entity includes: entity_id, period, states array, count
200
+ """
201
+ try:
202
+ # Parse entity_ids - handle string, list, or comma-separated
203
+ if isinstance(entity_ids, str):
204
+ if entity_ids.startswith("["):
205
+ # JSON array string
206
+ parsed_ids = parse_string_list_param(entity_ids, "entity_ids")
207
+ if parsed_ids is None:
208
+ return {
209
+ "success": False,
210
+ "error": "entity_ids is required",
211
+ "suggestions": ["Provide at least one entity ID"],
212
+ }
213
+ entity_id_list = parsed_ids
214
+ elif "," in entity_ids:
215
+ # Comma-separated string
216
+ entity_id_list = [e.strip() for e in entity_ids.split(",") if e.strip()]
217
+ else:
218
+ # Single entity ID
219
+ entity_id_list = [entity_ids.strip()]
220
+ else:
221
+ entity_id_list = entity_ids
222
+
223
+ if not entity_id_list:
224
+ return {
225
+ "success": False,
226
+ "error": "entity_ids is required",
227
+ "suggestions": ["Provide at least one entity ID"],
228
+ }
229
+
230
+ # Parse time parameters
231
+ try:
232
+ start_dt = parse_relative_time(start_time, default_hours=24)
233
+ except ValueError as e:
234
+ return {
235
+ "success": False,
236
+ "error": str(e),
237
+ "suggestions": [
238
+ "Use ISO format: '2025-01-25T00:00:00Z'",
239
+ "Use relative format: '24h', '7d', '2w', '1m'",
240
+ ],
241
+ }
242
+
243
+ if end_time:
244
+ try:
245
+ end_dt = parse_relative_time(end_time, default_hours=0)
246
+ except ValueError as e:
247
+ return {
248
+ "success": False,
249
+ "error": str(e),
250
+ "suggestions": ["Use ISO format: '2025-01-26T00:00:00Z'"],
251
+ }
252
+ else:
253
+ end_dt = datetime.now(UTC)
254
+
255
+ # Apply limit constraints with string coercion for AI tools
256
+ try:
257
+ effective_limit = coerce_int_param(
258
+ limit,
259
+ param_name="limit",
260
+ default=DEFAULT_HISTORY_LIMIT,
261
+ min_value=1,
262
+ max_value=MAX_HISTORY_LIMIT,
263
+ )
264
+ if effective_limit is None:
265
+ effective_limit = DEFAULT_HISTORY_LIMIT
266
+ except ValueError as e:
267
+ return {
268
+ "success": False,
269
+ "error": str(e),
270
+ "suggestions": ["Provide limit as an integer (e.g., 100)"],
271
+ }
272
+
273
+ # Connect to WebSocket
274
+ ws_client, error = await get_connected_ws_client(
275
+ client.base_url, client.token
276
+ )
277
+ if error or ws_client is None:
278
+ return error or {
279
+ "success": False,
280
+ "error": "Failed to establish WebSocket connection",
281
+ }
282
+
283
+ try:
284
+ # Build WebSocket command for history
285
+ # WebSocket command: history/history_during_period
286
+ command_params = {
287
+ "start_time": start_dt.isoformat(),
288
+ "end_time": end_dt.isoformat(),
289
+ "entity_ids": entity_id_list,
290
+ "minimal_response": minimal_response,
291
+ "significant_changes_only": significant_changes_only,
292
+ "no_attributes": minimal_response,
293
+ }
294
+
295
+ response = await ws_client.send_command(
296
+ "history/history_during_period", **command_params
297
+ )
298
+
299
+ if not response.get("success"):
300
+ error_msg = response.get("error", "Unknown error")
301
+ return await add_timezone_metadata(
302
+ client,
303
+ {
304
+ "success": False,
305
+ "error": f"Failed to retrieve history: {error_msg}",
306
+ "entity_ids": entity_id_list,
307
+ "suggestions": [
308
+ "Verify entity IDs exist using ha_search_entities()",
309
+ "Check that entities are recorded (not excluded from recorder)",
310
+ "Ensure time range is within recorder retention period (~10 days)",
311
+ ],
312
+ },
313
+ )
314
+
315
+ # Process results
316
+ result_data = response.get("result", {})
317
+ entities_history = []
318
+
319
+ for entity_id in entity_id_list:
320
+ entity_states = result_data.get(entity_id, [])
321
+
322
+ # Apply limit per entity
323
+ limited_states = entity_states[:effective_limit]
324
+
325
+ # Format states for output
326
+ formatted_states = []
327
+ for state in limited_states:
328
+ # Get timestamps - WebSocket returns short-form (lc/lu) as Unix epoch floats
329
+ # or long-form (last_changed/last_updated) as strings
330
+ last_changed_raw = state.get("lc", state.get("last_changed"))
331
+ last_updated_raw = state.get("lu", state.get("last_updated"))
332
+
333
+ state_entry = {
334
+ "state": state.get("s", state.get("state")),
335
+ "last_changed": _convert_timestamp(last_changed_raw),
336
+ "last_updated": _convert_timestamp(last_updated_raw),
337
+ }
338
+ if not minimal_response:
339
+ state_entry["attributes"] = state.get(
340
+ "a", state.get("attributes", {})
341
+ )
342
+ formatted_states.append(state_entry)
343
+
344
+ entities_history.append(
345
+ {
346
+ "entity_id": entity_id,
347
+ "period": {
348
+ "start": start_dt.isoformat(),
349
+ "end": end_dt.isoformat(),
350
+ },
351
+ "states": formatted_states,
352
+ "count": len(formatted_states),
353
+ "total_available": len(entity_states),
354
+ "truncated": len(entity_states) > effective_limit,
355
+ }
356
+ )
357
+
358
+ history_data = {
359
+ "success": True,
360
+ "entities": entities_history,
361
+ "period": {
362
+ "start": start_dt.isoformat(),
363
+ "end": end_dt.isoformat(),
364
+ },
365
+ "query_params": {
366
+ "minimal_response": minimal_response,
367
+ "significant_changes_only": significant_changes_only,
368
+ "limit": effective_limit,
369
+ },
370
+ }
371
+
372
+ return await add_timezone_metadata(client, history_data)
373
+
374
+ finally:
375
+ if ws_client:
376
+ await ws_client.disconnect()
377
+
378
+ except Exception as e:
379
+ logger.error(f"Failed to get history: {e}")
380
+ error_data = {
381
+ "success": False,
382
+ "error": f"Failed to retrieve history: {str(e)}",
383
+ "suggestions": [
384
+ "Check Home Assistant connection",
385
+ "Verify entity IDs are correct",
386
+ "Ensure recorder component is enabled",
387
+ ],
388
+ }
389
+ return await add_timezone_metadata(client, error_data)
390
+
391
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["history"], "title": "Get Statistics"})
392
+ @log_tool_usage
393
+ async def ha_get_statistics(
394
+ entity_ids: Annotated[
395
+ str | list[str],
396
+ Field(
397
+ description="Entity ID(s) to query. Must have state_class attribute. Can be single ID, comma-separated, or JSON array."
398
+ ),
399
+ ],
400
+ start_time: Annotated[
401
+ str | None,
402
+ Field(
403
+ description="Start time: ISO datetime or relative (e.g., '30d', '6m', '12m'). Default: 30d ago",
404
+ default=None,
405
+ ),
406
+ ] = None,
407
+ end_time: Annotated[
408
+ str | None,
409
+ Field(
410
+ description="End time: ISO datetime. Default: now",
411
+ default=None,
412
+ ),
413
+ ] = None,
414
+ period: Annotated[
415
+ str,
416
+ Field(
417
+ description="Aggregation period: '5minute', 'hour', 'day', 'week', 'month'. Default: 'day'",
418
+ default="day",
419
+ ),
420
+ ] = "day",
421
+ statistic_types: Annotated[
422
+ str | list[str] | None,
423
+ Field(
424
+ description="Statistics types: 'mean', 'min', 'max', 'sum', 'state', 'change'. Default: all",
425
+ default=None,
426
+ ),
427
+ ] = None,
428
+ ) -> dict[str, Any]:
429
+ """
430
+ Retrieve pre-aggregated long-term statistics for trend analysis.
431
+
432
+ Returns aggregated statistical data from Home Assistant's long-term statistics
433
+ table. This data is pre-computed and stored permanently, allowing analysis
434
+ of historical trends beyond the standard ~10 day recorder retention.
435
+
436
+ **Data Characteristics:**
437
+ - Pre-aggregated: Hourly/daily/monthly statistics
438
+ - Retention: Permanent - never purged
439
+ - Entities: Only those with state_class (measurement, total, total_increasing)
440
+ - Best for: Long-term trends, energy consumption, period comparisons
441
+
442
+ **Parameters:**
443
+ - entity_ids: Entity ID(s) with state_class attribute (required)
444
+ - start_time: Start of period - ISO datetime or relative ('30d', '6m', '12m'). Default: 30d ago
445
+ - end_time: End of period - ISO datetime. Default: now
446
+ - period: Aggregation: '5minute', 'hour', 'day', 'week', 'month'. Default: 'day'
447
+ - statistic_types: Types to include: 'mean', 'min', 'max', 'sum', 'state', 'change'. Default: all
448
+
449
+ **Statistic Types Explained:**
450
+ - mean: Average value over the period
451
+ - min: Minimum value during the period
452
+ - max: Maximum value during the period
453
+ - sum: Running total (for total_increasing entities like energy)
454
+ - state: Last known state value
455
+ - change: Change from previous period
456
+
457
+ **Use Cases:**
458
+ - "How much electricity did I use this month vs last month?" - Monthly sum
459
+ - "What's my average living room temperature?" - Daily/monthly mean
460
+ - "Show daily energy consumption for the past 2 weeks" - Daily sum
461
+ - "Has my solar production declined year over year?" - Monthly comparison
462
+
463
+ **Example:**
464
+ ```python
465
+ # Get daily energy statistics for last 30 days
466
+ ha_get_statistics(entity_ids="sensor.total_energy_kwh")
467
+
468
+ # Get monthly temperature averages for 6 months
469
+ ha_get_statistics(
470
+ entity_ids="sensor.living_room_temperature",
471
+ start_time="6m",
472
+ period="month",
473
+ statistic_types=["mean", "min", "max"]
474
+ )
475
+
476
+ # Compare multiple sensors
477
+ ha_get_statistics(
478
+ entity_ids=["sensor.solar_production", "sensor.grid_consumption"],
479
+ start_time="12m",
480
+ period="month",
481
+ statistic_types=["sum"]
482
+ )
483
+ ```
484
+
485
+ **Note:** Only entities with state_class attribute support statistics.
486
+ Use ha_search_entities() to find entities and check their state_class.
487
+
488
+ **Returns:**
489
+ - List of entities with their statistics
490
+ - Each includes: entity_id, period type, statistics array, unit_of_measurement
491
+ """
492
+ try:
493
+ # Parse entity_ids
494
+ if isinstance(entity_ids, str):
495
+ if entity_ids.startswith("["):
496
+ parsed_ids = parse_string_list_param(entity_ids, "entity_ids")
497
+ if parsed_ids is None:
498
+ return {
499
+ "success": False,
500
+ "error": "entity_ids is required",
501
+ "suggestions": [
502
+ "Provide at least one entity ID with state_class attribute"
503
+ ],
504
+ }
505
+ entity_id_list = parsed_ids
506
+ elif "," in entity_ids:
507
+ entity_id_list = [e.strip() for e in entity_ids.split(",") if e.strip()]
508
+ else:
509
+ entity_id_list = [entity_ids.strip()]
510
+ else:
511
+ entity_id_list = entity_ids
512
+
513
+ if not entity_id_list:
514
+ return {
515
+ "success": False,
516
+ "error": "entity_ids is required",
517
+ "suggestions": [
518
+ "Provide at least one entity ID with state_class attribute"
519
+ ],
520
+ }
521
+
522
+ # Parse time parameters (default 30 days for statistics)
523
+ try:
524
+ start_dt = parse_relative_time(start_time, default_hours=30 * 24)
525
+ except ValueError as e:
526
+ return {
527
+ "success": False,
528
+ "error": str(e),
529
+ "suggestions": [
530
+ "Use ISO format: '2025-01-01T00:00:00Z'",
531
+ "Use relative format: '30d', '6m', '12m'",
532
+ ],
533
+ }
534
+
535
+ if end_time:
536
+ try:
537
+ end_dt = parse_relative_time(end_time, default_hours=0)
538
+ except ValueError as e:
539
+ return {
540
+ "success": False,
541
+ "error": str(e),
542
+ "suggestions": ["Use ISO format: '2025-01-31T23:59:59Z'"],
543
+ }
544
+ else:
545
+ end_dt = datetime.now(UTC)
546
+
547
+ # Validate period
548
+ valid_periods = ["5minute", "hour", "day", "week", "month"]
549
+ if period not in valid_periods:
550
+ return {
551
+ "success": False,
552
+ "error": f"Invalid period: {period}",
553
+ "valid_periods": valid_periods,
554
+ "suggestions": [f"Use one of: {', '.join(valid_periods)}"],
555
+ }
556
+
557
+ # Parse statistic_types
558
+ stat_types_list = None
559
+ if statistic_types:
560
+ if isinstance(statistic_types, str):
561
+ if statistic_types.startswith("["):
562
+ stat_types_list = parse_string_list_param(
563
+ statistic_types, "statistic_types"
564
+ )
565
+ elif "," in statistic_types:
566
+ stat_types_list = [
567
+ s.strip() for s in statistic_types.split(",") if s.strip()
568
+ ]
569
+ else:
570
+ stat_types_list = [statistic_types.strip()]
571
+ else:
572
+ stat_types_list = statistic_types
573
+
574
+ # Validate statistic types
575
+ valid_types = ["mean", "min", "max", "sum", "state", "change"]
576
+ if stat_types_list is None:
577
+ stat_types_list = []
578
+ invalid_types = [t for t in stat_types_list if t not in valid_types]
579
+ if invalid_types:
580
+ return {
581
+ "success": False,
582
+ "error": f"Invalid statistic types: {invalid_types}",
583
+ "valid_types": valid_types,
584
+ "suggestions": [f"Use one or more of: {', '.join(valid_types)}"],
585
+ }
586
+
587
+ # Connect to WebSocket
588
+ ws_client, error = await get_connected_ws_client(
589
+ client.base_url, client.token
590
+ )
591
+ if error or ws_client is None:
592
+ return error or {
593
+ "success": False,
594
+ "error": "Failed to establish WebSocket connection",
595
+ }
596
+
597
+ try:
598
+ # Build WebSocket command for statistics
599
+ # WebSocket command: recorder/statistics_during_period
600
+ command_params: dict[str, Any] = {
601
+ "start_time": start_dt.isoformat(),
602
+ "end_time": end_dt.isoformat(),
603
+ "statistic_ids": entity_id_list,
604
+ "period": period,
605
+ }
606
+
607
+ if stat_types_list is not None:
608
+ command_params["types"] = stat_types_list
609
+
610
+ response = await ws_client.send_command(
611
+ "recorder/statistics_during_period", **command_params
612
+ )
613
+
614
+ if not response.get("success"):
615
+ error_msg = response.get("error", "Unknown error")
616
+ return await add_timezone_metadata(
617
+ client,
618
+ {
619
+ "success": False,
620
+ "error": f"Failed to retrieve statistics: {error_msg}",
621
+ "entity_ids": entity_id_list,
622
+ "suggestions": [
623
+ "Verify entities have state_class attribute (measurement, total, total_increasing)",
624
+ "Use ha_search_entities() to check entity attributes",
625
+ "Statistics are only available for entities that track numeric values",
626
+ ],
627
+ },
628
+ )
629
+
630
+ # Process results
631
+ result_data = response.get("result", {})
632
+ entities_statistics = []
633
+
634
+ for entity_id in entity_id_list:
635
+ entity_stats = result_data.get(entity_id, [])
636
+
637
+ # Format statistics for output
638
+ formatted_stats = []
639
+ unit = None
640
+
641
+ for stat in entity_stats:
642
+ stat_entry: dict[str, Any] = {
643
+ "start": stat.get("start"),
644
+ }
645
+
646
+ # Include requested statistic types (or all if not specified)
647
+ for stat_type in stat_types_list or [
648
+ "mean",
649
+ "min",
650
+ "max",
651
+ "sum",
652
+ "state",
653
+ "change",
654
+ ]:
655
+ if stat_type in stat:
656
+ stat_entry[stat_type] = stat[stat_type]
657
+
658
+ # Capture unit from first stat
659
+ if unit is None and "unit_of_measurement" in stat:
660
+ unit = stat["unit_of_measurement"]
661
+
662
+ formatted_stats.append(stat_entry)
663
+
664
+ entities_statistics.append(
665
+ {
666
+ "entity_id": entity_id,
667
+ "period": period,
668
+ "statistics": formatted_stats,
669
+ "count": len(formatted_stats),
670
+ "unit_of_measurement": unit,
671
+ }
672
+ )
673
+
674
+ # Check if any entities had no statistics
675
+ empty_entities: list[str] = [
676
+ str(e["entity_id"]) for e in entities_statistics if e["count"] == 0
677
+ ]
678
+
679
+ statistics_data: dict[str, Any] = {
680
+ "success": True,
681
+ "entities": entities_statistics,
682
+ "period_type": period,
683
+ "time_range": {
684
+ "start": start_dt.isoformat(),
685
+ "end": end_dt.isoformat(),
686
+ },
687
+ "statistic_types": stat_types_list
688
+ or ["mean", "min", "max", "sum", "state", "change"],
689
+ }
690
+
691
+ if empty_entities:
692
+ statistics_data["warnings"] = [
693
+ f"No statistics found for: {', '.join(empty_entities)}. "
694
+ "These entities may not have state_class attribute or may not have recorded data yet."
695
+ ]
696
+
697
+ return await add_timezone_metadata(client, statistics_data)
698
+
699
+ finally:
700
+ if ws_client:
701
+ await ws_client.disconnect()
702
+
703
+ except Exception as e:
704
+ logger.error(f"Failed to get statistics: {e}")
705
+ error_data = {
706
+ "success": False,
707
+ "error": f"Failed to retrieve statistics: {str(e)}",
708
+ "suggestions": [
709
+ "Check Home Assistant connection",
710
+ "Verify entities have state_class attribute",
711
+ "Ensure recorder component is enabled with statistics",
712
+ ],
713
+ }
714
+ return await add_timezone_metadata(client, error_data)