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,713 @@
1
+ """
2
+ Label management tools for Home Assistant.
3
+
4
+ This module provides tools for listing, creating, updating, and deleting
5
+ Home Assistant labels, as well as managing label assignments for entities.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Annotated, Any, Literal
11
+
12
+ from pydantic import Field
13
+
14
+ from .helpers import log_tool_usage
15
+ from .util_helpers import coerce_bool_param, parse_string_list_param
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
21
+ """Register Home Assistant label management tools."""
22
+
23
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["label"], "title": "Get Label"})
24
+ @log_tool_usage
25
+ async def ha_config_get_label(
26
+ label_id: Annotated[
27
+ str | None,
28
+ Field(
29
+ description="ID of the label to retrieve. If omitted, lists all labels.",
30
+ default=None,
31
+ ),
32
+ ] = None,
33
+ ) -> dict[str, Any]:
34
+ """
35
+ Get label info - list all labels or get a specific one by ID.
36
+
37
+ Without a label_id: Lists all Home Assistant labels with their configurations.
38
+ With a label_id: Returns configuration for that specific label.
39
+
40
+ LABEL PROPERTIES:
41
+ - ID (label_id), Name
42
+ - Color (optional), Icon (optional), Description (optional)
43
+
44
+ EXAMPLES:
45
+ - List all labels: ha_config_get_label()
46
+ - Get specific label: ha_config_get_label("my_label_id")
47
+
48
+ Use ha_config_set_label() to create or update labels.
49
+ Use ha_manage_entity_labels() to manage label assignments for entities.
50
+ """
51
+ try:
52
+ message: dict[str, Any] = {
53
+ "type": "config/label_registry/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 labels: {result.get('error', 'Unknown error')}",
62
+ "label_id": label_id,
63
+ }
64
+
65
+ labels = result.get("result", [])
66
+
67
+ # List mode - return all labels
68
+ if label_id is None:
69
+ return {
70
+ "success": True,
71
+ "count": len(labels),
72
+ "labels": labels,
73
+ "message": f"Found {len(labels)} label(s)",
74
+ }
75
+
76
+ # Get mode - find specific label
77
+ label = next(
78
+ (lbl for lbl in labels if lbl.get("label_id") == label_id), None
79
+ )
80
+
81
+ if label:
82
+ return {
83
+ "success": True,
84
+ "label_id": label_id,
85
+ "label": label,
86
+ "message": f"Found label: {label.get('name', label_id)}",
87
+ }
88
+ else:
89
+ available_ids = [lbl.get("label_id") for lbl in labels[:10]]
90
+ return {
91
+ "success": False,
92
+ "error": f"Label not found: {label_id}",
93
+ "label_id": label_id,
94
+ "available_label_ids": available_ids,
95
+ "suggestion": "Use ha_config_get_label() without label_id to see all labels",
96
+ }
97
+
98
+ except Exception as e:
99
+ logger.error(f"Error getting labels: {e}")
100
+ return {
101
+ "success": False,
102
+ "error": f"Failed to get labels: {str(e)}",
103
+ "label_id": label_id,
104
+ "suggestions": [
105
+ "Check Home Assistant connection",
106
+ "Verify WebSocket connection is active",
107
+ ],
108
+ }
109
+
110
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["label"], "title": "Create or Update Label"})
111
+ @log_tool_usage
112
+ async def ha_config_set_label(
113
+ name: Annotated[str, Field(description="Display name for the label")],
114
+ label_id: Annotated[
115
+ str | None,
116
+ Field(
117
+ description="Label ID for updates. If not provided, creates a new label.",
118
+ default=None,
119
+ ),
120
+ ] = None,
121
+ color: Annotated[
122
+ str | None,
123
+ Field(
124
+ description="Color for the label (e.g., 'red', 'blue', 'green', or hex like '#FF5733')",
125
+ default=None,
126
+ ),
127
+ ] = None,
128
+ icon: Annotated[
129
+ str | None,
130
+ Field(
131
+ description="Material Design Icon (e.g., 'mdi:tag', 'mdi:label')",
132
+ default=None,
133
+ ),
134
+ ] = None,
135
+ description: Annotated[
136
+ str | None,
137
+ Field(
138
+ description="Description of the label's purpose",
139
+ default=None,
140
+ ),
141
+ ] = None,
142
+ ) -> dict[str, Any]:
143
+ """
144
+ Create or update a Home Assistant label.
145
+
146
+ Creates a new label if label_id is not provided, or updates an existing label if label_id is provided.
147
+
148
+ Labels are a flexible tagging system that can be applied to entities,
149
+ devices, and areas for organization and automation purposes.
150
+
151
+ EXAMPLES:
152
+ - Create simple label: ha_config_set_label("Critical")
153
+ - Create colored label: ha_config_set_label("Outdoor", color="green")
154
+ - Create label with icon: ha_config_set_label("Battery Powered", icon="mdi:battery")
155
+ - Create full label: ha_config_set_label("Security", color="red", icon="mdi:shield", description="Security-related devices")
156
+ - Update label: ha_config_set_label("Updated Name", label_id="my_label_id", color="blue")
157
+
158
+ After creating a label, use ha_manage_entity_labels() to assign it to entities.
159
+ """
160
+ try:
161
+ # Determine if this is a create or update
162
+ action = "update" if label_id else "create"
163
+
164
+ message: dict[str, Any] = {
165
+ "type": f"config/label_registry/{action}",
166
+ "name": name,
167
+ }
168
+
169
+ if action == "update":
170
+ message["label_id"] = label_id
171
+ # Note: name is always provided as it's a required parameter
172
+ # The validation of at least one field is satisfied by name being required
173
+
174
+ # Add optional fields only if they are explicitly provided (not None)
175
+ if color is not None:
176
+ message["color"] = color
177
+ if icon is not None:
178
+ message["icon"] = icon
179
+ if description is not None:
180
+ message["description"] = description
181
+
182
+ result = await client.send_websocket_message(message)
183
+
184
+ if result.get("success"):
185
+ label_data = result.get("result", {})
186
+ action_past = "created" if action == "create" else "updated"
187
+ return {
188
+ "success": True,
189
+ "label_id": label_data.get("label_id"),
190
+ "label_data": label_data,
191
+ "message": f"Successfully {action_past} label: {name}",
192
+ }
193
+ else:
194
+ return {
195
+ "success": False,
196
+ "error": f"Failed to {action} label: {result.get('error', 'Unknown error')}",
197
+ "name": name,
198
+ }
199
+
200
+ except Exception as e:
201
+ logger.error(f"Error setting label: {e}")
202
+ return {
203
+ "success": False,
204
+ "error": f"Failed to set label: {str(e)}",
205
+ "name": name,
206
+ "suggestions": [
207
+ "Check Home Assistant connection",
208
+ "Verify the label name is valid",
209
+ "For updates, verify the label_id exists using ha_config_get_label()",
210
+ ],
211
+ }
212
+
213
+ @mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True, "tags": ["label"], "title": "Remove Label"})
214
+ @log_tool_usage
215
+ async def ha_config_remove_label(
216
+ label_id: Annotated[
217
+ str,
218
+ Field(description="ID of the label to delete"),
219
+ ],
220
+ ) -> dict[str, Any]:
221
+ """
222
+ Delete a Home Assistant label.
223
+
224
+ Removes the label from the label registry. This will also remove the label
225
+ from all entities, devices, and areas that have it assigned.
226
+
227
+ EXAMPLES:
228
+ - Delete label: ha_config_remove_label("my_label_id")
229
+
230
+ Use ha_config_get_label() to find label IDs.
231
+
232
+ **WARNING:** Deleting a label will remove it from all assigned entities.
233
+ This action cannot be undone.
234
+ """
235
+ try:
236
+ message: dict[str, Any] = {
237
+ "type": "config/label_registry/delete",
238
+ "label_id": label_id,
239
+ }
240
+
241
+ result = await client.send_websocket_message(message)
242
+
243
+ if result.get("success"):
244
+ return {
245
+ "success": True,
246
+ "label_id": label_id,
247
+ "message": f"Successfully deleted label: {label_id}",
248
+ }
249
+ else:
250
+ return {
251
+ "success": False,
252
+ "error": f"Failed to delete label: {result.get('error', 'Unknown error')}",
253
+ "label_id": label_id,
254
+ }
255
+
256
+ except Exception as e:
257
+ logger.error(f"Error deleting label: {e}")
258
+ return {
259
+ "success": False,
260
+ "error": f"Failed to delete label: {str(e)}",
261
+ "label_id": label_id,
262
+ "suggestions": [
263
+ "Check Home Assistant connection",
264
+ "Verify the label_id exists using ha_config_get_label()",
265
+ ],
266
+ }
267
+
268
+ # Helper functions for label management
269
+ async def _get_entity_labels(entity_id: str) -> list[str]:
270
+ """
271
+ Fetch current labels for an entity from entity registry.
272
+
273
+ Args:
274
+ entity_id: Entity to query
275
+
276
+ Returns:
277
+ List of current label IDs (empty list if none)
278
+
279
+ Raises:
280
+ ValueError: If entity not found or API error
281
+ """
282
+ message: dict[str, Any] = {
283
+ "type": "config/entity_registry/get",
284
+ "entity_id": entity_id,
285
+ }
286
+
287
+ result = await client.send_websocket_message(message)
288
+
289
+ if not result.get("success"):
290
+ error_msg = result.get("error", {}).get("message", "Unknown error")
291
+ raise ValueError(f"Entity not found or API error: {entity_id} - {error_msg}")
292
+
293
+ entity_entry = result.get("result", {})
294
+ return entity_entry.get("labels", [])
295
+
296
+ async def _set_labels_single(entity_id: str, labels: list[str]) -> dict[str, Any]:
297
+ """
298
+ Set labels for a single entity (replaces all existing labels).
299
+
300
+ Args:
301
+ entity_id: Entity to update
302
+ labels: Complete list of label IDs to set
303
+
304
+ Returns:
305
+ Operation result dictionary
306
+ """
307
+ try:
308
+ message: dict[str, Any] = {
309
+ "type": "config/entity_registry/update",
310
+ "entity_id": entity_id,
311
+ "labels": labels,
312
+ }
313
+
314
+ result = await client.send_websocket_message(message)
315
+
316
+ if result.get("success"):
317
+ entity_entry = result.get("result", {}).get("entity_entry", {})
318
+ return {
319
+ "success": True,
320
+ "entity_id": entity_id,
321
+ "labels": labels,
322
+ "entity_data": entity_entry,
323
+ "message": f"Successfully set {len(labels)} label(s) for {entity_id}",
324
+ }
325
+ else:
326
+ return {
327
+ "success": False,
328
+ "entity_id": entity_id,
329
+ "error": f"Failed to set labels: {result.get('error', 'Unknown error')}",
330
+ }
331
+ except Exception as e:
332
+ logger.error(f"Error setting labels for {entity_id}: {e}")
333
+ return {
334
+ "success": False,
335
+ "entity_id": entity_id,
336
+ "error": f"Failed to set labels: {str(e)}",
337
+ }
338
+
339
+ async def _add_labels_single(entity_id: str, labels: list[str]) -> dict[str, Any]:
340
+ """
341
+ Add labels to a single entity (preserves existing labels).
342
+
343
+ Args:
344
+ entity_id: Entity to update
345
+ labels: Label IDs to add
346
+
347
+ Returns:
348
+ Operation result dictionary
349
+ """
350
+ try:
351
+ # Fetch current labels
352
+ current_labels = await _get_entity_labels(entity_id)
353
+
354
+ # Merge and deduplicate
355
+ final_labels = list(set(current_labels + labels))
356
+
357
+ # Update entity registry
358
+ message: dict[str, Any] = {
359
+ "type": "config/entity_registry/update",
360
+ "entity_id": entity_id,
361
+ "labels": final_labels,
362
+ }
363
+
364
+ result = await client.send_websocket_message(message)
365
+
366
+ if result.get("success"):
367
+ entity_entry = result.get("result", {}).get("entity_entry", {})
368
+ return {
369
+ "success": True,
370
+ "entity_id": entity_id,
371
+ "labels": final_labels,
372
+ "entity_data": entity_entry,
373
+ "message": f"Successfully updated labels for {entity_id}. It now has {len(final_labels)} label(s).",
374
+ }
375
+ else:
376
+ return {
377
+ "success": False,
378
+ "entity_id": entity_id,
379
+ "error": f"Failed to add labels: {result.get('error', 'Unknown error')}",
380
+ }
381
+ except ValueError as e:
382
+ # Entity not found
383
+ return {
384
+ "success": False,
385
+ "entity_id": entity_id,
386
+ "error": str(e),
387
+ }
388
+ except Exception as e:
389
+ logger.error(f"Error adding labels to {entity_id}: {e}")
390
+ return {
391
+ "success": False,
392
+ "entity_id": entity_id,
393
+ "error": f"Failed to add labels: {str(e)}",
394
+ }
395
+
396
+ async def _remove_labels_single(entity_id: str, labels: list[str]) -> dict[str, Any]:
397
+ """
398
+ Remove labels from a single entity (preserves remaining labels).
399
+
400
+ Args:
401
+ entity_id: Entity to update
402
+ labels: Label IDs to remove
403
+
404
+ Returns:
405
+ Operation result dictionary
406
+ """
407
+ try:
408
+ # Fetch current labels
409
+ current_labels = await _get_entity_labels(entity_id)
410
+
411
+ # Remove specified labels (convert to set for O(1) lookup)
412
+ final_labels = [lbl for lbl in current_labels if lbl not in set(labels)]
413
+
414
+ # Update entity registry
415
+ message: dict[str, Any] = {
416
+ "type": "config/entity_registry/update",
417
+ "entity_id": entity_id,
418
+ "labels": final_labels,
419
+ }
420
+
421
+ result = await client.send_websocket_message(message)
422
+
423
+ if result.get("success"):
424
+ entity_entry = result.get("result", {}).get("entity_entry", {})
425
+ return {
426
+ "success": True,
427
+ "entity_id": entity_id,
428
+ "labels": final_labels,
429
+ "entity_data": entity_entry,
430
+ "message": f"Successfully updated labels for {entity_id}. It now has {len(final_labels)} label(s).",
431
+ }
432
+ else:
433
+ return {
434
+ "success": False,
435
+ "entity_id": entity_id,
436
+ "error": f"Failed to remove labels: {result.get('error', 'Unknown error')}",
437
+ }
438
+ except ValueError as e:
439
+ # Entity not found
440
+ return {
441
+ "success": False,
442
+ "entity_id": entity_id,
443
+ "error": str(e),
444
+ }
445
+ except Exception as e:
446
+ logger.error(f"Error removing labels from {entity_id}: {e}")
447
+ return {
448
+ "success": False,
449
+ "entity_id": entity_id,
450
+ "error": f"Failed to remove labels: {str(e)}",
451
+ }
452
+
453
+ async def _process_entity_safe(
454
+ entity_id: str, operation: str, labels: list[str]
455
+ ) -> dict[str, Any]:
456
+ """
457
+ Process single entity with full error handling.
458
+
459
+ Errors are isolated - one entity failure doesn't stop others in bulk operations.
460
+
461
+ Args:
462
+ entity_id: Entity to process
463
+ operation: Operation type ('add', 'remove', or 'set')
464
+ labels: Label IDs to apply
465
+
466
+ Returns:
467
+ Operation result dictionary
468
+ """
469
+ try:
470
+ if operation == "add":
471
+ return await _add_labels_single(entity_id, labels)
472
+ elif operation == "remove":
473
+ return await _remove_labels_single(entity_id, labels)
474
+ else: # set
475
+ return await _set_labels_single(entity_id, labels)
476
+ except Exception as e:
477
+ logger.error(f"Error processing {entity_id} with operation '{operation}': {e}")
478
+ return {
479
+ "entity_id": entity_id,
480
+ "success": False,
481
+ "error": f"Failed to {operation} labels: {str(e)}",
482
+ }
483
+
484
+ async def _process_bulk_operation(
485
+ entity_ids: list[str], operation: str, labels: list[str], parallel: bool
486
+ ) -> dict[str, Any]:
487
+ """
488
+ Process multiple entities with error isolation.
489
+
490
+ Args:
491
+ entity_ids: List of entities to process
492
+ operation: Operation type ('add', 'remove', or 'set')
493
+ labels: Label IDs to apply
494
+ parallel: Execute in parallel if True, sequential if False
495
+
496
+ Returns:
497
+ Bulk operation result dictionary
498
+ """
499
+ if parallel:
500
+ # Execute all entities in parallel using asyncio.gather
501
+ tasks = [_process_entity_safe(entity, operation, labels) for entity in entity_ids]
502
+ results = await asyncio.gather(*tasks, return_exceptions=True)
503
+
504
+ # Convert exceptions to error dicts
505
+ results = [
506
+ (
507
+ {
508
+ "entity_id": entity_ids[i],
509
+ "success": False,
510
+ "error": f"Exception: {str(r)}",
511
+ }
512
+ if isinstance(r, Exception)
513
+ else r
514
+ )
515
+ for i, r in enumerate(results)
516
+ ]
517
+ else:
518
+ # Execute sequentially
519
+ results = []
520
+ for entity in entity_ids:
521
+ result = await _process_entity_safe(entity, operation, labels)
522
+ results.append(result)
523
+
524
+ # Count successes/failures
525
+ successful = len([r for r in results if isinstance(r, dict) and r.get("success")])
526
+ failed = len(results) - successful
527
+
528
+ return {
529
+ "mode": "bulk",
530
+ "operation": operation,
531
+ "total_operations": len(entity_ids),
532
+ "successful": successful,
533
+ "failed": failed,
534
+ "execution_mode": "parallel" if parallel else "sequential",
535
+ "results": results,
536
+ }
537
+
538
+ @mcp.tool(
539
+ annotations={
540
+ "destructiveHint": True,
541
+ "tags": ["label", "entity"],
542
+ "title": "Manage Entity Labels",
543
+ }
544
+ )
545
+ @log_tool_usage
546
+ async def ha_manage_entity_labels(
547
+ entity_id: Annotated[
548
+ str | list[str],
549
+ Field(
550
+ description="Entity ID(s) to manage labels for. Single string for one entity, "
551
+ "list of strings for bulk operations (e.g., 'light.bedroom' or ['light.bedroom', 'switch.kitchen'])"
552
+ ),
553
+ ],
554
+ operation: Annotated[
555
+ Literal["add", "remove", "set"],
556
+ Field(
557
+ description="Label operation: 'add' appends labels (preserves existing), "
558
+ "'remove' removes specified labels (preserves others), "
559
+ "'set' replaces all labels"
560
+ ),
561
+ ],
562
+ labels: Annotated[
563
+ str | list[str],
564
+ Field(
565
+ description="Label ID(s) to apply. Can be a single label ID string, "
566
+ "a list of label IDs, or a JSON array string (e.g., '[\"label1\", \"label2\"]')"
567
+ ),
568
+ ],
569
+ parallel: Annotated[
570
+ bool,
571
+ Field(
572
+ description="Execute bulk operations in parallel (default: True). Only applies when entity_id is a list.",
573
+ default=True,
574
+ ),
575
+ ] = True,
576
+ ) -> dict[str, Any]:
577
+ """
578
+ Manage label assignments for entities with add, remove, or set operations.
579
+
580
+ This tool provides three operations for managing entity labels:
581
+ - **add**: Append labels to existing labels (preserves all current labels)
582
+ - **remove**: Remove specific labels (preserves remaining labels)
583
+ - **set**: Replace all labels with provided list (same as old ha_assign_label)
584
+
585
+ Supports both single entity and bulk operations. For bulk, entities can be
586
+ processed in parallel (default) or sequentially.
587
+
588
+ EXAMPLES:
589
+ - Add labels: ha_manage_entity_labels("light.bedroom", "add", ["outdoor", "smart"])
590
+ - Remove label: ha_manage_entity_labels("light.bedroom", "remove", "old_label")
591
+ - Set all labels: ha_manage_entity_labels("light.bedroom", "set", ["new1", "new2"])
592
+ - Clear all labels: ha_manage_entity_labels("light.bedroom", "set", [])
593
+ - Bulk add: ha_manage_entity_labels(["light.bedroom", "light.kitchen"], "add", "evening")
594
+ - Bulk parallel: ha_manage_entity_labels(["light.1", "light.2", "light.3"], "set", ["outdoor"], parallel=True)
595
+
596
+ Use ha_config_get_label() to find available label IDs.
597
+ Use ha_search_entities() to find entity IDs.
598
+
599
+ **OPERATION DETAILS:**
600
+ - add: Fetches current labels, merges with new (2 API calls)
601
+ - remove: Fetches current labels, removes specified (2 API calls)
602
+ - set: Direct replacement (1 API call - fastest)
603
+
604
+ **BULK OPERATIONS:**
605
+ - parallel=True: Process all entities simultaneously (faster, default)
606
+ - parallel=False: Process entities one by one (useful for debugging)
607
+ - Failures are isolated: one entity error doesn't stop others
608
+ """
609
+ try:
610
+ # Validate and parse entity_id parameter
611
+ entity_ids: list[str]
612
+ is_bulk: bool
613
+
614
+ if isinstance(entity_id, str):
615
+ entity_ids = [entity_id]
616
+ is_bulk = False
617
+ elif isinstance(entity_id, list):
618
+ if not entity_id:
619
+ return {
620
+ "success": False,
621
+ "error": "entity_id list cannot be empty",
622
+ "suggestions": ["Provide at least one entity_id"],
623
+ }
624
+ if not all(isinstance(e, str) for e in entity_id):
625
+ return {
626
+ "success": False,
627
+ "error": "All entity_id values must be strings",
628
+ }
629
+ entity_ids = entity_id
630
+ is_bulk = True
631
+ else:
632
+ return {
633
+ "success": False,
634
+ "error": f"entity_id must be string or list of strings, got {type(entity_id).__name__}",
635
+ }
636
+
637
+ # Parse labels parameter with backward compatibility for plain strings
638
+ if isinstance(labels, str):
639
+ # Try JSON parsing first (for JSON array strings)
640
+ try:
641
+ parsed_labels = parse_string_list_param(labels, "labels")
642
+ if parsed_labels is None:
643
+ parsed_labels = []
644
+ except ValueError:
645
+ # Plain string - wrap in list for backward compatibility
646
+ parsed_labels = [labels]
647
+ elif isinstance(labels, list):
648
+ # Validate all items are strings
649
+ if not all(isinstance(item, str) for item in labels):
650
+ return {
651
+ "success": False,
652
+ "error": "All labels must be strings",
653
+ "suggestions": [
654
+ "Provide labels as a string, list of strings, or JSON array",
655
+ "Example: labels='label_id' or labels=['label1', 'label2']",
656
+ ],
657
+ }
658
+ parsed_labels = labels
659
+ elif labels is None:
660
+ parsed_labels = []
661
+ else:
662
+ return {
663
+ "success": False,
664
+ "error": f"labels must be string or list, got {type(labels).__name__}",
665
+ "suggestions": [
666
+ "Provide labels as a string, list of strings, or JSON array",
667
+ "Example: labels='label_id' or labels=['label1', 'label2']",
668
+ ],
669
+ }
670
+
671
+ # Allow empty list for set operation (clears labels)
672
+ if len(parsed_labels) == 0 and operation in ["add", "remove"]:
673
+ # Empty list for add/remove is no-op but valid
674
+ pass
675
+ # Deduplicate labels
676
+ parsed_labels = list(set(parsed_labels))
677
+
678
+ # Coerce parallel parameter
679
+ parallel_bool = coerce_bool_param(parallel, "parallel", default=True)
680
+ if parallel_bool is None:
681
+ parallel_bool = True
682
+
683
+ # Validate operation
684
+ if operation not in ["add", "remove", "set"]:
685
+ return {
686
+ "success": False,
687
+ "error": f"Invalid operation: {operation}",
688
+ "suggestions": ["Use 'add', 'remove', or 'set'"],
689
+ }
690
+
691
+ # Route to appropriate handler
692
+ if is_bulk:
693
+ return await _process_bulk_operation(entity_ids, operation, parsed_labels, parallel_bool)
694
+ else:
695
+ # Single entity operation
696
+ result = await _process_entity_safe(entity_ids[0], operation, parsed_labels)
697
+ # Add mode indicator for single operations
698
+ if result.get("success"):
699
+ result["mode"] = "single"
700
+ result["operation"] = operation
701
+ return result
702
+
703
+ except Exception as e:
704
+ logger.error(f"Error in ha_manage_entity_labels: {e}")
705
+ return {
706
+ "success": False,
707
+ "error": f"Failed to manage labels: {str(e)}",
708
+ "suggestions": [
709
+ "Check Home Assistant connection",
710
+ "Verify entity_id exists using ha_search_entities()",
711
+ "Verify label IDs exist using ha_config_get_label()",
712
+ ],
713
+ }