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,387 @@
1
+ """
2
+ Configuration management tools for Home Assistant zones.
3
+
4
+ This module provides tools for listing, creating, updating, and deleting
5
+ Home Assistant zones (location-based areas for presence automation).
6
+ """
7
+
8
+ import logging
9
+ from typing import Annotated, Any
10
+
11
+ from pydantic import Field
12
+
13
+ from .helpers import log_tool_usage
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def register_zone_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
19
+ """Register Home Assistant zone configuration tools."""
20
+
21
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["zone"], "title": "Get Zone"})
22
+ @log_tool_usage
23
+ async def ha_get_zone(
24
+ zone_id: Annotated[
25
+ str | None,
26
+ Field(
27
+ description="Zone ID to get details for (from ha_get_zone() list). "
28
+ "If omitted, lists all zones.",
29
+ default=None,
30
+ ),
31
+ ] = None,
32
+ ) -> dict[str, Any]:
33
+ """
34
+ Get zone information - list all zones or get details for a specific one.
35
+
36
+ Without a zone_id: Lists all Home Assistant zones with their coordinates and radius.
37
+ With a zone_id: Returns detailed configuration for a specific zone.
38
+
39
+ ZONE PROPERTIES:
40
+ - ID, name, icon
41
+ - Latitude, longitude, radius
42
+ - Passive mode setting
43
+
44
+ EXAMPLES:
45
+ - List all zones: ha_get_zone()
46
+ - Get specific zone: ha_get_zone(zone_id="abc123")
47
+
48
+ **NOTE:** This returns storage-based zones (created via UI/API), not YAML-defined zones.
49
+ The 'home' zone is typically defined in YAML and may not appear in this list.
50
+ """
51
+ try:
52
+ message: dict[str, Any] = {
53
+ "type": "zone/list",
54
+ }
55
+
56
+ result = await client.send_websocket_message(message)
57
+
58
+ if not result.get("success"):
59
+ return {
60
+ "success": False,
61
+ "error": f"Failed to get zones: {result.get('error', 'Unknown error')}",
62
+ }
63
+
64
+ zones = result.get("result", [])
65
+
66
+ # If no zone_id provided, return list of all zones
67
+ if zone_id is None:
68
+ return {
69
+ "success": True,
70
+ "count": len(zones),
71
+ "zones": zones,
72
+ "message": f"Found {len(zones)} zone(s)",
73
+ }
74
+
75
+ # Find specific zone by ID
76
+ zone = next((z for z in zones if z.get("id") == zone_id), None)
77
+
78
+ if zone is None:
79
+ available_ids = [z.get("id") for z in zones[:10]] # Show first 10
80
+ return {
81
+ "success": False,
82
+ "error": f"Zone not found: {zone_id}",
83
+ "zone_id": zone_id,
84
+ "available_zone_ids": available_ids,
85
+ "suggestion": "Use ha_get_zone() without zone_id to see all available zones",
86
+ }
87
+
88
+ return {
89
+ "success": True,
90
+ "zone_id": zone_id,
91
+ "zone": zone,
92
+ }
93
+
94
+ except Exception as e:
95
+ logger.error(f"Error getting zones: {e}")
96
+ return {
97
+ "success": False,
98
+ "error": f"Failed to get zones: {str(e)}",
99
+ "suggestions": [
100
+ "Check Home Assistant connection",
101
+ "Verify WebSocket connection is active",
102
+ "Use ha_search_entities(domain_filter='zone') as alternative",
103
+ ],
104
+ }
105
+
106
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["zone"], "title": "Create Zone"})
107
+ @log_tool_usage
108
+ async def ha_create_zone(
109
+ name: Annotated[str, Field(description="Display name for the zone")],
110
+ latitude: Annotated[
111
+ float,
112
+ Field(description="Latitude coordinate of the zone center"),
113
+ ],
114
+ longitude: Annotated[
115
+ float,
116
+ Field(description="Longitude coordinate of the zone center"),
117
+ ],
118
+ radius: Annotated[
119
+ float,
120
+ Field(
121
+ description="Radius of the zone in meters (default: 100)",
122
+ default=100,
123
+ ),
124
+ ] = 100,
125
+ icon: Annotated[
126
+ str | None,
127
+ Field(
128
+ description="Material Design Icon (e.g., 'mdi:briefcase', 'mdi:school')",
129
+ default=None,
130
+ ),
131
+ ] = None,
132
+ passive: Annotated[
133
+ bool,
134
+ Field(
135
+ description="If True, zone will not trigger automations on enter/exit (default: False)",
136
+ default=False,
137
+ ),
138
+ ] = False,
139
+ ) -> dict[str, Any]:
140
+ """
141
+ Create a new Home Assistant zone for presence detection.
142
+
143
+ Zones are location-based areas that can trigger automations when
144
+ devices enter or exit. Common use cases include:
145
+ - Home, Work, School locations
146
+ - Gym, Shopping areas
147
+ - Family members' locations
148
+
149
+ EXAMPLES:
150
+ - Create office zone: ha_create_zone("Office", 40.7128, -74.0060, radius=150, icon="mdi:briefcase")
151
+ - Create gym zone: ha_create_zone("Gym", 40.7580, -73.9855, radius=50, icon="mdi:dumbbell")
152
+ - Create passive zone: ha_create_zone("City Center", 40.7484, -73.9857, radius=500, passive=True)
153
+
154
+ Note: The 'home' zone is typically defined in configuration.yaml and cannot be created via this API.
155
+ """
156
+ try:
157
+ # Validate coordinates
158
+ if not (-90 <= latitude <= 90):
159
+ return {
160
+ "success": False,
161
+ "error": f"Invalid latitude: {latitude}. Must be between -90 and 90.",
162
+ }
163
+ if not (-180 <= longitude <= 180):
164
+ return {
165
+ "success": False,
166
+ "error": f"Invalid longitude: {longitude}. Must be between -180 and 180.",
167
+ }
168
+ if radius <= 0:
169
+ return {
170
+ "success": False,
171
+ "error": f"Invalid radius: {radius}. Must be greater than 0.",
172
+ }
173
+
174
+ # Build create message
175
+ message: dict[str, Any] = {
176
+ "type": "zone/create",
177
+ "name": name,
178
+ "latitude": latitude,
179
+ "longitude": longitude,
180
+ "radius": radius,
181
+ "passive": passive,
182
+ }
183
+
184
+ if icon:
185
+ message["icon"] = icon
186
+
187
+ result = await client.send_websocket_message(message)
188
+
189
+ if result.get("success"):
190
+ zone_data = result.get("result", {})
191
+ return {
192
+ "success": True,
193
+ "zone_data": zone_data,
194
+ "zone_id": zone_data.get("id"),
195
+ "message": f"Successfully created zone: {name}",
196
+ }
197
+ else:
198
+ return {
199
+ "success": False,
200
+ "error": f"Failed to create zone: {result.get('error', 'Unknown error')}",
201
+ "name": name,
202
+ }
203
+
204
+ except Exception as e:
205
+ logger.error(f"Error creating zone: {e}")
206
+ return {
207
+ "success": False,
208
+ "error": f"Zone creation failed: {str(e)}",
209
+ "name": name,
210
+ "suggestions": [
211
+ "Check Home Assistant connection",
212
+ "Verify coordinates are valid",
213
+ "Ensure zone name is unique",
214
+ ],
215
+ }
216
+
217
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["zone"], "title": "Update Zone"})
218
+ @log_tool_usage
219
+ async def ha_update_zone(
220
+ zone_id: Annotated[
221
+ str,
222
+ Field(description="Zone ID to update (from ha_get_zone)"),
223
+ ],
224
+ name: Annotated[
225
+ str | None,
226
+ Field(description="New display name for the zone", default=None),
227
+ ] = None,
228
+ latitude: Annotated[
229
+ float | None,
230
+ Field(description="New latitude coordinate", default=None),
231
+ ] = None,
232
+ longitude: Annotated[
233
+ float | None,
234
+ Field(description="New longitude coordinate", default=None),
235
+ ] = None,
236
+ radius: Annotated[
237
+ float | None,
238
+ Field(description="New radius in meters", default=None),
239
+ ] = None,
240
+ icon: Annotated[
241
+ str | None,
242
+ Field(description="New Material Design Icon", default=None),
243
+ ] = None,
244
+ passive: Annotated[
245
+ bool | None,
246
+ Field(description="New passive mode setting", default=None),
247
+ ] = None,
248
+ ) -> dict[str, Any]:
249
+ """
250
+ Update an existing Home Assistant zone.
251
+
252
+ Only the fields you specify will be updated. Other fields remain unchanged.
253
+
254
+ EXAMPLES:
255
+ - Update zone name: ha_update_zone("abc123", name="New Office")
256
+ - Update zone radius: ha_update_zone("abc123", radius=200)
257
+ - Update zone location: ha_update_zone("abc123", latitude=40.7128, longitude=-74.0060)
258
+ - Update multiple fields: ha_update_zone("abc123", name="Gym", radius=75, icon="mdi:dumbbell")
259
+
260
+ **TIP:** Use ha_get_zone() to get the zone_id for the zone you want to update.
261
+ """
262
+ try:
263
+ # Validate that at least one field is being updated
264
+ update_fields = {
265
+ "name": name,
266
+ "latitude": latitude,
267
+ "longitude": longitude,
268
+ "radius": radius,
269
+ "icon": icon,
270
+ "passive": passive,
271
+ }
272
+ fields_to_update = {k: v for k, v in update_fields.items() if v is not None}
273
+
274
+ if not fields_to_update:
275
+ return {
276
+ "success": False,
277
+ "error": "No fields to update. Provide at least one field to change.",
278
+ "zone_id": zone_id,
279
+ }
280
+
281
+ # Validate coordinates if provided
282
+ if latitude is not None and not (-90 <= latitude <= 90):
283
+ return {
284
+ "success": False,
285
+ "error": f"Invalid latitude: {latitude}. Must be between -90 and 90.",
286
+ }
287
+ if longitude is not None and not (-180 <= longitude <= 180):
288
+ return {
289
+ "success": False,
290
+ "error": f"Invalid longitude: {longitude}. Must be between -180 and 180.",
291
+ }
292
+ if radius is not None and radius <= 0:
293
+ return {
294
+ "success": False,
295
+ "error": f"Invalid radius: {radius}. Must be greater than 0.",
296
+ }
297
+
298
+ # Build update message
299
+ message: dict[str, Any] = {
300
+ "type": "zone/update",
301
+ "zone_id": zone_id,
302
+ **fields_to_update,
303
+ }
304
+
305
+ result = await client.send_websocket_message(message)
306
+
307
+ if result.get("success"):
308
+ zone_data = result.get("result", {})
309
+ return {
310
+ "success": True,
311
+ "zone_data": zone_data,
312
+ "zone_id": zone_id,
313
+ "updated_fields": list(fields_to_update.keys()),
314
+ "message": f"Successfully updated zone: {zone_id}",
315
+ }
316
+ else:
317
+ return {
318
+ "success": False,
319
+ "error": f"Failed to update zone: {result.get('error', 'Unknown error')}",
320
+ "zone_id": zone_id,
321
+ }
322
+
323
+ except Exception as e:
324
+ logger.error(f"Error updating zone: {e}")
325
+ return {
326
+ "success": False,
327
+ "error": f"Zone update failed: {str(e)}",
328
+ "zone_id": zone_id,
329
+ "suggestions": [
330
+ "Check Home Assistant connection",
331
+ "Verify zone_id exists using ha_get_zone()",
332
+ "Ensure values are valid",
333
+ ],
334
+ }
335
+
336
+ @mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True, "tags": ["zone"], "title": "Delete Zone"})
337
+ @log_tool_usage
338
+ async def ha_delete_zone(
339
+ zone_id: Annotated[
340
+ str,
341
+ Field(description="Zone ID to delete (from ha_get_zone)"),
342
+ ],
343
+ ) -> dict[str, Any]:
344
+ """
345
+ Delete a Home Assistant zone.
346
+
347
+ EXAMPLES:
348
+ - Delete zone: ha_delete_zone("abc123")
349
+
350
+ **WARNING:** Deleting a zone that is used in automations may cause those automations to fail.
351
+ Use ha_get_zone() to get the zone_id for the zone you want to delete.
352
+
353
+ **NOTE:** The 'home' zone cannot be deleted as it is typically defined in configuration.yaml.
354
+ """
355
+ try:
356
+ message: dict[str, Any] = {
357
+ "type": "zone/delete",
358
+ "zone_id": zone_id,
359
+ }
360
+
361
+ result = await client.send_websocket_message(message)
362
+
363
+ if result.get("success"):
364
+ return {
365
+ "success": True,
366
+ "zone_id": zone_id,
367
+ "message": f"Successfully deleted zone: {zone_id}",
368
+ }
369
+ else:
370
+ return {
371
+ "success": False,
372
+ "error": f"Failed to delete zone: {result.get('error', 'Unknown error')}",
373
+ "zone_id": zone_id,
374
+ }
375
+
376
+ except Exception as e:
377
+ logger.error(f"Error deleting zone: {e}")
378
+ return {
379
+ "success": False,
380
+ "error": f"Zone deletion failed: {str(e)}",
381
+ "zone_id": zone_id,
382
+ "suggestions": [
383
+ "Check Home Assistant connection",
384
+ "Verify zone_id exists using ha_get_zone()",
385
+ "Ensure zone is not the 'home' zone (YAML-defined)",
386
+ ],
387
+ }
@@ -0,0 +1,199 @@
1
+ """
2
+ Shared utility functions for MCP tool modules.
3
+
4
+ This module provides common helper functions used across multiple tool registration modules.
5
+ """
6
+
7
+ import json
8
+ from typing import Any
9
+
10
+
11
+ def coerce_bool_param(
12
+ value: bool | str | None,
13
+ param_name: str = "parameter",
14
+ default: bool | None = None,
15
+ ) -> bool | None:
16
+ """
17
+ Coerce a value to a boolean, handling string inputs from AI tools.
18
+
19
+ AI assistants using XML-style function calls pass boolean parameters as strings
20
+ (e.g., "true" instead of true). This function safely converts such inputs.
21
+
22
+ Args:
23
+ value: The value to coerce (bool, str, or None)
24
+ param_name: Parameter name for error messages
25
+ default: Default value to return if value is None
26
+
27
+ Returns:
28
+ The coerced boolean value, or default if value is None
29
+
30
+ Raises:
31
+ ValueError: If the value cannot be converted to a boolean
32
+ """
33
+ if value is None:
34
+ return default
35
+
36
+ if isinstance(value, bool):
37
+ return value
38
+
39
+ if isinstance(value, str):
40
+ value = value.strip().lower()
41
+ if not value:
42
+ return default
43
+ if value in ("true", "1", "yes", "on"):
44
+ return True
45
+ if value in ("false", "0", "no", "off"):
46
+ return False
47
+ raise ValueError(
48
+ f"{param_name} must be a boolean value, got '{value}'"
49
+ )
50
+
51
+ raise ValueError(
52
+ f"{param_name} must be bool or string, got {type(value).__name__}"
53
+ )
54
+
55
+
56
+ def coerce_int_param(
57
+ value: int | str | None,
58
+ param_name: str = "parameter",
59
+ default: int | None = None,
60
+ min_value: int | None = None,
61
+ max_value: int | None = None,
62
+ ) -> int | None:
63
+ """
64
+ Coerce a value to an integer, handling string inputs from AI tools.
65
+
66
+ AI assistants often pass numeric parameters as strings (e.g., "100" instead of 100).
67
+ This function safely converts such inputs to integers.
68
+
69
+ Args:
70
+ value: The value to coerce (int, str, or None)
71
+ param_name: Parameter name for error messages
72
+ default: Default value to return if value is None
73
+ min_value: Optional minimum value constraint
74
+ max_value: Optional maximum value constraint
75
+
76
+ Returns:
77
+ The coerced integer value, or default if value is None
78
+
79
+ Raises:
80
+ ValueError: If the value cannot be converted to an integer
81
+ """
82
+ if value is None:
83
+ return default
84
+
85
+ if isinstance(value, int):
86
+ result = value
87
+ elif isinstance(value, str):
88
+ value = value.strip()
89
+ if not value:
90
+ return default
91
+ try:
92
+ # Handle float strings like "100.0" by converting via float first
93
+ result = int(float(value))
94
+ except ValueError:
95
+ raise ValueError(
96
+ f"{param_name} must be a valid integer, got '{value}'"
97
+ ) from None
98
+ else:
99
+ raise ValueError(
100
+ f"{param_name} must be int or string, got {type(value).__name__}"
101
+ )
102
+
103
+ # Apply constraints
104
+ if min_value is not None and result < min_value:
105
+ result = min_value
106
+ if max_value is not None and result > max_value:
107
+ result = max_value
108
+
109
+ return result
110
+
111
+
112
+ def parse_json_param(
113
+ param: str | dict | list | None, param_name: str = "parameter"
114
+ ) -> dict | list | None:
115
+ """
116
+ Parse flexibly JSON string or return existing dict/list.
117
+
118
+ Args:
119
+ param: JSON string, dict, list, or None
120
+ param_name: Parameter name for error context
121
+
122
+ Returns:
123
+ Parsed dict/list or original value if already correct type
124
+
125
+ Raises:
126
+ ValueError: If JSON parsing fails
127
+ """
128
+ if param is None:
129
+ return None
130
+
131
+ if isinstance(param, (dict, list)):
132
+ return param
133
+
134
+ if isinstance(param, str):
135
+ try:
136
+ parsed = json.loads(param)
137
+ if not isinstance(parsed, (dict, list)):
138
+ raise ValueError(
139
+ f"{param_name} must be a JSON object or array, got {type(parsed).__name__}"
140
+ )
141
+ return parsed
142
+ except json.JSONDecodeError as e:
143
+ raise ValueError(f"Invalid JSON in {param_name}: {e}")
144
+
145
+ raise ValueError(
146
+ f"{param_name} must be string, dict, list, or None, got {type(param).__name__}"
147
+ )
148
+
149
+
150
+ def parse_string_list_param(
151
+ param: str | list[str] | None, param_name: str = "parameter"
152
+ ) -> list[str] | None:
153
+ """Parse JSON string array or return existing list of strings."""
154
+ if param is None:
155
+ return None
156
+
157
+ if isinstance(param, list):
158
+ if all(isinstance(item, str) for item in param):
159
+ return param
160
+ raise ValueError(f"{param_name} must be a list of strings")
161
+
162
+ if isinstance(param, str):
163
+ try:
164
+ parsed = json.loads(param)
165
+ if not isinstance(parsed, list):
166
+ raise ValueError(f"{param_name} must be a JSON array")
167
+ if not all(isinstance(item, str) for item in parsed):
168
+ raise ValueError(f"{param_name} must be a JSON array of strings")
169
+ return parsed
170
+ except json.JSONDecodeError as e:
171
+ raise ValueError(f"Invalid JSON in {param_name}: {e}")
172
+
173
+ raise ValueError(f"{param_name} must be string, list, or None")
174
+
175
+
176
+ async def add_timezone_metadata(client: Any, data: dict[str, Any]) -> dict[str, Any]:
177
+ """Add timezone metadata to tool responses containing timestamps."""
178
+ try:
179
+ config = await client.get_config()
180
+ ha_timezone = config.get("time_zone", "UTC")
181
+
182
+ return {
183
+ "data": data,
184
+ "metadata": {
185
+ "home_assistant_timezone": ha_timezone,
186
+ "timestamp_format": "ISO 8601 (UTC)",
187
+ "note": f"All timestamps are in UTC. Home Assistant timezone is {ha_timezone}.",
188
+ },
189
+ }
190
+ except Exception:
191
+ # Fallback if config fetch fails
192
+ return {
193
+ "data": data,
194
+ "metadata": {
195
+ "home_assistant_timezone": "Unknown",
196
+ "timestamp_format": "ISO 8601 (UTC)",
197
+ "note": "All timestamps are in UTC. Could not fetch Home Assistant timezone.",
198
+ },
199
+ }
@@ -0,0 +1,30 @@
1
+ """
2
+ Utility modules for Home Assistant MCP server.
3
+ """
4
+
5
+ from .fuzzy_search import FuzzyEntitySearcher, create_fuzzy_searcher
6
+ from .operation_manager import (
7
+ DeviceOperation,
8
+ OperationManager,
9
+ OperationStatus,
10
+ get_operation_manager,
11
+ get_operation_from_memory,
12
+ store_pending_operation,
13
+ update_pending_operations,
14
+ )
15
+ from .usage_logger import UsageLogger, ToolUsageLog, log_tool_call
16
+
17
+ __all__ = [
18
+ "FuzzyEntitySearcher",
19
+ "create_fuzzy_searcher",
20
+ "DeviceOperation",
21
+ "OperationManager",
22
+ "OperationStatus",
23
+ "get_operation_manager",
24
+ "get_operation_from_memory",
25
+ "store_pending_operation",
26
+ "update_pending_operations",
27
+ "UsageLogger",
28
+ "ToolUsageLog",
29
+ "log_tool_call",
30
+ ]