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,478 @@
1
+ """
2
+ Area and floor management tools for Home Assistant.
3
+
4
+ This module provides tools for listing, creating, updating, and deleting
5
+ Home Assistant areas and floors - essential organizational features for smart homes.
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
+ from .util_helpers import parse_string_list_param
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def register_area_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
20
+ """Register Home Assistant area and floor management tools."""
21
+
22
+ # ============================================================
23
+ # AREA TOOLS
24
+ # ============================================================
25
+
26
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["area"], "title": "List Areas"})
27
+ @log_tool_usage
28
+ async def ha_config_list_areas() -> dict[str, Any]:
29
+ """
30
+ List all Home Assistant areas (rooms).
31
+
32
+ Returns area ID, name, icon, floor assignment, aliases, and picture URL.
33
+ """
34
+ try:
35
+ message: dict[str, Any] = {
36
+ "type": "config/area_registry/list",
37
+ }
38
+
39
+ result = await client.send_websocket_message(message)
40
+
41
+ if result.get("success"):
42
+ areas = result.get("result", [])
43
+ return {
44
+ "success": True,
45
+ "count": len(areas),
46
+ "areas": areas,
47
+ "message": f"Found {len(areas)} area(s)",
48
+ }
49
+ else:
50
+ return {
51
+ "success": False,
52
+ "error": f"Failed to list areas: {result.get('error', 'Unknown error')}",
53
+ }
54
+
55
+ except Exception as e:
56
+ logger.error(f"Error listing areas: {e}")
57
+ return {
58
+ "success": False,
59
+ "error": f"Failed to list areas: {str(e)}",
60
+ "suggestions": [
61
+ "Check Home Assistant connection",
62
+ "Verify WebSocket connection is active",
63
+ ],
64
+ }
65
+
66
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["area"], "title": "Create or Update Area"})
67
+ @log_tool_usage
68
+ async def ha_config_set_area(
69
+ name: Annotated[
70
+ str | None,
71
+ Field(
72
+ description="Name for the area (required for create, optional for update, e.g., 'Living Room', 'Kitchen')",
73
+ default=None,
74
+ ),
75
+ ] = None,
76
+ area_id: Annotated[
77
+ str | None,
78
+ Field(
79
+ description="Area ID to update (omit to create new area, use ha_config_list_areas to find IDs)",
80
+ default=None,
81
+ ),
82
+ ] = None,
83
+ floor_id: Annotated[
84
+ str | None,
85
+ Field(
86
+ description="Floor ID to assign this area to (use ha_config_list_floors to find IDs, empty string to remove)",
87
+ default=None,
88
+ ),
89
+ ] = None,
90
+ icon: Annotated[
91
+ str | None,
92
+ Field(
93
+ description="Material Design Icon (e.g., 'mdi:sofa', 'mdi:bed', empty string to remove)",
94
+ default=None,
95
+ ),
96
+ ] = None,
97
+ aliases: Annotated[
98
+ str | list[str] | None,
99
+ Field(
100
+ description="Alternative names for voice assistant recognition (e.g., ['lounge', 'family room'], empty list to clear)",
101
+ default=None,
102
+ ),
103
+ ] = None,
104
+ picture: Annotated[
105
+ str | None,
106
+ Field(
107
+ description="URL to a picture representing the area (empty string to remove)",
108
+ default=None,
109
+ ),
110
+ ] = None,
111
+ ) -> dict[str, Any]:
112
+ """
113
+ Create or update a Home Assistant area (room).
114
+
115
+ Provide name only to create a new area. Provide area_id to update existing.
116
+ Areas organize entities by physical location for room-based control.
117
+ """
118
+ try:
119
+ # Parse aliases if provided as string
120
+ try:
121
+ parsed_aliases = parse_string_list_param(aliases, "aliases")
122
+ except ValueError as e:
123
+ return {"success": False, "error": f"Invalid aliases parameter: {e}"}
124
+
125
+ # Determine if this is a create or update operation
126
+ if area_id:
127
+ # UPDATE operation
128
+ message: dict[str, Any] = {
129
+ "type": "config/area_registry/update",
130
+ "area_id": area_id,
131
+ }
132
+
133
+ # Only add fields that were explicitly provided
134
+ if name is not None:
135
+ message["name"] = name
136
+ if floor_id is not None:
137
+ message["floor_id"] = floor_id if floor_id else None
138
+ if icon is not None:
139
+ message["icon"] = icon if icon else None
140
+ if parsed_aliases is not None:
141
+ message["aliases"] = parsed_aliases
142
+ if picture is not None:
143
+ message["picture"] = picture if picture else None
144
+
145
+ operation = "update"
146
+ else:
147
+ # CREATE operation - name is required
148
+ if not name:
149
+ return {
150
+ "success": False,
151
+ "error": "name is required when creating a new area",
152
+ }
153
+
154
+ message: dict[str, Any] = {
155
+ "type": "config/area_registry/create",
156
+ "name": name,
157
+ }
158
+
159
+ if floor_id:
160
+ message["floor_id"] = floor_id
161
+ if icon:
162
+ message["icon"] = icon
163
+ if parsed_aliases:
164
+ message["aliases"] = parsed_aliases
165
+ if picture:
166
+ message["picture"] = picture
167
+
168
+ operation = "create"
169
+
170
+ result = await client.send_websocket_message(message)
171
+
172
+ if result.get("success"):
173
+ area_data = result.get("result", {})
174
+ area_name = name or area_data.get("name", area_id)
175
+ return {
176
+ "success": True,
177
+ "area": area_data,
178
+ "area_id": area_data.get("area_id", area_id),
179
+ "message": f"Successfully {operation}d area: {area_name}",
180
+ }
181
+ else:
182
+ error = result.get("error", {})
183
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
184
+ error_response = {
185
+ "success": False,
186
+ "error": f"Failed to {operation} area: {error_msg}",
187
+ }
188
+ if name:
189
+ error_response["name"] = name
190
+ return error_response
191
+
192
+ except Exception as e:
193
+ logger.error(f"Error in ha_config_set_area: {e}")
194
+ error_response = {
195
+ "success": False,
196
+ "error": f"Failed to set area: {str(e)}",
197
+ "suggestions": [
198
+ "Check Home Assistant connection",
199
+ "For create: Verify the name is unique",
200
+ "For update: Verify the area_id exists using ha_config_list_areas()",
201
+ "If assigning to a floor, verify floor_id exists",
202
+ ],
203
+ }
204
+ if name:
205
+ error_response["name"] = name
206
+ return error_response
207
+
208
+ @mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True, "tags": ["area"], "title": "Remove Area"})
209
+ @log_tool_usage
210
+ async def ha_config_remove_area(
211
+ area_id: Annotated[
212
+ str,
213
+ Field(description="Area ID to delete (use ha_config_list_areas to find IDs)"),
214
+ ],
215
+ ) -> dict[str, Any]:
216
+ """
217
+ Delete a Home Assistant area.
218
+
219
+ Entities and devices in the area are not deleted, just unassigned.
220
+ May break automations referencing this area.
221
+ """
222
+ try:
223
+ message: dict[str, Any] = {
224
+ "type": "config/area_registry/delete",
225
+ "area_id": area_id,
226
+ }
227
+
228
+ result = await client.send_websocket_message(message)
229
+
230
+ if result.get("success"):
231
+ return {
232
+ "success": True,
233
+ "area_id": area_id,
234
+ "message": f"Successfully deleted area: {area_id}",
235
+ }
236
+ else:
237
+ error = result.get("error", {})
238
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
239
+ return {
240
+ "success": False,
241
+ "error": f"Failed to delete area: {error_msg}",
242
+ "area_id": area_id,
243
+ }
244
+
245
+ except Exception as e:
246
+ logger.error(f"Error deleting area: {e}")
247
+ return {
248
+ "success": False,
249
+ "error": f"Failed to delete area: {str(e)}",
250
+ "area_id": area_id,
251
+ "suggestions": [
252
+ "Check Home Assistant connection",
253
+ "Verify the area_id exists using ha_config_list_areas()",
254
+ ],
255
+ }
256
+
257
+ # ============================================================
258
+ # FLOOR TOOLS
259
+ # ============================================================
260
+
261
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["floor"], "title": "List Floors"})
262
+ @log_tool_usage
263
+ async def ha_config_list_floors() -> dict[str, Any]:
264
+ """
265
+ List all Home Assistant floors.
266
+
267
+ Returns floor ID, name, icon, level (0=ground, 1=first, -1=basement), and aliases.
268
+ """
269
+ try:
270
+ message: dict[str, Any] = {
271
+ "type": "config/floor_registry/list",
272
+ }
273
+
274
+ result = await client.send_websocket_message(message)
275
+
276
+ if result.get("success"):
277
+ floors = result.get("result", [])
278
+ return {
279
+ "success": True,
280
+ "count": len(floors),
281
+ "floors": floors,
282
+ "message": f"Found {len(floors)} floor(s)",
283
+ }
284
+ else:
285
+ return {
286
+ "success": False,
287
+ "error": f"Failed to list floors: {result.get('error', 'Unknown error')}",
288
+ }
289
+
290
+ except Exception as e:
291
+ logger.error(f"Error listing floors: {e}")
292
+ return {
293
+ "success": False,
294
+ "error": f"Failed to list floors: {str(e)}",
295
+ "suggestions": [
296
+ "Check Home Assistant connection",
297
+ "Verify WebSocket connection is active",
298
+ ],
299
+ }
300
+
301
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["floor"], "title": "Create or Update Floor"})
302
+ @log_tool_usage
303
+ async def ha_config_set_floor(
304
+ name: Annotated[
305
+ str | None,
306
+ Field(
307
+ description="Name for the floor (required for create, optional for update, e.g., 'Ground Floor', 'Basement')",
308
+ default=None,
309
+ ),
310
+ ] = None,
311
+ floor_id: Annotated[
312
+ str | None,
313
+ Field(
314
+ description="Floor ID to update (omit to create new floor, use ha_config_list_floors to find IDs)",
315
+ default=None,
316
+ ),
317
+ ] = None,
318
+ level: Annotated[
319
+ int | None,
320
+ Field(
321
+ description="Numeric level for ordering (0=ground, 1=first, -1=basement, etc.)",
322
+ default=None,
323
+ ),
324
+ ] = None,
325
+ icon: Annotated[
326
+ str | None,
327
+ Field(
328
+ description="Material Design Icon (e.g., 'mdi:home-floor-1', 'mdi:home-floor-b', empty string to remove)",
329
+ default=None,
330
+ ),
331
+ ] = None,
332
+ aliases: Annotated[
333
+ str | list[str] | None,
334
+ Field(
335
+ description="Alternative names for voice assistant recognition (e.g., ['downstairs', 'main level'], empty list to clear)",
336
+ default=None,
337
+ ),
338
+ ] = None,
339
+ ) -> dict[str, Any]:
340
+ """
341
+ Create or update a Home Assistant floor.
342
+
343
+ Provide name only to create a new floor. Provide floor_id to update existing.
344
+ Floors organize areas into vertical levels for building-wide control.
345
+ """
346
+ try:
347
+ # Parse aliases if provided as string
348
+ try:
349
+ parsed_aliases = parse_string_list_param(aliases, "aliases")
350
+ except ValueError as e:
351
+ return {"success": False, "error": f"Invalid aliases parameter: {e}"}
352
+
353
+ # Determine if this is a create or update operation
354
+ if floor_id:
355
+ # UPDATE operation
356
+ message: dict[str, Any] = {
357
+ "type": "config/floor_registry/update",
358
+ "floor_id": floor_id,
359
+ }
360
+
361
+ # Only add fields that were explicitly provided
362
+ if name is not None:
363
+ message["name"] = name
364
+ if level is not None:
365
+ message["level"] = level
366
+ if icon is not None:
367
+ message["icon"] = icon if icon else None
368
+ if parsed_aliases is not None:
369
+ message["aliases"] = parsed_aliases
370
+
371
+ operation = "update"
372
+ else:
373
+ # CREATE operation - name is required
374
+ if not name:
375
+ return {
376
+ "success": False,
377
+ "error": "name is required when creating a new floor",
378
+ }
379
+
380
+ message: dict[str, Any] = {
381
+ "type": "config/floor_registry/create",
382
+ "name": name,
383
+ }
384
+
385
+ if level is not None:
386
+ message["level"] = level
387
+ if icon:
388
+ message["icon"] = icon
389
+ if parsed_aliases:
390
+ message["aliases"] = parsed_aliases
391
+
392
+ operation = "create"
393
+
394
+ result = await client.send_websocket_message(message)
395
+
396
+ if result.get("success"):
397
+ floor_data = result.get("result", {})
398
+ floor_name = name or floor_data.get("name", floor_id)
399
+ return {
400
+ "success": True,
401
+ "floor": floor_data,
402
+ "floor_id": floor_data.get("floor_id", floor_id),
403
+ "message": f"Successfully {operation}d floor: {floor_name}",
404
+ }
405
+ else:
406
+ error = result.get("error", {})
407
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
408
+ error_response = {
409
+ "success": False,
410
+ "error": f"Failed to {operation} floor: {error_msg}",
411
+ }
412
+ if name:
413
+ error_response["name"] = name
414
+ return error_response
415
+
416
+ except Exception as e:
417
+ logger.error(f"Error in ha_config_set_floor: {e}")
418
+ error_response = {
419
+ "success": False,
420
+ "error": f"Failed to set floor: {str(e)}",
421
+ "suggestions": [
422
+ "Check Home Assistant connection",
423
+ "For create: Verify the name is unique",
424
+ "For update: Verify the floor_id exists using ha_config_list_floors()",
425
+ ],
426
+ }
427
+ if name:
428
+ error_response["name"] = name
429
+ return error_response
430
+
431
+ @mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True, "tags": ["floor"], "title": "Remove Floor"})
432
+ @log_tool_usage
433
+ async def ha_config_remove_floor(
434
+ floor_id: Annotated[
435
+ str,
436
+ Field(description="Floor ID to delete (use ha_config_list_floors to find IDs)"),
437
+ ],
438
+ ) -> dict[str, Any]:
439
+ """
440
+ Delete a Home Assistant floor.
441
+
442
+ Areas on this floor are not deleted, just unassigned.
443
+ May break automations referencing this floor.
444
+ """
445
+ try:
446
+ message: dict[str, Any] = {
447
+ "type": "config/floor_registry/delete",
448
+ "floor_id": floor_id,
449
+ }
450
+
451
+ result = await client.send_websocket_message(message)
452
+
453
+ if result.get("success"):
454
+ return {
455
+ "success": True,
456
+ "floor_id": floor_id,
457
+ "message": f"Successfully deleted floor: {floor_id}",
458
+ }
459
+ else:
460
+ error = result.get("error", {})
461
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
462
+ return {
463
+ "success": False,
464
+ "error": f"Failed to delete floor: {error_msg}",
465
+ "floor_id": floor_id,
466
+ }
467
+
468
+ except Exception as e:
469
+ logger.error(f"Error deleting floor: {e}")
470
+ return {
471
+ "success": False,
472
+ "error": f"Failed to delete floor: {str(e)}",
473
+ "floor_id": floor_id,
474
+ "suggestions": [
475
+ "Check Home Assistant connection",
476
+ "Verify the floor_id exists using ha_config_list_floors()",
477
+ ],
478
+ }