1111from pydantic import Field
1212
1313from .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
1616logger = 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