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