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,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
|
+
}
|
ha_mcp/utils/__init__.py
ADDED
|
@@ -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
|
+
]
|