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,925 @@
1
+ """
2
+ Configuration management tools for Home Assistant helpers.
3
+
4
+ This module provides tools for listing, creating, updating, and removing
5
+ Home Assistant helper entities (input_button, input_boolean, input_select,
6
+ input_number, input_text, input_datetime, counter, timer, schedule).
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import Annotated, Any, Literal
12
+
13
+ from pydantic import Field
14
+
15
+ from .helpers import log_tool_usage
16
+ from .util_helpers import parse_string_list_param
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def register_config_helper_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
22
+ """Register Home Assistant helper configuration tools."""
23
+
24
+ @mcp.tool(
25
+ annotations={
26
+ "idempotentHint": True,
27
+ "readOnlyHint": True,
28
+ "tags": ["helper"],
29
+ "title": "List Helpers",
30
+ }
31
+ )
32
+ @log_tool_usage
33
+ async def ha_config_list_helpers(
34
+ helper_type: Annotated[
35
+ Literal[
36
+ "input_button",
37
+ "input_boolean",
38
+ "input_select",
39
+ "input_number",
40
+ "input_text",
41
+ "input_datetime",
42
+ "counter",
43
+ "timer",
44
+ "schedule",
45
+ "zone",
46
+ "person",
47
+ "tag",
48
+ ],
49
+ Field(description="Type of helper entity to list"),
50
+ ],
51
+ ) -> dict[str, Any]:
52
+ """
53
+ List all Home Assistant helpers of a specific type with their configurations.
54
+
55
+ Returns complete configuration for all helpers of the specified type including:
56
+ - ID, name, icon
57
+ - Type-specific settings (min/max for input_number, options for input_select, etc.)
58
+ - Area and label assignments
59
+
60
+ SUPPORTED HELPER TYPES:
61
+ - input_button: Virtual buttons for triggering automations
62
+ - input_boolean: Toggle switches/checkboxes
63
+ - input_select: Dropdown selection lists
64
+ - input_number: Numeric sliders/input boxes
65
+ - input_text: Text input fields
66
+ - input_datetime: Date/time pickers
67
+ - counter: Counters with increment/decrement/reset
68
+ - timer: Countdown timers with start/pause/cancel
69
+ - schedule: Weekly schedules with time ranges (on/off per day)
70
+ - zone: Geographical zones for presence detection
71
+ - person: Person entities linked to device trackers
72
+ - tag: NFC/QR tags for automation triggers
73
+
74
+ EXAMPLES:
75
+ - List all number helpers: ha_config_list_helpers("input_number")
76
+ - List all counters: ha_config_list_helpers("counter")
77
+ - List all zones: ha_config_list_helpers("zone")
78
+ - List all persons: ha_config_list_helpers("person")
79
+ - List all tags: ha_config_list_helpers("tag")
80
+
81
+ **NOTE:** This only returns storage-based helpers (created via UI/API), not YAML-defined helpers.
82
+
83
+ For detailed helper documentation, use: ha_get_domain_docs("input_number"), etc.
84
+ """
85
+ try:
86
+ # Use the websocket list endpoint for the helper type
87
+ message: dict[str, Any] = {
88
+ "type": f"{helper_type}/list",
89
+ }
90
+
91
+ result = await client.send_websocket_message(message)
92
+
93
+ if result.get("success"):
94
+ items = result.get("result", [])
95
+ return {
96
+ "success": True,
97
+ "helper_type": helper_type,
98
+ "count": len(items),
99
+ "helpers": items,
100
+ "message": f"Found {len(items)} {helper_type} helper(s)",
101
+ }
102
+ else:
103
+ return {
104
+ "success": False,
105
+ "error": f"Failed to list helpers: {result.get('error', 'Unknown error')}",
106
+ "helper_type": helper_type,
107
+ }
108
+
109
+ except Exception as e:
110
+ logger.error(f"Error listing helpers: {e}")
111
+ return {
112
+ "success": False,
113
+ "error": f"Failed to list {helper_type} helpers: {str(e)}",
114
+ "helper_type": helper_type,
115
+ "suggestions": [
116
+ "Check Home Assistant connection",
117
+ "Verify WebSocket connection is active",
118
+ "Use ha_search_entities(domain_filter='input_*') as alternative",
119
+ ],
120
+ }
121
+
122
+ @mcp.tool(
123
+ annotations={
124
+ "destructiveHint": True,
125
+ "tags": ["helper"],
126
+ "title": "Create or Update Helper",
127
+ }
128
+ )
129
+ @log_tool_usage
130
+ async def ha_config_set_helper(
131
+ helper_type: Annotated[
132
+ Literal[
133
+ "input_button",
134
+ "input_boolean",
135
+ "input_select",
136
+ "input_number",
137
+ "input_text",
138
+ "input_datetime",
139
+ "counter",
140
+ "timer",
141
+ "schedule",
142
+ "zone",
143
+ "person",
144
+ "tag",
145
+ ],
146
+ Field(description="Type of helper entity to create or update"),
147
+ ],
148
+ name: Annotated[str, Field(description="Display name for the helper")],
149
+ helper_id: Annotated[
150
+ str | None,
151
+ Field(
152
+ description="Helper ID for updates (e.g., 'my_button' or 'input_button.my_button'). If not provided, creates a new helper.",
153
+ default=None,
154
+ ),
155
+ ] = None,
156
+ icon: Annotated[
157
+ str | None,
158
+ Field(
159
+ description="Material Design Icon (e.g., 'mdi:bell', 'mdi:toggle-switch')",
160
+ default=None,
161
+ ),
162
+ ] = None,
163
+ area_id: Annotated[
164
+ str | None,
165
+ Field(description="Area/room ID to assign the helper to", default=None),
166
+ ] = None,
167
+ labels: Annotated[
168
+ str | list[str] | None,
169
+ Field(description="Labels to categorize the helper", default=None),
170
+ ] = None,
171
+ min_value: Annotated[
172
+ float | None,
173
+ Field(
174
+ description="Minimum value (input_number/counter) or minimum length (input_text)",
175
+ default=None,
176
+ ),
177
+ ] = None,
178
+ max_value: Annotated[
179
+ float | None,
180
+ Field(
181
+ description="Maximum value (input_number/counter) or maximum length (input_text)",
182
+ default=None,
183
+ ),
184
+ ] = None,
185
+ step: Annotated[
186
+ float | None,
187
+ Field(
188
+ description="Step/increment value for input_number or counter",
189
+ default=None,
190
+ ),
191
+ ] = None,
192
+ unit_of_measurement: Annotated[
193
+ str | None,
194
+ Field(
195
+ description="Unit of measurement for input_number (e.g., '°C', '%', 'W')",
196
+ default=None,
197
+ ),
198
+ ] = None,
199
+ options: Annotated[
200
+ str | list[str] | None,
201
+ Field(
202
+ description="List of options for input_select (required for input_select)",
203
+ default=None,
204
+ ),
205
+ ] = None,
206
+ initial: Annotated[
207
+ str | int | None,
208
+ Field(
209
+ description="Initial value for the helper (input_select, input_text, input_boolean, input_datetime, counter)",
210
+ default=None,
211
+ ),
212
+ ] = None,
213
+ mode: Annotated[
214
+ str | None,
215
+ Field(
216
+ description="Display mode: 'box'/'slider' for input_number, 'text'/'password' for input_text",
217
+ default=None,
218
+ ),
219
+ ] = None,
220
+ has_date: Annotated[
221
+ bool | None,
222
+ Field(
223
+ description="Include date component for input_datetime", default=None
224
+ ),
225
+ ] = None,
226
+ has_time: Annotated[
227
+ bool | None,
228
+ Field(
229
+ description="Include time component for input_datetime", default=None
230
+ ),
231
+ ] = None,
232
+ restore: Annotated[
233
+ bool | None,
234
+ Field(
235
+ description="Restore state after restart (counter, timer). Defaults to True for counter, False for timer",
236
+ default=None,
237
+ ),
238
+ ] = None,
239
+ duration: Annotated[
240
+ str | None,
241
+ Field(
242
+ description="Default duration for timer in format 'HH:MM:SS' or seconds (e.g., '0:05:00' for 5 minutes)",
243
+ default=None,
244
+ ),
245
+ ] = None,
246
+ monday: Annotated[
247
+ list[dict[str, str]] | None,
248
+ Field(
249
+ description="Schedule time ranges for Monday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
250
+ default=None,
251
+ ),
252
+ ] = None,
253
+ tuesday: Annotated[
254
+ list[dict[str, str]] | None,
255
+ Field(
256
+ description="Schedule time ranges for Tuesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
257
+ default=None,
258
+ ),
259
+ ] = None,
260
+ wednesday: Annotated[
261
+ list[dict[str, str]] | None,
262
+ Field(
263
+ description="Schedule time ranges for Wednesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
264
+ default=None,
265
+ ),
266
+ ] = None,
267
+ thursday: Annotated[
268
+ list[dict[str, str]] | None,
269
+ Field(
270
+ description="Schedule time ranges for Thursday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
271
+ default=None,
272
+ ),
273
+ ] = None,
274
+ friday: Annotated[
275
+ list[dict[str, str]] | None,
276
+ Field(
277
+ description="Schedule time ranges for Friday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
278
+ default=None,
279
+ ),
280
+ ] = None,
281
+ saturday: Annotated[
282
+ list[dict[str, str]] | None,
283
+ Field(
284
+ description="Schedule time ranges for Saturday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
285
+ default=None,
286
+ ),
287
+ ] = None,
288
+ sunday: Annotated[
289
+ list[dict[str, str]] | None,
290
+ Field(
291
+ description="Schedule time ranges for Sunday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
292
+ default=None,
293
+ ),
294
+ ] = None,
295
+ latitude: Annotated[
296
+ float | None,
297
+ Field(
298
+ description="Latitude for zone (required for zone)",
299
+ default=None,
300
+ ),
301
+ ] = None,
302
+ longitude: Annotated[
303
+ float | None,
304
+ Field(
305
+ description="Longitude for zone (required for zone)",
306
+ default=None,
307
+ ),
308
+ ] = None,
309
+ radius: Annotated[
310
+ float | None,
311
+ Field(
312
+ description="Radius in meters for zone (default: 100)",
313
+ default=None,
314
+ ),
315
+ ] = None,
316
+ passive: Annotated[
317
+ bool | None,
318
+ Field(
319
+ description="Passive zone (won't trigger state changes for person entities)",
320
+ default=None,
321
+ ),
322
+ ] = None,
323
+ user_id: Annotated[
324
+ str | None,
325
+ Field(
326
+ description="User ID to link to person entity",
327
+ default=None,
328
+ ),
329
+ ] = None,
330
+ device_trackers: Annotated[
331
+ list[str] | None,
332
+ Field(
333
+ description="List of device_tracker entity IDs for person",
334
+ default=None,
335
+ ),
336
+ ] = None,
337
+ picture: Annotated[
338
+ str | None,
339
+ Field(
340
+ description="Picture URL for person entity",
341
+ default=None,
342
+ ),
343
+ ] = None,
344
+ tag_id: Annotated[
345
+ str | None,
346
+ Field(
347
+ description="Tag ID for tag (auto-generated if not provided)",
348
+ default=None,
349
+ ),
350
+ ] = None,
351
+ description: Annotated[
352
+ str | None,
353
+ Field(
354
+ description="Description for tag",
355
+ default=None,
356
+ ),
357
+ ] = None,
358
+ ) -> dict[str, Any]:
359
+ """
360
+ Create or update Home Assistant helper entities.
361
+
362
+ Creates new helper if helper_id is omitted, updates existing if helper_id is provided.
363
+ Parameters are validated by Home Assistant - errors return clear messages.
364
+
365
+ QUICK EXAMPLES:
366
+ - ha_config_set_helper("input_boolean", "My Switch", icon="mdi:toggle-switch")
367
+ - ha_config_set_helper("counter", "My Counter", initial=0, step=1)
368
+ - ha_config_set_helper("timer", "Laundry", duration="0:45:00")
369
+ - ha_config_set_helper("zone", "Office", latitude=37.77, longitude=-122.41, radius=100)
370
+ - ha_config_set_helper("schedule", "Work", monday=[{"from": "09:00", "to": "17:00"}])
371
+
372
+ PREFER BUILT-IN HELPERS OVER TEMPLATE SENSORS:
373
+ Before creating a template sensor, check if a built-in helper/integration exists:
374
+ - Use `min_max` integration (type: mean/min/max/sum) instead of template for combining sensors
375
+ - Use `group` instead of template binary sensor for any/all logic
376
+ - Use `counter` instead of template with math for counting
377
+ - Use `input_number` instead of template for storing values
378
+ - Use `schedule` instead of template with weekday checks
379
+
380
+ For detailed parameter info: ha_get_domain_docs("counter"), ha_get_domain_docs("zone"), etc.
381
+ """
382
+ try:
383
+ # Parse JSON list parameters if provided as strings
384
+ try:
385
+ labels = parse_string_list_param(labels, "labels")
386
+ options = parse_string_list_param(options, "options")
387
+ except ValueError as e:
388
+ return {"success": False, "error": f"Invalid list parameter: {e}"}
389
+
390
+ # Determine if this is a create or update based on helper_id
391
+ action = "update" if helper_id else "create"
392
+
393
+ if action == "create":
394
+ if not name:
395
+ return {
396
+ "success": False,
397
+ "error": "name is required for create action",
398
+ }
399
+
400
+ # Build create message based on helper type
401
+ message: dict[str, Any] = {
402
+ "type": f"{helper_type}/create",
403
+ "name": name,
404
+ }
405
+
406
+ # Icon supported by most helpers except person and tag
407
+ if icon and helper_type not in ("person", "tag"):
408
+ message["icon"] = icon
409
+
410
+ # Type-specific parameters
411
+ if helper_type == "input_select":
412
+ if not options:
413
+ return {
414
+ "success": False,
415
+ "error": "options list is required for input_select",
416
+ }
417
+ if not isinstance(options, list) or len(options) == 0:
418
+ return {
419
+ "success": False,
420
+ "error": "options must be a non-empty list for input_select",
421
+ }
422
+ message["options"] = options
423
+ if initial and initial in options:
424
+ message["initial"] = initial
425
+
426
+ elif helper_type == "input_number":
427
+ # Validate min_value/max_value range
428
+ if (
429
+ min_value is not None
430
+ and max_value is not None
431
+ and min_value > max_value
432
+ ):
433
+ return {
434
+ "success": False,
435
+ "error": f"Minimum value ({min_value}) cannot be greater than maximum value ({max_value})",
436
+ "min_value": min_value,
437
+ "max_value": max_value,
438
+ }
439
+
440
+ if min_value is not None:
441
+ message["min"] = min_value
442
+ if max_value is not None:
443
+ message["max"] = max_value
444
+ if step is not None:
445
+ message["step"] = step
446
+ if unit_of_measurement:
447
+ message["unit_of_measurement"] = unit_of_measurement
448
+ if mode in ["box", "slider"]:
449
+ message["mode"] = mode
450
+
451
+ elif helper_type == "input_text":
452
+ if min_value is not None:
453
+ message["min"] = int(min_value)
454
+ if max_value is not None:
455
+ message["max"] = int(max_value)
456
+ if mode in ["text", "password"]:
457
+ message["mode"] = mode
458
+ if initial:
459
+ message["initial"] = initial
460
+
461
+ elif helper_type == "input_boolean":
462
+ if initial is not None:
463
+ initial_str = str(initial).lower()
464
+ message["initial"] = initial_str in [
465
+ "true",
466
+ "on",
467
+ "yes",
468
+ "1",
469
+ ]
470
+
471
+ elif helper_type == "input_datetime":
472
+ # At least one of has_date or has_time must be True
473
+ if has_date is None and has_time is None:
474
+ # Default to both if not specified
475
+ message["has_date"] = True
476
+ message["has_time"] = True
477
+ elif has_date is None:
478
+ message["has_date"] = False
479
+ message["has_time"] = has_time
480
+ elif has_time is None:
481
+ message["has_date"] = has_date
482
+ message["has_time"] = False
483
+ else:
484
+ message["has_date"] = has_date
485
+ message["has_time"] = has_time
486
+
487
+ # Validate that at least one is True
488
+ if not message["has_date"] and not message["has_time"]:
489
+ return {
490
+ "success": False,
491
+ "error": "At least one of has_date or has_time must be True for input_datetime",
492
+ }
493
+
494
+ if initial:
495
+ message["initial"] = initial
496
+
497
+ elif helper_type == "counter":
498
+ # Counter parameters: initial, minimum, maximum, step, restore
499
+ if initial is not None:
500
+ message["initial"] = (
501
+ int(initial) if isinstance(initial, str) else initial
502
+ )
503
+ if min_value is not None:
504
+ message["minimum"] = int(min_value)
505
+ if max_value is not None:
506
+ message["maximum"] = int(max_value)
507
+ if step is not None:
508
+ message["step"] = int(step)
509
+ if restore is not None:
510
+ message["restore"] = restore
511
+
512
+ elif helper_type == "timer":
513
+ # Timer parameters: duration, restore
514
+ if duration:
515
+ message["duration"] = duration
516
+ if restore is not None:
517
+ message["restore"] = restore
518
+
519
+ elif helper_type == "schedule":
520
+ # Schedule parameters: monday-sunday with time ranges
521
+ # Each day is a list of {"from": "HH:MM:SS", "to": "HH:MM:SS"}
522
+ day_params = {
523
+ "monday": monday,
524
+ "tuesday": tuesday,
525
+ "wednesday": wednesday,
526
+ "thursday": thursday,
527
+ "friday": friday,
528
+ "saturday": saturday,
529
+ "sunday": sunday,
530
+ }
531
+ for day_name, day_schedule in day_params.items():
532
+ if day_schedule is not None:
533
+ # Ensure time format has seconds
534
+ formatted_ranges = []
535
+ for time_range in day_schedule:
536
+ formatted_range = {}
537
+ for key in ["from", "to"]:
538
+ if key in time_range:
539
+ time_val = time_range[key]
540
+ # Add seconds if not present
541
+ if time_val.count(":") == 1:
542
+ time_val = f"{time_val}:00"
543
+ formatted_range[key] = time_val
544
+ formatted_ranges.append(formatted_range)
545
+ message[day_name] = formatted_ranges
546
+
547
+ elif helper_type == "zone":
548
+ # Zone parameters - HA validates required fields (latitude, longitude)
549
+ if latitude is not None:
550
+ message["latitude"] = latitude
551
+ if longitude is not None:
552
+ message["longitude"] = longitude
553
+ if radius is not None:
554
+ message["radius"] = radius
555
+ if passive is not None:
556
+ message["passive"] = passive
557
+
558
+ elif helper_type == "person":
559
+ # Person parameters: user_id, device_trackers, picture
560
+ if user_id:
561
+ message["user_id"] = user_id
562
+ if device_trackers:
563
+ message["device_trackers"] = device_trackers
564
+ if picture:
565
+ message["picture"] = picture
566
+
567
+ elif helper_type == "tag":
568
+ # Tag parameters: tag_id, description
569
+ # Note: name goes into entity registry, not tag storage
570
+ if tag_id:
571
+ message["tag_id"] = tag_id
572
+ if description:
573
+ message["description"] = description
574
+
575
+ result = await client.send_websocket_message(message)
576
+
577
+ if result.get("success"):
578
+ helper_data = result.get("result", {})
579
+ entity_id = helper_data.get("entity_id")
580
+
581
+ # Wait for entity to be properly registered before proceeding
582
+ if entity_id:
583
+ logger.debug(f"Waiting for {entity_id} to be registered...")
584
+ # Give the entity a moment to register in the system
585
+ await asyncio.sleep(0.2)
586
+
587
+ # Verify the entity is accessible via state API
588
+ max_verification_attempts = 5
589
+ for attempt in range(max_verification_attempts):
590
+ try:
591
+ state_check = await client.get_state(entity_id)
592
+ if state_check:
593
+ logger.debug(
594
+ f"Entity {entity_id} verified via state API"
595
+ )
596
+ break
597
+ except Exception:
598
+ pass
599
+
600
+ if attempt < max_verification_attempts - 1:
601
+ wait_time = 0.1 * (
602
+ attempt + 1
603
+ ) # 0.1s, 0.2s, 0.3s, 0.4s
604
+ logger.debug(
605
+ f"Entity {entity_id} not yet accessible, waiting {wait_time}s..."
606
+ )
607
+ await asyncio.sleep(wait_time)
608
+
609
+ # Update entity registry if area_id or labels specified
610
+ if (area_id or labels) and entity_id:
611
+ update_message: dict[str, Any] = {
612
+ "type": "config/entity_registry/update",
613
+ "entity_id": entity_id,
614
+ }
615
+ if area_id:
616
+ update_message["area_id"] = area_id
617
+ if labels:
618
+ update_message["labels"] = labels
619
+
620
+ update_result = await client.send_websocket_message(
621
+ update_message
622
+ )
623
+ if update_result.get("success"):
624
+ helper_data["area_id"] = area_id
625
+ helper_data["labels"] = labels
626
+
627
+ return {
628
+ "success": True,
629
+ "action": "create",
630
+ "helper_type": helper_type,
631
+ "helper_data": helper_data,
632
+ "entity_id": entity_id,
633
+ "message": f"Successfully created {helper_type}: {name}",
634
+ }
635
+ else:
636
+ return {
637
+ "success": False,
638
+ "error": f"Failed to create helper: {result.get('error', 'Unknown error')}",
639
+ "helper_type": helper_type,
640
+ "name": name,
641
+ }
642
+
643
+ elif action == "update":
644
+ if not helper_id:
645
+ return {
646
+ "success": False,
647
+ "error": "helper_id is required for update action",
648
+ }
649
+
650
+ # For updates, we primarily use entity registry update
651
+ entity_id = (
652
+ helper_id
653
+ if helper_id.startswith(helper_type)
654
+ else f"{helper_type}.{helper_id}"
655
+ )
656
+
657
+ update_msg: dict[str, Any] = {
658
+ "type": "config/entity_registry/update",
659
+ "entity_id": entity_id,
660
+ }
661
+
662
+ if name:
663
+ update_msg["name"] = name
664
+ if icon:
665
+ update_msg["icon"] = icon
666
+ if area_id:
667
+ update_msg["area_id"] = area_id
668
+ if labels:
669
+ update_msg["labels"] = labels
670
+
671
+ result = await client.send_websocket_message(update_msg)
672
+
673
+ if result.get("success"):
674
+ entity_data = result.get("result", {}).get("entity_entry", {})
675
+ return {
676
+ "success": True,
677
+ "action": "update",
678
+ "helper_type": helper_type,
679
+ "entity_id": entity_id,
680
+ "updated_data": entity_data,
681
+ "message": f"Successfully updated {helper_type}: {entity_id}",
682
+ }
683
+ else:
684
+ return {
685
+ "success": False,
686
+ "error": f"Failed to update helper: {result.get('error', 'Unknown error')}",
687
+ "entity_id": entity_id,
688
+ }
689
+
690
+ # This should never be reached since action is either "create" or "update"
691
+ return {
692
+ "success": False,
693
+ "error": f"Unexpected action: {action}",
694
+ }
695
+
696
+ except Exception as e:
697
+ return {
698
+ "success": False,
699
+ "error": f"Helper management failed: {str(e)}",
700
+ "action": action,
701
+ "helper_type": helper_type,
702
+ "suggestions": [
703
+ "Check Home Assistant connection",
704
+ "Verify helper_id exists for update operations",
705
+ "Ensure required parameters are provided for the helper type",
706
+ ],
707
+ }
708
+
709
+ @mcp.tool(
710
+ annotations={
711
+ "destructiveHint": True,
712
+ "idempotentHint": True,
713
+ "tags": ["helper"],
714
+ "title": "Remove Helper",
715
+ }
716
+ )
717
+ @log_tool_usage
718
+ async def ha_config_remove_helper(
719
+ helper_type: Annotated[
720
+ Literal[
721
+ "input_button",
722
+ "input_boolean",
723
+ "input_select",
724
+ "input_number",
725
+ "input_text",
726
+ "input_datetime",
727
+ "counter",
728
+ "timer",
729
+ "schedule",
730
+ "zone",
731
+ "person",
732
+ "tag",
733
+ ],
734
+ Field(description="Type of helper entity to delete"),
735
+ ],
736
+ helper_id: Annotated[
737
+ str,
738
+ Field(
739
+ description="Helper ID to delete (e.g., 'my_button' or 'input_button.my_button')"
740
+ ),
741
+ ],
742
+ ) -> dict[str, Any]:
743
+ """
744
+ Delete a Home Assistant helper entity.
745
+
746
+ SUPPORTED HELPER TYPES:
747
+ - input_button, input_boolean, input_select, input_number, input_text, input_datetime
748
+ - counter, timer, schedule, zone, person, tag
749
+
750
+ EXAMPLES:
751
+ - Delete button: ha_config_remove_helper("input_button", "my_button")
752
+ - Delete counter: ha_config_remove_helper("counter", "my_counter")
753
+ - Delete timer: ha_config_remove_helper("timer", "my_timer")
754
+ - Delete schedule: ha_config_remove_helper("schedule", "work_hours")
755
+
756
+ **WARNING:** Deleting a helper that is used by automations or scripts may cause those automations/scripts to fail.
757
+ Use ha_search_entities() to verify the helper exists before attempting to delete it.
758
+ """
759
+ try:
760
+ # Convert helper_id to full entity_id if needed
761
+ entity_id = (
762
+ helper_id
763
+ if helper_id.startswith(helper_type)
764
+ else f"{helper_type}.{helper_id}"
765
+ )
766
+
767
+ # Try to get unique_id with retry logic to handle race conditions
768
+ unique_id = None
769
+ registry_result = None
770
+ max_retries = 3
771
+
772
+ for attempt in range(max_retries):
773
+ logger.info(
774
+ f"Getting entity registry for: {entity_id} (attempt {attempt + 1}/{max_retries})"
775
+ )
776
+
777
+ # Check if entity exists via state API first (faster check)
778
+ try:
779
+ state_check = await client.get_state(entity_id)
780
+ if not state_check:
781
+ # Entity doesn't exist in state, wait a bit for registration
782
+ if attempt < max_retries - 1:
783
+ wait_time = 0.5 * (
784
+ 2**attempt
785
+ ) # Exponential backoff: 0.5s, 1s, 2s
786
+ logger.debug(
787
+ f"Entity {entity_id} not found in state, waiting {wait_time}s before retry..."
788
+ )
789
+ await asyncio.sleep(wait_time)
790
+ continue
791
+ except Exception as e:
792
+ logger.debug(f"State check failed for {entity_id}: {e}")
793
+
794
+ # Try registry lookup
795
+ registry_msg: dict[str, Any] = {
796
+ "type": "config/entity_registry/get",
797
+ "entity_id": entity_id,
798
+ }
799
+
800
+ try:
801
+ registry_result = await client.send_websocket_message(registry_msg)
802
+
803
+ if registry_result.get("success"):
804
+ entity_entry = registry_result.get("result", {})
805
+ unique_id = entity_entry.get("unique_id")
806
+ if unique_id:
807
+ logger.info(f"Found unique_id: {unique_id} for {entity_id}")
808
+ break
809
+
810
+ # If registry lookup failed but we haven't exhausted retries, wait and try again
811
+ if attempt < max_retries - 1:
812
+ wait_time = 0.5 * (2**attempt) # Exponential backoff
813
+ logger.debug(
814
+ f"Registry lookup failed for {entity_id}, waiting {wait_time}s before retry..."
815
+ )
816
+ await asyncio.sleep(wait_time)
817
+
818
+ except Exception as e:
819
+ logger.warning(f"Registry lookup attempt {attempt + 1} failed: {e}")
820
+ if attempt < max_retries - 1:
821
+ wait_time = 0.5 * (2**attempt)
822
+ await asyncio.sleep(wait_time)
823
+
824
+ # Fallback strategy 1: Try deletion with helper_id directly if unique_id not found
825
+ if not unique_id:
826
+ logger.info(
827
+ f"Could not find unique_id for {entity_id}, trying direct deletion with helper_id"
828
+ )
829
+
830
+ # Try deleting using helper_id directly (fallback approach)
831
+ delete_msg: dict[str, Any] = {
832
+ "type": f"{helper_type}/delete",
833
+ f"{helper_type}_id": helper_id,
834
+ }
835
+
836
+ logger.info(f"Sending fallback WebSocket delete message: {delete_msg}")
837
+ result = await client.send_websocket_message(delete_msg)
838
+
839
+ if result.get("success"):
840
+ return {
841
+ "success": True,
842
+ "action": "delete",
843
+ "helper_type": helper_type,
844
+ "helper_id": helper_id,
845
+ "entity_id": entity_id,
846
+ "method": "fallback_direct_id",
847
+ "message": f"Successfully deleted {helper_type}: {helper_id} using direct ID (entity: {entity_id})",
848
+ }
849
+
850
+ # Fallback strategy 2: Check if entity was already deleted
851
+ try:
852
+ final_state_check = await client.get_state(entity_id)
853
+ if not final_state_check:
854
+ logger.info(
855
+ f"Entity {entity_id} no longer exists, considering deletion successful"
856
+ )
857
+ return {
858
+ "success": True,
859
+ "action": "delete",
860
+ "helper_type": helper_type,
861
+ "helper_id": helper_id,
862
+ "entity_id": entity_id,
863
+ "method": "already_deleted",
864
+ "message": f"Helper {helper_id} was already deleted or never properly registered",
865
+ }
866
+ except Exception:
867
+ pass
868
+
869
+ # Final fallback failed
870
+ return {
871
+ "success": False,
872
+ "error": f"Helper not found in entity registry after {max_retries} attempts: {registry_result.get('error', 'Unknown error') if registry_result else 'No registry response'}",
873
+ "helper_id": helper_id,
874
+ "entity_id": entity_id,
875
+ "suggestion": "Helper may not be properly registered or was already deleted. Use ha_search_entities() to verify.",
876
+ }
877
+
878
+ # Delete helper using unique_id (correct API from docs)
879
+ delete_message: dict[str, Any] = {
880
+ "type": f"{helper_type}/delete",
881
+ f"{helper_type}_id": unique_id,
882
+ }
883
+
884
+ logger.info(f"Sending WebSocket delete message: {delete_message}")
885
+ result = await client.send_websocket_message(delete_message)
886
+ logger.info(f"WebSocket delete response: {result}")
887
+
888
+ if result.get("success"):
889
+ return {
890
+ "success": True,
891
+ "action": "delete",
892
+ "helper_type": helper_type,
893
+ "helper_id": helper_id,
894
+ "entity_id": entity_id,
895
+ "unique_id": unique_id,
896
+ "method": "standard",
897
+ "message": f"Successfully deleted {helper_type}: {helper_id} (entity: {entity_id})",
898
+ }
899
+ else:
900
+ error_msg = result.get("error", "Unknown error")
901
+ # Handle specific HA error messages
902
+ if isinstance(error_msg, dict):
903
+ error_msg = error_msg.get("message", str(error_msg))
904
+
905
+ return {
906
+ "success": False,
907
+ "error": f"Failed to delete helper: {error_msg}",
908
+ "helper_id": helper_id,
909
+ "entity_id": entity_id,
910
+ "unique_id": unique_id,
911
+ "suggestion": "Make sure the helper exists and is not being used by automations or scripts",
912
+ }
913
+
914
+ except Exception as e:
915
+ return {
916
+ "success": False,
917
+ "error": f"Helper deletion failed: {str(e)}",
918
+ "helper_type": helper_type,
919
+ "helper_id": helper_id,
920
+ "suggestions": [
921
+ "Check Home Assistant connection",
922
+ "Verify helper_id exists using ha_search_entities()",
923
+ "Ensure helper is not being used by automations or scripts",
924
+ ],
925
+ }