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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- 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)
|