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,1115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entity Registry and Device Registry management tools for Home Assistant.
|
|
3
|
+
|
|
4
|
+
This module provides tools for:
|
|
5
|
+
- Renaming entities (changing entity_id) with optional voice assistant exposure migration
|
|
6
|
+
- Managing devices (list, get details, update, remove)
|
|
7
|
+
- Convenience wrapper for renaming both entity and device together
|
|
8
|
+
|
|
9
|
+
Important: Device renaming does NOT cascade to entities - they are independent registries.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from .helpers import log_tool_usage
|
|
19
|
+
from .util_helpers import coerce_bool_param, parse_string_list_param
|
|
20
|
+
|
|
21
|
+
# Known voice assistant identifiers
|
|
22
|
+
KNOWN_ASSISTANTS = ["conversation", "cloud.alexa", "cloud.google_assistant"]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_registry_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
28
|
+
"""Register entity registry and device registry management tools."""
|
|
29
|
+
|
|
30
|
+
# Internal helper functions for reuse across tools
|
|
31
|
+
async def _rename_entity_internal(
|
|
32
|
+
entity_id: str,
|
|
33
|
+
new_entity_id: str,
|
|
34
|
+
name: str | None = None,
|
|
35
|
+
icon: str | None = None,
|
|
36
|
+
preserve_voice_exposure: bool = True,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Internal implementation of entity rename with voice exposure migration."""
|
|
39
|
+
try:
|
|
40
|
+
# Validate entity_id format
|
|
41
|
+
entity_pattern = r"^[a-z_]+\.[a-z0-9_]+$"
|
|
42
|
+
if not re.match(entity_pattern, entity_id):
|
|
43
|
+
return {
|
|
44
|
+
"success": False,
|
|
45
|
+
"error": f"Invalid entity_id format: {entity_id}",
|
|
46
|
+
"expected_format": "domain.object_id (lowercase letters, numbers, underscores only)",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if not re.match(entity_pattern, new_entity_id):
|
|
50
|
+
return {
|
|
51
|
+
"success": False,
|
|
52
|
+
"error": f"Invalid new_entity_id format: {new_entity_id}",
|
|
53
|
+
"expected_format": "domain.object_id (lowercase letters, numbers, underscores only)",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Extract and validate domains match
|
|
57
|
+
current_domain = entity_id.split(".")[0]
|
|
58
|
+
new_domain = new_entity_id.split(".")[0]
|
|
59
|
+
|
|
60
|
+
if current_domain != new_domain:
|
|
61
|
+
return {
|
|
62
|
+
"success": False,
|
|
63
|
+
"error": f"Domain mismatch: cannot change from '{current_domain}' to '{new_domain}'",
|
|
64
|
+
"suggestion": f"New entity_id must start with '{current_domain}.'",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Step 1: Get current voice exposure settings BEFORE rename
|
|
68
|
+
old_exposure: dict[str, bool] = {}
|
|
69
|
+
exposure_migrated = False
|
|
70
|
+
if preserve_voice_exposure:
|
|
71
|
+
try:
|
|
72
|
+
exposure_list_msg: dict[str, Any] = {
|
|
73
|
+
"type": "homeassistant/expose_entity/list"
|
|
74
|
+
}
|
|
75
|
+
exposure_result = await client.send_websocket_message(
|
|
76
|
+
exposure_list_msg
|
|
77
|
+
)
|
|
78
|
+
if exposure_result.get("success"):
|
|
79
|
+
exposed_entities = exposure_result.get("result", {}).get(
|
|
80
|
+
"exposed_entities", {}
|
|
81
|
+
)
|
|
82
|
+
old_exposure = exposed_entities.get(entity_id, {})
|
|
83
|
+
if old_exposure:
|
|
84
|
+
logger.info(
|
|
85
|
+
f"Found voice exposure settings for {entity_id}: {old_exposure}"
|
|
86
|
+
)
|
|
87
|
+
except Exception as exp_err:
|
|
88
|
+
logger.warning(
|
|
89
|
+
f"Could not fetch exposure settings: {exp_err}. "
|
|
90
|
+
"Continuing with rename without exposure migration."
|
|
91
|
+
)
|
|
92
|
+
old_exposure = {}
|
|
93
|
+
|
|
94
|
+
# Step 2: Perform the entity rename
|
|
95
|
+
message: dict[str, Any] = {
|
|
96
|
+
"type": "config/entity_registry/update",
|
|
97
|
+
"entity_id": entity_id,
|
|
98
|
+
"new_entity_id": new_entity_id,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if name is not None:
|
|
102
|
+
message["name"] = name
|
|
103
|
+
if icon is not None:
|
|
104
|
+
message["icon"] = icon
|
|
105
|
+
|
|
106
|
+
logger.info(f"Renaming entity {entity_id} to {new_entity_id}")
|
|
107
|
+
result = await client.send_websocket_message(message)
|
|
108
|
+
|
|
109
|
+
if not result.get("success"):
|
|
110
|
+
error = result.get("error", {})
|
|
111
|
+
error_msg = (
|
|
112
|
+
error.get("message", str(error))
|
|
113
|
+
if isinstance(error, dict)
|
|
114
|
+
else str(error)
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
"success": False,
|
|
118
|
+
"error": f"Failed to rename entity: {error_msg}",
|
|
119
|
+
"entity_id": entity_id,
|
|
120
|
+
"suggestions": [
|
|
121
|
+
"Verify the entity exists using ha_search_entities()",
|
|
122
|
+
"Check that the new entity_id doesn't already exist",
|
|
123
|
+
"Ensure the entity has a unique_id (some legacy entities cannot be renamed)",
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
entity_entry = result.get("result", {}).get("entity_entry", {})
|
|
128
|
+
|
|
129
|
+
# Step 3: Migrate voice exposure settings to the new entity_id
|
|
130
|
+
exposure_migration_result: dict[str, Any] = {}
|
|
131
|
+
if preserve_voice_exposure and old_exposure:
|
|
132
|
+
try:
|
|
133
|
+
# Find which assistants the entity was exposed to
|
|
134
|
+
exposed_assistants = [
|
|
135
|
+
asst for asst in KNOWN_ASSISTANTS if old_exposure.get(asst)
|
|
136
|
+
]
|
|
137
|
+
hidden_assistants = [
|
|
138
|
+
asst
|
|
139
|
+
for asst in KNOWN_ASSISTANTS
|
|
140
|
+
if old_exposure.get(asst) is False
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
# Apply exposure to new entity
|
|
144
|
+
if exposed_assistants:
|
|
145
|
+
expose_msg: dict[str, Any] = {
|
|
146
|
+
"type": "homeassistant/expose_entity",
|
|
147
|
+
"assistants": exposed_assistants,
|
|
148
|
+
"entity_ids": [new_entity_id],
|
|
149
|
+
"should_expose": True,
|
|
150
|
+
}
|
|
151
|
+
expose_result = await client.send_websocket_message(expose_msg)
|
|
152
|
+
if expose_result.get("success"):
|
|
153
|
+
exposure_migrated = True
|
|
154
|
+
logger.info(
|
|
155
|
+
f"Migrated exposure to {exposed_assistants} for {new_entity_id}"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
logger.warning(
|
|
159
|
+
f"Failed to migrate exposure settings: {expose_result.get('error')}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Apply hidden settings to new entity
|
|
163
|
+
if hidden_assistants:
|
|
164
|
+
hide_msg: dict[str, Any] = {
|
|
165
|
+
"type": "homeassistant/expose_entity",
|
|
166
|
+
"assistants": hidden_assistants,
|
|
167
|
+
"entity_ids": [new_entity_id],
|
|
168
|
+
"should_expose": False,
|
|
169
|
+
}
|
|
170
|
+
hide_result = await client.send_websocket_message(hide_msg)
|
|
171
|
+
if hide_result.get("success"):
|
|
172
|
+
exposure_migrated = True
|
|
173
|
+
logger.info(
|
|
174
|
+
f"Migrated hidden settings to {hidden_assistants} for {new_entity_id}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
exposure_migration_result = {
|
|
178
|
+
"migrated": exposure_migrated,
|
|
179
|
+
"exposed_to": exposed_assistants,
|
|
180
|
+
"hidden_from": hidden_assistants,
|
|
181
|
+
}
|
|
182
|
+
except Exception as migrate_err:
|
|
183
|
+
logger.warning(f"Error migrating exposure settings: {migrate_err}")
|
|
184
|
+
exposure_migration_result = {
|
|
185
|
+
"migrated": False,
|
|
186
|
+
"error": str(migrate_err),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Build response
|
|
190
|
+
response: dict[str, Any] = {
|
|
191
|
+
"success": True,
|
|
192
|
+
"old_entity_id": entity_id,
|
|
193
|
+
"new_entity_id": new_entity_id,
|
|
194
|
+
"entity_entry": entity_entry,
|
|
195
|
+
"message": f"Successfully renamed entity from {entity_id} to {new_entity_id}",
|
|
196
|
+
"warning": "Remember to update any automations, scripts, or dashboards that reference the old entity_id",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if preserve_voice_exposure:
|
|
200
|
+
if exposure_migration_result:
|
|
201
|
+
response["voice_exposure_migration"] = exposure_migration_result
|
|
202
|
+
elif not old_exposure:
|
|
203
|
+
response["voice_exposure_migration"] = {
|
|
204
|
+
"migrated": False,
|
|
205
|
+
"note": "No custom voice exposure settings found for original entity",
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return response
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"Error renaming entity: {e}")
|
|
212
|
+
return {
|
|
213
|
+
"success": False,
|
|
214
|
+
"error": f"Entity rename failed: {str(e)}",
|
|
215
|
+
"entity_id": entity_id,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async def _update_device_internal(
|
|
219
|
+
device_id: str,
|
|
220
|
+
name: str | None = None,
|
|
221
|
+
area_id: str | None = None,
|
|
222
|
+
disabled_by: str | None = None,
|
|
223
|
+
labels: list[str] | None = None,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Internal implementation of device update."""
|
|
226
|
+
try:
|
|
227
|
+
# Build update message
|
|
228
|
+
message: dict[str, Any] = {
|
|
229
|
+
"type": "config/device_registry/update",
|
|
230
|
+
"device_id": device_id,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
updates_made = []
|
|
234
|
+
|
|
235
|
+
if name is not None:
|
|
236
|
+
message["name_by_user"] = name if name else None
|
|
237
|
+
updates_made.append(f"name='{name}'" if name else "name cleared")
|
|
238
|
+
|
|
239
|
+
if area_id is not None:
|
|
240
|
+
message["area_id"] = area_id if area_id else None
|
|
241
|
+
updates_made.append(
|
|
242
|
+
f"area_id='{area_id}'" if area_id else "area cleared"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if disabled_by is not None:
|
|
246
|
+
message["disabled_by"] = disabled_by if disabled_by else None
|
|
247
|
+
updates_made.append(
|
|
248
|
+
f"disabled_by='{disabled_by}'" if disabled_by else "enabled"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if labels is not None:
|
|
252
|
+
message["labels"] = labels
|
|
253
|
+
updates_made.append(f"labels={labels}")
|
|
254
|
+
|
|
255
|
+
if not updates_made:
|
|
256
|
+
return {
|
|
257
|
+
"success": False,
|
|
258
|
+
"error": "No updates specified",
|
|
259
|
+
"suggestion": "Provide at least one of: name, area_id, disabled_by, or labels",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
logger.info(f"Updating device {device_id}: {', '.join(updates_made)}")
|
|
263
|
+
result = await client.send_websocket_message(message)
|
|
264
|
+
|
|
265
|
+
if result.get("success"):
|
|
266
|
+
device_entry = result.get("result", {})
|
|
267
|
+
return {
|
|
268
|
+
"success": True,
|
|
269
|
+
"device_id": device_id,
|
|
270
|
+
"updates": updates_made,
|
|
271
|
+
"device_entry": {
|
|
272
|
+
"name": device_entry.get("name_by_user")
|
|
273
|
+
or device_entry.get("name"),
|
|
274
|
+
"name_by_user": device_entry.get("name_by_user"),
|
|
275
|
+
"area_id": device_entry.get("area_id"),
|
|
276
|
+
"disabled_by": device_entry.get("disabled_by"),
|
|
277
|
+
"labels": device_entry.get("labels", []),
|
|
278
|
+
},
|
|
279
|
+
"message": f"Device updated: {', '.join(updates_made)}",
|
|
280
|
+
"note": "Remember: Device rename does NOT cascade to entities. Use ha_rename_entity() to rename entities.",
|
|
281
|
+
}
|
|
282
|
+
else:
|
|
283
|
+
error = result.get("error", {})
|
|
284
|
+
error_msg = (
|
|
285
|
+
error.get("message", str(error))
|
|
286
|
+
if isinstance(error, dict)
|
|
287
|
+
else str(error)
|
|
288
|
+
)
|
|
289
|
+
return {
|
|
290
|
+
"success": False,
|
|
291
|
+
"error": f"Failed to update device: {error_msg}",
|
|
292
|
+
"device_id": device_id,
|
|
293
|
+
"suggestions": [
|
|
294
|
+
"Verify the device_id exists using ha_get_device()",
|
|
295
|
+
"Check that area_id exists if specified",
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(f"Error updating device: {e}")
|
|
301
|
+
return {
|
|
302
|
+
"success": False,
|
|
303
|
+
"error": f"Device update failed: {str(e)}",
|
|
304
|
+
"device_id": device_id,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@mcp.tool(annotations={"destructiveHint": True, "title": "Rename Entity"})
|
|
308
|
+
@log_tool_usage
|
|
309
|
+
async def ha_rename_entity(
|
|
310
|
+
entity_id: Annotated[
|
|
311
|
+
str,
|
|
312
|
+
Field(description="Current entity ID to rename (e.g., 'light.old_name')"),
|
|
313
|
+
],
|
|
314
|
+
new_entity_id: Annotated[
|
|
315
|
+
str,
|
|
316
|
+
Field(
|
|
317
|
+
description="New entity ID (e.g., 'light.new_name'). Domain must match the original."
|
|
318
|
+
),
|
|
319
|
+
],
|
|
320
|
+
name: Annotated[
|
|
321
|
+
str | None,
|
|
322
|
+
Field(
|
|
323
|
+
description="Optional: New friendly name for the entity",
|
|
324
|
+
default=None,
|
|
325
|
+
),
|
|
326
|
+
] = None,
|
|
327
|
+
icon: Annotated[
|
|
328
|
+
str | None,
|
|
329
|
+
Field(
|
|
330
|
+
description="Optional: New icon (e.g., 'mdi:lightbulb')",
|
|
331
|
+
default=None,
|
|
332
|
+
),
|
|
333
|
+
] = None,
|
|
334
|
+
preserve_voice_exposure: Annotated[
|
|
335
|
+
bool | str | None,
|
|
336
|
+
Field(
|
|
337
|
+
description=(
|
|
338
|
+
"Migrate voice assistant exposure settings to the new entity_id. "
|
|
339
|
+
"Defaults to True. Set to False to skip exposure migration."
|
|
340
|
+
),
|
|
341
|
+
default=None,
|
|
342
|
+
),
|
|
343
|
+
] = None,
|
|
344
|
+
) -> dict[str, Any]:
|
|
345
|
+
"""
|
|
346
|
+
Rename a Home Assistant entity by changing its entity_id.
|
|
347
|
+
|
|
348
|
+
Changes the entity_id (e.g., light.old_name -> light.new_name).
|
|
349
|
+
The domain must remain the same - you cannot change a light to a switch.
|
|
350
|
+
|
|
351
|
+
VOICE ASSISTANT EXPOSURE:
|
|
352
|
+
By default, this function preserves voice assistant exposure settings
|
|
353
|
+
(Alexa, Google Assistant, Assist) when renaming. The exposure settings
|
|
354
|
+
are stored separately from the entity registry and must be migrated
|
|
355
|
+
manually. Set preserve_voice_exposure=False to skip this migration.
|
|
356
|
+
|
|
357
|
+
IMPORTANT LIMITATIONS:
|
|
358
|
+
- References in automations/scripts/dashboards are NOT automatically updated
|
|
359
|
+
- Entity history is preserved (HA 2022.4+)
|
|
360
|
+
- Some entities cannot be renamed:
|
|
361
|
+
- Entities without unique IDs
|
|
362
|
+
- Entities disabled by integration
|
|
363
|
+
|
|
364
|
+
EXAMPLES:
|
|
365
|
+
- Rename light: ha_rename_entity("light.bedroom_1", "light.master_bedroom")
|
|
366
|
+
- Rename with friendly name: ha_rename_entity("sensor.temp", "sensor.living_room_temp", name="Living Room Temperature")
|
|
367
|
+
- Rename without exposure migration: ha_rename_entity("light.old", "light.new", preserve_voice_exposure=False)
|
|
368
|
+
|
|
369
|
+
NOTE: This is different from renaming a device. Device and entity renaming are independent.
|
|
370
|
+
Renaming a device does NOT rename its entities. See ha_update_device() for device renaming.
|
|
371
|
+
For renaming both entity and device together, use ha_rename_entity_and_device().
|
|
372
|
+
"""
|
|
373
|
+
# Parse preserve_voice_exposure (default True)
|
|
374
|
+
should_preserve_exposure = coerce_bool_param(
|
|
375
|
+
preserve_voice_exposure, "preserve_voice_exposure", default=True
|
|
376
|
+
)
|
|
377
|
+
assert should_preserve_exposure is not None # default=True guarantees non-None
|
|
378
|
+
# Delegate to internal implementation
|
|
379
|
+
return await _rename_entity_internal(
|
|
380
|
+
entity_id=entity_id,
|
|
381
|
+
new_entity_id=new_entity_id,
|
|
382
|
+
name=name,
|
|
383
|
+
icon=icon,
|
|
384
|
+
preserve_voice_exposure=should_preserve_exposure,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@mcp.tool(
|
|
388
|
+
annotations={
|
|
389
|
+
"idempotentHint": True,
|
|
390
|
+
"readOnlyHint": True,
|
|
391
|
+
"tags": ["system", "zigbee"],
|
|
392
|
+
"title": "Get Device",
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
@log_tool_usage
|
|
396
|
+
async def ha_get_device(
|
|
397
|
+
device_id: Annotated[
|
|
398
|
+
str | None,
|
|
399
|
+
Field(
|
|
400
|
+
description="Device ID to retrieve details for. If omitted, lists devices.",
|
|
401
|
+
default=None,
|
|
402
|
+
),
|
|
403
|
+
] = None,
|
|
404
|
+
entity_id: Annotated[
|
|
405
|
+
str | None,
|
|
406
|
+
Field(
|
|
407
|
+
description="Entity ID to find the associated device for (e.g., 'light.living_room')",
|
|
408
|
+
default=None,
|
|
409
|
+
),
|
|
410
|
+
] = None,
|
|
411
|
+
integration: Annotated[
|
|
412
|
+
str | None,
|
|
413
|
+
Field(
|
|
414
|
+
description="Filter devices by integration: 'zha', 'zigbee2mqtt', 'mqtt', 'hue', etc.",
|
|
415
|
+
default=None,
|
|
416
|
+
),
|
|
417
|
+
] = None,
|
|
418
|
+
area_id: Annotated[
|
|
419
|
+
str | None,
|
|
420
|
+
Field(
|
|
421
|
+
description="Filter devices by area ID (e.g., 'living_room')",
|
|
422
|
+
default=None,
|
|
423
|
+
),
|
|
424
|
+
] = None,
|
|
425
|
+
manufacturer: Annotated[
|
|
426
|
+
str | None,
|
|
427
|
+
Field(
|
|
428
|
+
description="Filter devices by manufacturer name (e.g., 'Philips')",
|
|
429
|
+
default=None,
|
|
430
|
+
),
|
|
431
|
+
] = None,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
"""
|
|
434
|
+
Get device information - list all devices or get details for a specific one.
|
|
435
|
+
|
|
436
|
+
Without device_id/entity_id: Lists all devices with optional filters.
|
|
437
|
+
With device_id or entity_id: Returns detailed info for that specific device.
|
|
438
|
+
|
|
439
|
+
**List all devices:**
|
|
440
|
+
- All devices: ha_get_device()
|
|
441
|
+
- By area: ha_get_device(area_id="living_room")
|
|
442
|
+
- By manufacturer: ha_get_device(manufacturer="Philips")
|
|
443
|
+
- By integration: ha_get_device(integration="zigbee2mqtt")
|
|
444
|
+
- Combined filters: ha_get_device(integration="zha", area_id="kitchen")
|
|
445
|
+
|
|
446
|
+
**Single device lookup:**
|
|
447
|
+
- By device_id: ha_get_device(device_id="abc123")
|
|
448
|
+
- By entity_id: ha_get_device(entity_id="light.living_room")
|
|
449
|
+
|
|
450
|
+
**Zigbee automation tips:**
|
|
451
|
+
- ZHA triggers: Use `ieee_address` for zha_event triggers
|
|
452
|
+
- Z2M triggers: Use `friendly_name` for MQTT topics (zigbee2mqtt/{friendly_name}/action)
|
|
453
|
+
|
|
454
|
+
**Returns (list mode):**
|
|
455
|
+
- List of devices with device_id, name, manufacturer, model, area_id
|
|
456
|
+
|
|
457
|
+
**Returns (single device):**
|
|
458
|
+
- Full device details including integration_type, ieee_address, entities
|
|
459
|
+
"""
|
|
460
|
+
try:
|
|
461
|
+
# Get device registry
|
|
462
|
+
list_message: dict[str, Any] = {"type": "config/device_registry/list"}
|
|
463
|
+
list_result = await client.send_websocket_message(list_message)
|
|
464
|
+
|
|
465
|
+
if not list_result.get("success"):
|
|
466
|
+
return {
|
|
467
|
+
"success": False,
|
|
468
|
+
"error": f"Failed to access device registry: {list_result.get('error', 'Unknown error')}",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
all_devices = list_result.get("result", [])
|
|
472
|
+
|
|
473
|
+
# Get entity registry
|
|
474
|
+
entity_message: dict[str, Any] = {"type": "config/entity_registry/list"}
|
|
475
|
+
entity_result = await client.send_websocket_message(entity_message)
|
|
476
|
+
all_entities = (
|
|
477
|
+
entity_result.get("result", []) if entity_result.get("success") else []
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Build entity -> device_id map
|
|
481
|
+
entity_to_device: dict[str, str] = {}
|
|
482
|
+
device_to_entities: dict[str, list[dict[str, Any]]] = {}
|
|
483
|
+
for e in all_entities:
|
|
484
|
+
eid = e.get("entity_id")
|
|
485
|
+
did = e.get("device_id")
|
|
486
|
+
if eid and did:
|
|
487
|
+
entity_to_device[eid] = did
|
|
488
|
+
if did not in device_to_entities:
|
|
489
|
+
device_to_entities[did] = []
|
|
490
|
+
device_to_entities[did].append(
|
|
491
|
+
{
|
|
492
|
+
"entity_id": eid,
|
|
493
|
+
"name": e.get("name") or e.get("original_name"),
|
|
494
|
+
"platform": e.get("platform"),
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# If entity_id provided, find the device_id
|
|
499
|
+
if entity_id and not device_id:
|
|
500
|
+
device_id = entity_to_device.get(entity_id)
|
|
501
|
+
if not device_id:
|
|
502
|
+
return {
|
|
503
|
+
"success": False,
|
|
504
|
+
"error": f"Entity '{entity_id}' not found or has no associated device",
|
|
505
|
+
"suggestion": "Use ha_search_entities() to find valid entity IDs",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# Helper function to extract integration info from a device
|
|
509
|
+
def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
|
|
510
|
+
identifiers = device.get("identifiers", [])
|
|
511
|
+
connections = device.get("connections", [])
|
|
512
|
+
|
|
513
|
+
# Determine integration type and extract IEEE address
|
|
514
|
+
integration_sources = []
|
|
515
|
+
ieee_address = None
|
|
516
|
+
friendly_name = device.get("name_by_user") or device.get("name")
|
|
517
|
+
is_z2m = False
|
|
518
|
+
|
|
519
|
+
for identifier in identifiers:
|
|
520
|
+
if isinstance(identifier, (list, tuple)) and len(identifier) >= 2:
|
|
521
|
+
domain = identifier[0]
|
|
522
|
+
value = str(identifier[1])
|
|
523
|
+
if domain not in integration_sources:
|
|
524
|
+
integration_sources.append(domain)
|
|
525
|
+
|
|
526
|
+
# ZHA: identifier is ["zha", "IEEE_ADDRESS"]
|
|
527
|
+
if domain == "zha":
|
|
528
|
+
ieee_address = value
|
|
529
|
+
|
|
530
|
+
# Z2M: identifier is ["mqtt", "zigbee2mqtt_0xIEEE"]
|
|
531
|
+
if domain == "mqtt" and "zigbee2mqtt" in value.lower():
|
|
532
|
+
is_z2m = True
|
|
533
|
+
# Extract IEEE from "zigbee2mqtt_0x..." or "zigbee2mqtt_bridge_0x..."
|
|
534
|
+
if "_0x" in value:
|
|
535
|
+
ieee_address = "0x" + value.split("_0x")[-1]
|
|
536
|
+
|
|
537
|
+
# Also check connections for IEEE
|
|
538
|
+
for connection in connections:
|
|
539
|
+
if isinstance(connection, (list, tuple)) and len(connection) >= 2:
|
|
540
|
+
if connection[0] == "ieee" and not ieee_address:
|
|
541
|
+
ieee_address = connection[1]
|
|
542
|
+
|
|
543
|
+
# Determine primary integration type
|
|
544
|
+
if "zha" in integration_sources:
|
|
545
|
+
integration_type = "zha"
|
|
546
|
+
elif is_z2m:
|
|
547
|
+
integration_type = "zigbee2mqtt"
|
|
548
|
+
elif "mqtt" in integration_sources:
|
|
549
|
+
integration_type = "mqtt"
|
|
550
|
+
elif integration_sources:
|
|
551
|
+
integration_type = integration_sources[0]
|
|
552
|
+
else:
|
|
553
|
+
integration_type = "unknown"
|
|
554
|
+
|
|
555
|
+
device_info: dict[str, Any] = {
|
|
556
|
+
"device_id": device.get("id"),
|
|
557
|
+
"name": friendly_name,
|
|
558
|
+
"manufacturer": device.get("manufacturer"),
|
|
559
|
+
"model": device.get("model"),
|
|
560
|
+
"sw_version": device.get("sw_version"),
|
|
561
|
+
"area_id": device.get("area_id"),
|
|
562
|
+
"integration_type": integration_type,
|
|
563
|
+
"integration_sources": integration_sources,
|
|
564
|
+
"via_device_id": device.get("via_device_id"),
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# Add Zigbee-specific info
|
|
568
|
+
if ieee_address:
|
|
569
|
+
device_info["ieee_address"] = ieee_address
|
|
570
|
+
|
|
571
|
+
if integration_type == "zigbee2mqtt":
|
|
572
|
+
device_info["friendly_name"] = friendly_name
|
|
573
|
+
device_info["mqtt_topic_hint"] = f"zigbee2mqtt/{friendly_name}/..."
|
|
574
|
+
|
|
575
|
+
if integration_type == "zha" and ieee_address:
|
|
576
|
+
device_info["zha_trigger_hint"] = (
|
|
577
|
+
f"Use ieee '{ieee_address}' for zha_event triggers"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return device_info
|
|
581
|
+
|
|
582
|
+
# Single device lookup mode
|
|
583
|
+
if device_id:
|
|
584
|
+
device = next(
|
|
585
|
+
(d for d in all_devices if d.get("id") == device_id), None
|
|
586
|
+
)
|
|
587
|
+
if not device:
|
|
588
|
+
return {
|
|
589
|
+
"success": False,
|
|
590
|
+
"error": f"Device not found: {device_id}",
|
|
591
|
+
"suggestion": "Use ha_get_device() to find valid device IDs",
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
device_info = get_device_info(device)
|
|
595
|
+
device_info["entities"] = device_to_entities.get(device_id, [])
|
|
596
|
+
|
|
597
|
+
# Add extra fields for single lookup
|
|
598
|
+
device_info["name_by_user"] = device.get("name_by_user")
|
|
599
|
+
device_info["default_name"] = device.get("name")
|
|
600
|
+
device_info["hw_version"] = device.get("hw_version")
|
|
601
|
+
device_info["serial_number"] = device.get("serial_number")
|
|
602
|
+
device_info["disabled_by"] = device.get("disabled_by")
|
|
603
|
+
device_info["labels"] = device.get("labels", [])
|
|
604
|
+
device_info["config_entries"] = device.get("config_entries", [])
|
|
605
|
+
device_info["connections"] = device.get("connections", [])
|
|
606
|
+
device_info["identifiers"] = device.get("identifiers", [])
|
|
607
|
+
|
|
608
|
+
entities = device_info.get("entities", [])
|
|
609
|
+
return {
|
|
610
|
+
"success": True,
|
|
611
|
+
"device": device_info,
|
|
612
|
+
"entities": entities, # Also at top level for backward compatibility
|
|
613
|
+
"entity_count": len(entities),
|
|
614
|
+
"queried_by": "entity_id" if entity_id else "device_id",
|
|
615
|
+
"queried_entity_id": entity_id,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# List mode - filter devices by any combination of filters
|
|
619
|
+
matched_devices = []
|
|
620
|
+
integration_lower = integration.lower() if integration else None
|
|
621
|
+
manufacturer_lower = manufacturer.lower() if manufacturer else None
|
|
622
|
+
|
|
623
|
+
for device in all_devices:
|
|
624
|
+
# Apply area filter
|
|
625
|
+
if area_id and device.get("area_id") != area_id:
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
# Apply manufacturer filter
|
|
629
|
+
if manufacturer_lower:
|
|
630
|
+
device_manufacturer = (device.get("manufacturer") or "").lower()
|
|
631
|
+
if manufacturer_lower not in device_manufacturer:
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
device_info = get_device_info(device)
|
|
635
|
+
|
|
636
|
+
# Apply integration filter if specified
|
|
637
|
+
if integration_lower:
|
|
638
|
+
# Match integration
|
|
639
|
+
if (
|
|
640
|
+
integration_lower == "zigbee2mqtt"
|
|
641
|
+
and device_info["integration_type"] != "zigbee2mqtt"
|
|
642
|
+
):
|
|
643
|
+
continue
|
|
644
|
+
elif (
|
|
645
|
+
integration_lower == "zha"
|
|
646
|
+
and device_info["integration_type"] != "zha"
|
|
647
|
+
):
|
|
648
|
+
continue
|
|
649
|
+
elif (
|
|
650
|
+
integration_lower not in ["zigbee2mqtt", "zha"]
|
|
651
|
+
and integration_lower not in device_info.get("integration_sources", [])
|
|
652
|
+
):
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
device_info["entities"] = device_to_entities.get(
|
|
656
|
+
device.get("id"), []
|
|
657
|
+
)
|
|
658
|
+
matched_devices.append(device_info)
|
|
659
|
+
|
|
660
|
+
# Build result
|
|
661
|
+
result: dict[str, Any] = {
|
|
662
|
+
"success": True,
|
|
663
|
+
"count": len(matched_devices),
|
|
664
|
+
"total_devices": len(all_devices),
|
|
665
|
+
"devices": matched_devices,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
# Add filter info
|
|
669
|
+
filters_applied = []
|
|
670
|
+
if integration:
|
|
671
|
+
result["integration_filter"] = integration
|
|
672
|
+
filters_applied.append(f"integration={integration}")
|
|
673
|
+
if area_id:
|
|
674
|
+
result["area_filter"] = area_id
|
|
675
|
+
filters_applied.append(f"area_id={area_id}")
|
|
676
|
+
if manufacturer:
|
|
677
|
+
result["manufacturer_filter"] = manufacturer
|
|
678
|
+
filters_applied.append(f"manufacturer={manufacturer}")
|
|
679
|
+
|
|
680
|
+
if filters_applied:
|
|
681
|
+
result["filters"] = filters_applied
|
|
682
|
+
|
|
683
|
+
# Find bridge device for Z2M
|
|
684
|
+
if integration_lower == "zigbee2mqtt":
|
|
685
|
+
bridge_info = None
|
|
686
|
+
for d in matched_devices:
|
|
687
|
+
if (
|
|
688
|
+
d.get("via_device_id") is None
|
|
689
|
+
and "bridge" in (d.get("name") or "").lower()
|
|
690
|
+
):
|
|
691
|
+
bridge_info = {
|
|
692
|
+
"device_id": d.get("device_id"),
|
|
693
|
+
"name": d.get("name"),
|
|
694
|
+
"ieee_address": d.get("ieee_address"),
|
|
695
|
+
}
|
|
696
|
+
break
|
|
697
|
+
if bridge_info:
|
|
698
|
+
result["bridge"] = bridge_info
|
|
699
|
+
result["usage_hint"] = (
|
|
700
|
+
"Use 'friendly_name' for MQTT topics: zigbee2mqtt/{friendly_name}/action"
|
|
701
|
+
)
|
|
702
|
+
elif integration_lower == "zha":
|
|
703
|
+
result["usage_hint"] = (
|
|
704
|
+
"Use 'ieee_address' for zha_event triggers in automations"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
return result
|
|
708
|
+
|
|
709
|
+
except Exception as e:
|
|
710
|
+
logger.error(f"Error getting device: {e}")
|
|
711
|
+
return {
|
|
712
|
+
"success": False,
|
|
713
|
+
"error": f"Failed to get device: {str(e)}",
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
@mcp.tool(
|
|
717
|
+
annotations={
|
|
718
|
+
"destructiveHint": True,
|
|
719
|
+
"tags": ["system"],
|
|
720
|
+
"title": "Update Device",
|
|
721
|
+
}
|
|
722
|
+
)
|
|
723
|
+
@log_tool_usage
|
|
724
|
+
async def ha_update_device(
|
|
725
|
+
device_id: Annotated[
|
|
726
|
+
str,
|
|
727
|
+
Field(description="Device ID to update"),
|
|
728
|
+
],
|
|
729
|
+
name: Annotated[
|
|
730
|
+
str | None,
|
|
731
|
+
Field(
|
|
732
|
+
description="New display name for the device (sets name_by_user)",
|
|
733
|
+
default=None,
|
|
734
|
+
),
|
|
735
|
+
] = None,
|
|
736
|
+
area_id: Annotated[
|
|
737
|
+
str | None,
|
|
738
|
+
Field(
|
|
739
|
+
description="Area/room ID to assign the device to. Use empty string '' to unassign.",
|
|
740
|
+
default=None,
|
|
741
|
+
),
|
|
742
|
+
] = None,
|
|
743
|
+
disabled_by: Annotated[
|
|
744
|
+
str | None,
|
|
745
|
+
Field(
|
|
746
|
+
description="Set to 'user' to disable, or None/empty string to enable",
|
|
747
|
+
default=None,
|
|
748
|
+
),
|
|
749
|
+
] = None,
|
|
750
|
+
labels: Annotated[
|
|
751
|
+
str | list[str] | None,
|
|
752
|
+
Field(
|
|
753
|
+
description="Labels to assign to the device (replaces existing labels)",
|
|
754
|
+
default=None,
|
|
755
|
+
),
|
|
756
|
+
] = None,
|
|
757
|
+
) -> dict[str, Any]:
|
|
758
|
+
"""
|
|
759
|
+
Update device properties such as name, area, disabled state, or labels.
|
|
760
|
+
|
|
761
|
+
IMPORTANT: Renaming a device does NOT rename its entities!
|
|
762
|
+
Device and entity names are independent. To rename entities, use ha_rename_entity().
|
|
763
|
+
|
|
764
|
+
Common workflow for full rename:
|
|
765
|
+
1. ha_update_device(device_id="abc", name="Living Room Sensor") # Rename device
|
|
766
|
+
2. ha_rename_entity(entity_id="sensor.old", new_entity_id="sensor.living_room") # Rename entities separately
|
|
767
|
+
|
|
768
|
+
PARAMETERS:
|
|
769
|
+
- name: Sets the user-defined display name (name_by_user)
|
|
770
|
+
- area_id: Assigns device to an area/room. Use '' to remove from area.
|
|
771
|
+
- disabled_by: Set to 'user' to disable, or empty to enable
|
|
772
|
+
- labels: List of labels (replaces existing labels)
|
|
773
|
+
|
|
774
|
+
EXAMPLES:
|
|
775
|
+
- Rename device: ha_update_device("abc123", name="Living Room Hub")
|
|
776
|
+
- Move to area: ha_update_device("abc123", area_id="living_room")
|
|
777
|
+
- Disable device: ha_update_device("abc123", disabled_by="user")
|
|
778
|
+
- Enable device: ha_update_device("abc123", disabled_by="")
|
|
779
|
+
- Add labels: ha_update_device("abc123", labels=["important", "sensor"])
|
|
780
|
+
"""
|
|
781
|
+
# Parse labels if provided as string
|
|
782
|
+
parsed_labels = None
|
|
783
|
+
if labels is not None:
|
|
784
|
+
try:
|
|
785
|
+
parsed_labels = parse_string_list_param(labels, "labels")
|
|
786
|
+
except ValueError as e:
|
|
787
|
+
return {"success": False, "error": f"Invalid labels parameter: {e}"}
|
|
788
|
+
|
|
789
|
+
# Delegate to internal implementation
|
|
790
|
+
return await _update_device_internal(
|
|
791
|
+
device_id=device_id,
|
|
792
|
+
name=name,
|
|
793
|
+
area_id=area_id,
|
|
794
|
+
disabled_by=disabled_by,
|
|
795
|
+
labels=parsed_labels,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
@mcp.tool(
|
|
799
|
+
annotations={
|
|
800
|
+
"destructiveHint": True,
|
|
801
|
+
"idempotentHint": True,
|
|
802
|
+
"tags": ["system"],
|
|
803
|
+
"title": "Remove Device",
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
@log_tool_usage
|
|
807
|
+
async def ha_remove_device(
|
|
808
|
+
device_id: Annotated[
|
|
809
|
+
str,
|
|
810
|
+
Field(description="Device ID to remove from the registry"),
|
|
811
|
+
],
|
|
812
|
+
) -> dict[str, Any]:
|
|
813
|
+
"""
|
|
814
|
+
Remove an orphaned device from the Home Assistant device registry.
|
|
815
|
+
|
|
816
|
+
WARNING: This removes the device entry from the registry.
|
|
817
|
+
- Use only for orphaned devices that are no longer connected
|
|
818
|
+
- Active devices will typically be re-added by their integration
|
|
819
|
+
- Associated entities may also be removed
|
|
820
|
+
|
|
821
|
+
This uses the config entry removal which is the safe way to remove devices.
|
|
822
|
+
If the device has multiple config entries, they must all be removed.
|
|
823
|
+
|
|
824
|
+
EXAMPLES:
|
|
825
|
+
- Remove orphaned device: ha_remove_device("abc123def456")
|
|
826
|
+
|
|
827
|
+
NOTE: For most use cases, consider disabling the device instead:
|
|
828
|
+
ha_update_device(device_id="abc123", disabled_by="user")
|
|
829
|
+
"""
|
|
830
|
+
try:
|
|
831
|
+
# First, get device details to find config entries
|
|
832
|
+
list_message: dict[str, Any] = {"type": "config/device_registry/list"}
|
|
833
|
+
list_result = await client.send_websocket_message(list_message)
|
|
834
|
+
|
|
835
|
+
if not list_result.get("success"):
|
|
836
|
+
return {
|
|
837
|
+
"success": False,
|
|
838
|
+
"error": f"Failed to access device registry: {list_result.get('error', 'Unknown error')}",
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
devices = list_result.get("result", [])
|
|
842
|
+
device = next((d for d in devices if d.get("id") == device_id), None)
|
|
843
|
+
|
|
844
|
+
if not device:
|
|
845
|
+
return {
|
|
846
|
+
"success": False,
|
|
847
|
+
"error": f"Device not found: {device_id}",
|
|
848
|
+
"suggestion": "Use ha_get_device() to find valid device IDs",
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
config_entries = device.get("config_entries", [])
|
|
852
|
+
|
|
853
|
+
if not config_entries:
|
|
854
|
+
return {
|
|
855
|
+
"success": False,
|
|
856
|
+
"error": "Device has no config entries - cannot be removed via this method",
|
|
857
|
+
"device_id": device_id,
|
|
858
|
+
"device_name": device.get("name_by_user") or device.get("name"),
|
|
859
|
+
"suggestion": "This device may be managed by an integration directly. Try disabling it instead.",
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
# Remove device from each config entry
|
|
863
|
+
removal_results = []
|
|
864
|
+
for config_entry_id in config_entries:
|
|
865
|
+
remove_message: dict[str, Any] = {
|
|
866
|
+
"type": "config/device_registry/remove_config_entry",
|
|
867
|
+
"device_id": device_id,
|
|
868
|
+
"config_entry_id": config_entry_id,
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
remove_result = await client.send_websocket_message(remove_message)
|
|
872
|
+
removal_results.append(
|
|
873
|
+
{
|
|
874
|
+
"config_entry_id": config_entry_id,
|
|
875
|
+
"success": remove_result.get("success", False),
|
|
876
|
+
"error": (
|
|
877
|
+
remove_result.get("error")
|
|
878
|
+
if not remove_result.get("success")
|
|
879
|
+
else None
|
|
880
|
+
),
|
|
881
|
+
}
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# Check if all removals succeeded
|
|
885
|
+
all_succeeded = all(r["success"] for r in removal_results)
|
|
886
|
+
any_succeeded = any(r["success"] for r in removal_results)
|
|
887
|
+
|
|
888
|
+
if all_succeeded:
|
|
889
|
+
return {
|
|
890
|
+
"success": True,
|
|
891
|
+
"device_id": device_id,
|
|
892
|
+
"device_name": device.get("name_by_user") or device.get("name"),
|
|
893
|
+
"config_entries_removed": len(config_entries),
|
|
894
|
+
"message": f"Successfully removed device from {len(config_entries)} config entry/entries",
|
|
895
|
+
}
|
|
896
|
+
elif any_succeeded:
|
|
897
|
+
return {
|
|
898
|
+
"success": True,
|
|
899
|
+
"partial": True,
|
|
900
|
+
"device_id": device_id,
|
|
901
|
+
"device_name": device.get("name_by_user") or device.get("name"),
|
|
902
|
+
"removal_results": removal_results,
|
|
903
|
+
"message": "Device partially removed - some config entries could not be removed",
|
|
904
|
+
}
|
|
905
|
+
else:
|
|
906
|
+
return {
|
|
907
|
+
"success": False,
|
|
908
|
+
"error": "Failed to remove device from any config entries",
|
|
909
|
+
"device_id": device_id,
|
|
910
|
+
"removal_results": removal_results,
|
|
911
|
+
"suggestion": "Device may be actively managed by its integration. Try disabling it instead.",
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
except Exception as e:
|
|
915
|
+
logger.error(f"Error removing device: {e}")
|
|
916
|
+
return {
|
|
917
|
+
"success": False,
|
|
918
|
+
"error": f"Device removal failed: {str(e)}",
|
|
919
|
+
"device_id": device_id,
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
@mcp.tool(
|
|
923
|
+
annotations={"destructiveHint": True, "title": "Rename Entity and Device"}
|
|
924
|
+
)
|
|
925
|
+
@log_tool_usage
|
|
926
|
+
async def ha_rename_entity_and_device(
|
|
927
|
+
entity_id: Annotated[
|
|
928
|
+
str,
|
|
929
|
+
Field(
|
|
930
|
+
description="Entity ID to rename (e.g., 'light.bedroom_lamp'). Used to find associated device."
|
|
931
|
+
),
|
|
932
|
+
],
|
|
933
|
+
new_entity_id: Annotated[
|
|
934
|
+
str,
|
|
935
|
+
Field(
|
|
936
|
+
description="New entity ID (e.g., 'light.master_bedroom_lamp'). Domain must match."
|
|
937
|
+
),
|
|
938
|
+
],
|
|
939
|
+
new_device_name: Annotated[
|
|
940
|
+
str | None,
|
|
941
|
+
Field(
|
|
942
|
+
description="New display name for the device. If not provided, device name is not changed.",
|
|
943
|
+
default=None,
|
|
944
|
+
),
|
|
945
|
+
] = None,
|
|
946
|
+
new_entity_name: Annotated[
|
|
947
|
+
str | None,
|
|
948
|
+
Field(
|
|
949
|
+
description="New friendly name for the entity. If not provided, entity name is not changed.",
|
|
950
|
+
default=None,
|
|
951
|
+
),
|
|
952
|
+
] = None,
|
|
953
|
+
preserve_voice_exposure: Annotated[
|
|
954
|
+
bool | str | None,
|
|
955
|
+
Field(
|
|
956
|
+
description=(
|
|
957
|
+
"Migrate voice assistant exposure settings to the new entity_id. "
|
|
958
|
+
"Defaults to True."
|
|
959
|
+
),
|
|
960
|
+
default=None,
|
|
961
|
+
),
|
|
962
|
+
] = None,
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
"""
|
|
965
|
+
Convenience tool to rename both an entity and its associated device in one operation.
|
|
966
|
+
|
|
967
|
+
This tool:
|
|
968
|
+
1. Finds the device associated with the entity
|
|
969
|
+
2. Renames the entity (changing entity_id)
|
|
970
|
+
3. Renames the device (changing display name)
|
|
971
|
+
4. Preserves voice assistant exposure settings (by default)
|
|
972
|
+
|
|
973
|
+
WHEN TO USE:
|
|
974
|
+
- When you want to rename a device and its primary entity together
|
|
975
|
+
- For smart home devices where device and entity should have matching names
|
|
976
|
+
|
|
977
|
+
IMPORTANT:
|
|
978
|
+
- If the entity has multiple associated entities (e.g., a smart bulb with brightness, color, etc.),
|
|
979
|
+
only the specified entity is renamed. Other entities retain their original IDs.
|
|
980
|
+
- If the entity has no associated device, only the entity is renamed.
|
|
981
|
+
|
|
982
|
+
EXAMPLES:
|
|
983
|
+
- Rename lamp: ha_rename_entity_and_device("light.bedroom_1", "light.master_bedroom", "Master Bedroom Lamp")
|
|
984
|
+
- Rename only entity_id: ha_rename_entity_and_device("sensor.temp", "sensor.living_room_temp")
|
|
985
|
+
|
|
986
|
+
See also:
|
|
987
|
+
- ha_rename_entity() - Rename only the entity
|
|
988
|
+
- ha_update_device() - Update only the device
|
|
989
|
+
"""
|
|
990
|
+
try:
|
|
991
|
+
# Parse preserve_voice_exposure (default True)
|
|
992
|
+
should_preserve_exposure = coerce_bool_param(
|
|
993
|
+
preserve_voice_exposure, "preserve_voice_exposure", default=True
|
|
994
|
+
)
|
|
995
|
+
assert should_preserve_exposure is not None # default=True guarantees non-None
|
|
996
|
+
|
|
997
|
+
results: dict[str, Any] = {
|
|
998
|
+
"entity_rename": None,
|
|
999
|
+
"device_rename": None,
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
# Step 1: Find the device associated with this entity
|
|
1003
|
+
entity_registry_msg: dict[str, Any] = {
|
|
1004
|
+
"type": "config/entity_registry/list"
|
|
1005
|
+
}
|
|
1006
|
+
entity_registry_result = await client.send_websocket_message(
|
|
1007
|
+
entity_registry_msg
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
device_id = None
|
|
1011
|
+
if entity_registry_result.get("success"):
|
|
1012
|
+
entities = entity_registry_result.get("result", [])
|
|
1013
|
+
for ent in entities:
|
|
1014
|
+
if ent.get("entity_id") == entity_id:
|
|
1015
|
+
device_id = ent.get("device_id")
|
|
1016
|
+
break
|
|
1017
|
+
|
|
1018
|
+
# Step 2: Rename the entity using internal helper (handles voice exposure migration)
|
|
1019
|
+
entity_rename_result = await _rename_entity_internal(
|
|
1020
|
+
entity_id=entity_id,
|
|
1021
|
+
new_entity_id=new_entity_id,
|
|
1022
|
+
name=new_entity_name,
|
|
1023
|
+
preserve_voice_exposure=should_preserve_exposure,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
results["entity_rename"] = entity_rename_result
|
|
1027
|
+
|
|
1028
|
+
if not entity_rename_result.get("success"):
|
|
1029
|
+
return {
|
|
1030
|
+
"success": False,
|
|
1031
|
+
"error": f"Entity rename failed: {entity_rename_result.get('error')}",
|
|
1032
|
+
"results": results,
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
# Step 3: Rename the device if we found one and a new name was provided
|
|
1036
|
+
if device_id and new_device_name:
|
|
1037
|
+
device_rename_result = await _update_device_internal(
|
|
1038
|
+
device_id=device_id,
|
|
1039
|
+
name=new_device_name,
|
|
1040
|
+
)
|
|
1041
|
+
results["device_rename"] = device_rename_result
|
|
1042
|
+
|
|
1043
|
+
if not device_rename_result.get("success"):
|
|
1044
|
+
# Entity was renamed but device rename failed - report partial success
|
|
1045
|
+
return {
|
|
1046
|
+
"success": True,
|
|
1047
|
+
"partial": True,
|
|
1048
|
+
"message": "Entity renamed successfully but device rename failed",
|
|
1049
|
+
"old_entity_id": entity_id,
|
|
1050
|
+
"new_entity_id": new_entity_id,
|
|
1051
|
+
"device_id": device_id,
|
|
1052
|
+
"results": results,
|
|
1053
|
+
"warning": f"Device rename failed: {device_rename_result.get('error')}",
|
|
1054
|
+
}
|
|
1055
|
+
elif device_id and not new_device_name:
|
|
1056
|
+
results["device_rename"] = {
|
|
1057
|
+
"skipped": True,
|
|
1058
|
+
"reason": "No new_device_name provided",
|
|
1059
|
+
"device_id": device_id,
|
|
1060
|
+
}
|
|
1061
|
+
elif not device_id:
|
|
1062
|
+
results["device_rename"] = {
|
|
1063
|
+
"skipped": True,
|
|
1064
|
+
"reason": "Entity has no associated device",
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
# Build success response
|
|
1068
|
+
response: dict[str, Any] = {
|
|
1069
|
+
"success": True,
|
|
1070
|
+
"old_entity_id": entity_id,
|
|
1071
|
+
"new_entity_id": new_entity_id,
|
|
1072
|
+
"device_id": device_id,
|
|
1073
|
+
"results": results,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (
|
|
1077
|
+
device_id
|
|
1078
|
+
and new_device_name
|
|
1079
|
+
and results["device_rename"].get("success")
|
|
1080
|
+
):
|
|
1081
|
+
response["message"] = (
|
|
1082
|
+
f"Successfully renamed entity ({entity_id} -> {new_entity_id}) "
|
|
1083
|
+
f"and device ({new_device_name})"
|
|
1084
|
+
)
|
|
1085
|
+
elif device_id:
|
|
1086
|
+
response["message"] = (
|
|
1087
|
+
f"Successfully renamed entity ({entity_id} -> {new_entity_id}). "
|
|
1088
|
+
f"Device name was not changed."
|
|
1089
|
+
)
|
|
1090
|
+
else:
|
|
1091
|
+
response["message"] = (
|
|
1092
|
+
f"Successfully renamed entity ({entity_id} -> {new_entity_id}). "
|
|
1093
|
+
f"No associated device found."
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
# Include voice exposure migration info if available
|
|
1097
|
+
if entity_rename_result.get("voice_exposure_migration"):
|
|
1098
|
+
response["voice_exposure_migration"] = entity_rename_result[
|
|
1099
|
+
"voice_exposure_migration"
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1102
|
+
response["warning"] = (
|
|
1103
|
+
"Remember to update any automations, scripts, or dashboards "
|
|
1104
|
+
"that reference the old entity_id"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
return response
|
|
1108
|
+
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
logger.error(f"Error in rename entity and device: {e}")
|
|
1111
|
+
return {
|
|
1112
|
+
"success": False,
|
|
1113
|
+
"error": f"Rename failed: {str(e)}",
|
|
1114
|
+
"entity_id": entity_id,
|
|
1115
|
+
}
|