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,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
|
+
}
|