Skip to content

Commit 3438997

Browse files
Danm72claude
andauthored
feat(entity): add ha_update_entity tool for entity registry updates (#469)
* feat(entity): add ha_update_entity tool for entity registry updates Add new tool to update entity properties in Home Assistant entity registry: - area_id: assign/clear entity area assignment - name: custom display name - icon: custom icon - disabled_by: enable/disable entity - hidden_by: show/hide from UI - aliases: voice assistant aliases - labels: organization labels Uses config/entity_registry/update WebSocket API. Includes 7 E2E tests covering all functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(entity): apply PR review feedback for ha_set_entity Changes based on PR #469 review: - Rename ha_update_entity → ha_set_entity for consistency with other tools (ha_config_set_*, ha_set_entity_enabled) - Replace disabled_by/hidden_by string params with cleaner boolean enabled/hidden params: - enabled=True → disabled_by=None (enable) - enabled=False → disabled_by="user" (disable) - hidden=True → hidden_by="user" (hide) - hidden=False → hidden_by=None (show) - Remove labels param (use ha_manage_entity_labels() for full add/remove/set functionality instead) Test updates: - Rename test methods and update tool calls - Remove labels test code - Use set comparison for aliases assertions - Move parse_mcp_result import to top of file Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(entity): remove ha_set_entity_enabled in favor of ha_set_entity ha_set_entity now handles enable/disable via its `enabled` parameter, making the standalone ha_set_entity_enabled tool redundant. Removes: - ha_set_entity_enabled tool from tools_entities.py - Associated tests (test_set_entity_enabled_cycle, test_set_entity_enabled_string_bool, test_set_entity_enabled_nonexistent) The enable/disable functionality is still fully tested via: - test_set_entity_enabled (tests enabled param on ha_set_entity) - test_set_entity_hidden (tests hidden param on ha_set_entity) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(entity): add ha_get_entity tool for reading entity registry values Adds the complementary read capability to ha_set_entity, allowing users to: - Query entity registry metadata (area_id, name, icon, aliases, labels) - Check enabled/hidden state with both raw API values and translated booleans - Query single or multiple entities in one call - Handle partial failures gracefully with errors array Addresses reviewer feedback on PR #469 that write capability needs matching read. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7263bda commit 3438997

2 files changed

Lines changed: 825 additions & 45 deletions

File tree

src/ha_mcp/tools/tools_entities.py

Lines changed: 334 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pydantic import Field
1212

1313
from .helpers import exception_to_structured_error, log_tool_usage
14-
from .util_helpers import coerce_bool_param
14+
from .util_helpers import coerce_bool_param, parse_string_list_param
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -24,40 +24,164 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
2424
"destructiveHint": True,
2525
"idempotentHint": True,
2626
"tags": ["entity"],
27-
"title": "Set Entity Enabled",
27+
"title": "Set Entity",
2828
}
2929
)
3030
@log_tool_usage
31-
async def ha_set_entity_enabled(
31+
async def ha_set_entity(
3232
entity_id: Annotated[
33-
str, Field(description="Entity ID (e.g., 'sensor.temperature')")
33+
str, Field(description="Entity ID to update (e.g., 'sensor.temperature')")
3434
],
35+
area_id: Annotated[
36+
str | None,
37+
Field(
38+
description="Area/room ID to assign the entity to. Use empty string '' to unassign from current area.",
39+
default=None,
40+
),
41+
] = None,
42+
name: Annotated[
43+
str | None,
44+
Field(
45+
description="Display name for the entity. Use empty string '' to remove custom name and revert to default.",
46+
default=None,
47+
),
48+
] = None,
49+
icon: Annotated[
50+
str | None,
51+
Field(
52+
description="Icon for the entity (e.g., 'mdi:thermometer'). Use empty string '' to remove custom icon.",
53+
default=None,
54+
),
55+
] = None,
3556
enabled: Annotated[
36-
bool | str, Field(description="True to enable, False to disable")
37-
],
57+
bool | str | None,
58+
Field(
59+
description="True to enable the entity, False to disable it.",
60+
default=None,
61+
),
62+
] = None,
63+
hidden: Annotated[
64+
bool | str | None,
65+
Field(
66+
description="True to hide the entity from UI, False to show it.",
67+
default=None,
68+
),
69+
] = None,
70+
aliases: Annotated[
71+
str | list[str] | None,
72+
Field(
73+
description="List of voice assistant aliases for the entity (replaces existing aliases).",
74+
default=None,
75+
),
76+
] = None,
3877
) -> dict[str, Any]:
39-
"""Enable/disable entity. Disabled entities don't appear in UI.
78+
"""Update entity properties in the entity registry.
79+
80+
Allows modifying entity metadata such as area assignment, display name,
81+
icon, enabled/disabled state, visibility, and aliases.
4082
4183
Use ha_search_entities() or ha_get_device() to find entity IDs.
84+
Use ha_manage_entity_labels() to manage entity labels.
85+
86+
PARAMETERS:
87+
- area_id: Assigns entity to an area/room. Use '' to remove from area.
88+
- name: Custom display name. Use '' to revert to default name.
89+
- icon: Custom icon (e.g., 'mdi:lightbulb'). Use '' to revert to default.
90+
- enabled: True to enable, False to disable.
91+
- hidden: True to hide from UI, False to show.
92+
- aliases: Voice assistant aliases (e.g., ["living room light", "main light"]).
93+
94+
EXAMPLES:
95+
- Assign to area: ha_set_entity("sensor.temp", area_id="living_room")
96+
- Rename: ha_set_entity("sensor.temp", name="Living Room Temperature")
97+
- Change icon: ha_set_entity("sensor.temp", icon="mdi:thermometer")
98+
- Disable: ha_set_entity("sensor.temp", enabled=False)
99+
- Enable: ha_set_entity("sensor.temp", enabled=True)
100+
- Hide: ha_set_entity("sensor.temp", hidden=True)
101+
- Show: ha_set_entity("sensor.temp", hidden=False)
102+
- Set aliases: ha_set_entity("light.lamp", aliases=["bedroom light", "lamp"])
103+
- Clear area: ha_set_entity("sensor.temp", area_id="")
104+
105+
NOTE: To rename an entity_id (e.g., sensor.old -> sensor.new), use ha_rename_entity() instead.
42106
"""
43107
try:
44-
enabled_bool = coerce_bool_param(enabled, "enabled")
108+
# Parse list parameters if provided as strings
109+
parsed_aliases = None
110+
if aliases is not None:
111+
try:
112+
parsed_aliases = parse_string_list_param(aliases, "aliases")
113+
except ValueError as e:
114+
return {"success": False, "error": f"Invalid aliases parameter: {e}"}
45115

46-
message = {
116+
# Build update message
117+
message: dict[str, Any] = {
47118
"type": "config/entity_registry/update",
48119
"entity_id": entity_id,
49-
"disabled_by": None if enabled_bool else "user",
50120
}
51121

122+
updates_made = []
123+
124+
if area_id is not None:
125+
# Empty string means remove from area (set to None in API)
126+
message["area_id"] = area_id if area_id else None
127+
updates_made.append(
128+
f"area_id='{area_id}'" if area_id else "area cleared"
129+
)
130+
131+
if name is not None:
132+
# Empty string means remove custom name (set to None in API)
133+
message["name"] = name if name else None
134+
updates_made.append(f"name='{name}'" if name else "name cleared")
135+
136+
if icon is not None:
137+
# Empty string means remove custom icon (set to None in API)
138+
message["icon"] = icon if icon else None
139+
updates_made.append(f"icon='{icon}'" if icon else "icon cleared")
140+
141+
if enabled is not None:
142+
# Convert boolean to API format: True=enable (None), False=disable ("user")
143+
enabled_bool = coerce_bool_param(enabled, "enabled")
144+
message["disabled_by"] = None if enabled_bool else "user"
145+
updates_made.append("enabled" if enabled_bool else "disabled")
146+
147+
if hidden is not None:
148+
# Convert boolean to API format: True=hide ("user"), False=show (None)
149+
hidden_bool = coerce_bool_param(hidden, "hidden")
150+
message["hidden_by"] = "user" if hidden_bool else None
151+
updates_made.append("hidden" if hidden_bool else "visible")
152+
153+
if parsed_aliases is not None:
154+
message["aliases"] = parsed_aliases
155+
updates_made.append(f"aliases={parsed_aliases}")
156+
157+
if not updates_made:
158+
return {
159+
"success": False,
160+
"error": "No updates specified",
161+
"suggestion": "Provide at least one of: area_id, name, icon, enabled, hidden, or aliases",
162+
}
163+
164+
logger.info(f"Updating entity {entity_id}: {', '.join(updates_made)}")
52165
result = await client.send_websocket_message(message)
53166

54167
if result.get("success"):
55168
entity_entry = result.get("result", {}).get("entity_entry", {})
56169
return {
57170
"success": True,
58171
"entity_id": entity_id,
59-
"enabled": entity_entry.get("disabled_by") is None,
60-
"message": f"Entity {'enabled' if enabled_bool else 'disabled'}",
172+
"updates": updates_made,
173+
"entity_entry": {
174+
"entity_id": entity_entry.get("entity_id"),
175+
"name": entity_entry.get("name"),
176+
"original_name": entity_entry.get("original_name"),
177+
"icon": entity_entry.get("icon"),
178+
"area_id": entity_entry.get("area_id"),
179+
"disabled_by": entity_entry.get("disabled_by"),
180+
"hidden_by": entity_entry.get("hidden_by"),
181+
"aliases": entity_entry.get("aliases", []),
182+
"labels": entity_entry.get("labels", []),
183+
},
184+
"message": f"Entity updated: {', '.join(updates_made)}",
61185
}
62186
else:
63187
error = result.get("error", {})
@@ -68,9 +192,205 @@ async def ha_set_entity_enabled(
68192
)
69193
return {
70194
"success": False,
71-
"error": f"Failed to {'enable' if enabled_bool else 'disable'}: {error_msg}",
195+
"error": f"Failed to update entity: {error_msg}",
72196
"entity_id": entity_id,
197+
"suggestions": [
198+
"Verify the entity_id exists using ha_search_entities()",
199+
"Check that area_id exists if specified",
200+
"Some entities may not support all update options",
201+
],
73202
}
203+
74204
except Exception as e:
75-
logger.error(f"Error setting entity enabled: {e}")
205+
logger.error(f"Error updating entity: {e}")
76206
return exception_to_structured_error(e, context={"entity_id": entity_id})
207+
208+
@mcp.tool(
209+
annotations={
210+
"readOnlyHint": True,
211+
"idempotentHint": True,
212+
"tags": ["entity"],
213+
"title": "Get Entity",
214+
}
215+
)
216+
@log_tool_usage
217+
async def ha_get_entity(
218+
entity_id: Annotated[
219+
str | list[str],
220+
Field(
221+
description="Entity ID or list of entity IDs to retrieve (e.g., 'sensor.temperature' or ['light.living_room', 'switch.porch'])"
222+
),
223+
],
224+
) -> dict[str, Any]:
225+
"""Get entity registry information for one or more entities.
226+
227+
Returns detailed entity registry metadata including area assignment,
228+
custom name/icon, enabled/hidden state, aliases, labels, and more.
229+
230+
RELATED TOOLS:
231+
- ha_set_entity(): Modify entity properties (area, name, icon, enabled, hidden, aliases)
232+
- ha_get_state(): Get current state/attributes (on/off, temperature, etc.)
233+
- ha_search_entities(): Find entities by name, domain, or area
234+
235+
EXAMPLES:
236+
- Single entity: ha_get_entity("sensor.temperature")
237+
- Multiple entities: ha_get_entity(["light.living_room", "switch.porch"])
238+
239+
RESPONSE FIELDS:
240+
- entity_id: Full entity identifier
241+
- name: Custom display name (null if using original_name)
242+
- original_name: Default name from integration
243+
- icon: Custom icon (null if using default)
244+
- area_id: Assigned area/room ID (null if unassigned)
245+
- disabled_by: Why disabled (null=enabled, "user"/"integration"/etc)
246+
- hidden_by: Why hidden (null=visible, "user"/"integration"/etc)
247+
- enabled: Boolean shorthand (True if disabled_by is null)
248+
- hidden: Boolean shorthand (True if hidden_by is not null)
249+
- aliases: Voice assistant aliases
250+
- labels: Assigned label IDs
251+
- platform: Integration platform (e.g., "hue", "zwave_js")
252+
- device_id: Associated device ID (null if standalone)
253+
- unique_id: Integration's unique identifier
254+
"""
255+
try:
256+
# Validate and parse entity_id parameter
257+
entity_ids: list[str]
258+
is_bulk: bool
259+
260+
if isinstance(entity_id, str):
261+
entity_ids = [entity_id]
262+
is_bulk = False
263+
elif isinstance(entity_id, list):
264+
if not entity_id:
265+
return {
266+
"success": True,
267+
"entity_entries": [],
268+
"count": 0,
269+
"message": "No entities requested",
270+
}
271+
if not all(isinstance(e, str) for e in entity_id):
272+
return {
273+
"success": False,
274+
"error": "All entity_id values must be strings",
275+
}
276+
entity_ids = entity_id
277+
is_bulk = True
278+
else:
279+
return {
280+
"success": False,
281+
"error": f"entity_id must be string or list of strings, got {type(entity_id).__name__}",
282+
}
283+
284+
async def _fetch_entity(eid: str) -> dict[str, Any]:
285+
"""Fetch a single entity from the registry."""
286+
message: dict[str, Any] = {
287+
"type": "config/entity_registry/get",
288+
"entity_id": eid,
289+
}
290+
result = await client.send_websocket_message(message)
291+
292+
if not result.get("success"):
293+
error = result.get("error", {})
294+
error_msg = (
295+
error.get("message", str(error))
296+
if isinstance(error, dict)
297+
else str(error)
298+
)
299+
return {
300+
"success": False,
301+
"entity_id": eid,
302+
"error": error_msg,
303+
}
304+
305+
entry = result.get("result", {})
306+
return {
307+
"success": True,
308+
"entity_id": entry.get("entity_id"),
309+
"name": entry.get("name"),
310+
"original_name": entry.get("original_name"),
311+
"icon": entry.get("icon"),
312+
"area_id": entry.get("area_id"),
313+
"disabled_by": entry.get("disabled_by"),
314+
"hidden_by": entry.get("hidden_by"),
315+
"enabled": entry.get("disabled_by") is None,
316+
"hidden": entry.get("hidden_by") is not None,
317+
"aliases": entry.get("aliases", []),
318+
"labels": entry.get("labels", []),
319+
"platform": entry.get("platform"),
320+
"device_id": entry.get("device_id"),
321+
"unique_id": entry.get("unique_id"),
322+
}
323+
324+
# Single entity case
325+
if not is_bulk:
326+
eid = entity_ids[0]
327+
logger.info(f"Getting entity registry entry for {eid}")
328+
result = await _fetch_entity(eid)
329+
330+
if result.get("success"):
331+
return {
332+
"success": True,
333+
"entity_id": eid,
334+
"entity_entry": {
335+
k: v for k, v in result.items() if k not in ("success",)
336+
},
337+
}
338+
else:
339+
return {
340+
"success": False,
341+
"entity_id": eid,
342+
"error": f"Entity not found: {result.get('error', 'Unknown error')}",
343+
"suggestions": [
344+
"Use ha_search_entities() to find valid entity IDs",
345+
"Check the entity_id spelling and format (e.g., 'sensor.temperature')",
346+
],
347+
}
348+
349+
# Bulk case - fetch all entities
350+
import asyncio
351+
352+
logger.info(f"Getting entity registry entries for {len(entity_ids)} entities")
353+
results = await asyncio.gather(
354+
*[_fetch_entity(eid) for eid in entity_ids],
355+
return_exceptions=True,
356+
)
357+
358+
entity_entries: list[dict[str, Any]] = []
359+
errors: list[dict[str, Any]] = []
360+
361+
for eid, fetch_result in zip(entity_ids, results, strict=True):
362+
if isinstance(fetch_result, BaseException):
363+
errors.append({
364+
"entity_id": eid,
365+
"error": str(fetch_result),
366+
})
367+
continue
368+
if fetch_result.get("success"):
369+
entity_entries.append(
370+
{k: v for k, v in fetch_result.items() if k not in ("success",)}
371+
)
372+
else:
373+
errors.append({
374+
"entity_id": eid,
375+
"error": fetch_result.get("error", "Unknown error"),
376+
})
377+
378+
response: dict[str, Any] = {
379+
"success": True,
380+
"count": len(entity_entries),
381+
"entity_entries": entity_entries,
382+
}
383+
384+
if errors:
385+
response["errors"] = errors
386+
response["suggestions"] = [
387+
"Use ha_search_entities() to find valid entity IDs for failed lookups"
388+
]
389+
390+
return response
391+
392+
except Exception as e:
393+
logger.error(f"Error getting entity: {e}")
394+
return exception_to_structured_error(
395+
e, context={"entity_id": entity_id if isinstance(entity_id, str) else entity_ids}
396+
)

0 commit comments

Comments
 (0)