55via the Home Assistant entity registry API.
66"""
77
8- import json
98import logging
109from typing import Annotated , Any
1110
1413from ..errors import ErrorCode , create_error_response
1514from .helpers import exception_to_structured_error , log_tool_usage
1615from .tools_voice_assistant import KNOWN_ASSISTANTS
17- from .util_helpers import coerce_bool_param , parse_string_list_param
16+ from .util_helpers import coerce_bool_param , parse_json_param , parse_string_list_param
1817
1918logger = logging .getLogger (__name__ )
2019
2120
21+ def _format_entity_entry (entry : dict [str , Any ]) -> dict [str , Any ]:
22+ """Format entity registry entry for API response."""
23+ return {
24+ "entity_id" : entry .get ("entity_id" ),
25+ "name" : entry .get ("name" ),
26+ "original_name" : entry .get ("original_name" ),
27+ "icon" : entry .get ("icon" ),
28+ "area_id" : entry .get ("area_id" ),
29+ "disabled_by" : entry .get ("disabled_by" ),
30+ "hidden_by" : entry .get ("hidden_by" ),
31+ "aliases" : entry .get ("aliases" , []),
32+ "labels" : entry .get ("labels" , []),
33+ }
34+
35+
2236def register_entity_tools (mcp : Any , client : Any , ** kwargs : Any ) -> None :
2337 """Register entity management tools with the MCP server."""
2438
@@ -158,28 +172,21 @@ async def ha_set_entity(
158172 # Parse and validate expose_to parameter
159173 parsed_expose_to : dict [str , bool ] | None = None
160174 if expose_to is not None :
161- if isinstance (expose_to , str ):
162- try :
163- parsed_expose_to = json .loads (expose_to )
164- except json .JSONDecodeError :
165- return create_error_response (
166- ErrorCode .VALIDATION_INVALID_PARAMETER ,
167- "expose_to must be a JSON dict mapping assistant IDs to booleans, "
168- 'e.g. {"conversation": true, "cloud.alexa": false}' ,
169- )
170- elif isinstance (expose_to , dict ):
171- parsed_expose_to = expose_to
172- else :
175+ try :
176+ parsed = parse_json_param (expose_to , "expose_to" )
177+ except ValueError as e :
173178 return create_error_response (
174179 ErrorCode .VALIDATION_INVALID_PARAMETER ,
175- f"expose_to must be a dict, got { type ( expose_to ). __name__ } " ,
180+ str ( e ) ,
176181 )
177182
178- if not isinstance (parsed_expose_to , dict ):
183+ if not isinstance (parsed , dict ):
179184 return create_error_response (
180185 ErrorCode .VALIDATION_INVALID_PARAMETER ,
181- "expose_to must be a dict mapping assistant IDs to booleans" ,
186+ "expose_to must be a dict mapping assistant IDs to booleans, "
187+ 'e.g. {"conversation": true, "cloud.alexa": false}' ,
182188 )
189+ parsed_expose_to = parsed
183190
184191 # Validate assistant names
185192 invalid_assistants = [
@@ -194,7 +201,13 @@ async def ha_set_entity(
194201
195202 # Coerce values to bool
196203 for asst , val in parsed_expose_to .items ():
197- coerced = coerce_bool_param (val , f"expose_to[{ asst } ]" )
204+ try :
205+ coerced = coerce_bool_param (val , f"expose_to[{ asst } ]" )
206+ except ValueError as e :
207+ return create_error_response (
208+ ErrorCode .VALIDATION_INVALID_PARAMETER ,
209+ str (e ),
210+ )
198211 if coerced is None :
199212 return create_error_response (
200213 ErrorCode .VALIDATION_INVALID_PARAMETER ,
@@ -228,14 +241,24 @@ async def ha_set_entity(
228241 updates_made .append (f"icon='{ icon } '" if icon else "icon cleared" )
229242
230243 if enabled is not None :
231- # Convert boolean to API format: True=enable (None), False=disable ("user")
232- enabled_bool = coerce_bool_param (enabled , "enabled" )
244+ try :
245+ enabled_bool = coerce_bool_param (enabled , "enabled" )
246+ except ValueError as e :
247+ return create_error_response (
248+ ErrorCode .VALIDATION_INVALID_PARAMETER ,
249+ str (e ),
250+ )
233251 message ["disabled_by" ] = None if enabled_bool else "user"
234252 updates_made .append ("enabled" if enabled_bool else "disabled" )
235253
236254 if hidden is not None :
237- # Convert boolean to API format: True=hide ("user"), False=show (None)
238- hidden_bool = coerce_bool_param (hidden , "hidden" )
255+ try :
256+ hidden_bool = coerce_bool_param (hidden , "hidden" )
257+ except ValueError as e :
258+ return create_error_response (
259+ ErrorCode .VALIDATION_INVALID_PARAMETER ,
260+ str (e ),
261+ )
239262 message ["hidden_by" ] = "user" if hidden_bool else None
240263 updates_made .append ("hidden" if hidden_bool else "visible" )
241264
@@ -254,15 +277,18 @@ async def ha_set_entity(
254277 return {
255278 "success" : False ,
256279 "error" : "No updates specified" ,
257- "suggestion" : "Provide at least one of: area_id, name, icon, enabled, hidden, aliases, labels, or expose_to" ,
280+ "suggestions" : [
281+ "Provide at least one of: area_id, name, icon, enabled, hidden, aliases, labels, or expose_to"
282+ ],
258283 }
259284
260285 # Send entity registry update (covers all fields except expose_to)
261286 has_registry_updates = len (message ) > 2 # more than just type + entity_id
262- entity_entry = {}
287+ entity_entry : dict [ str , Any ] = {}
263288
264289 if has_registry_updates :
265- logger .info (f"Updating entity registry for { entity_id } : { ', ' .join (u for u in updates_made if not u .startswith ('expose_to=' ))} " )
290+ registry_update_fields = [u for u in updates_made if not u .startswith ("expose_to=" )]
291+ logger .info (f"Updating entity registry for { entity_id } : { ', ' .join (registry_update_fields )} " )
266292 result = await client .send_websocket_message (message )
267293
268294 if not result .get ("success" ):
@@ -286,12 +312,14 @@ async def ha_set_entity(
286312 entity_entry = result .get ("result" , {}).get ("entity_entry" , {})
287313
288314 # Handle expose_to via separate WebSocket API
289- exposure_result = None
315+ exposure_result : dict [ str , bool ] | None = None
290316 if parsed_expose_to is not None :
291317 # Group by should_expose value for efficient API calls
292318 expose_true = [a for a , v in parsed_expose_to .items () if v ]
293319 expose_false = [a for a , v in parsed_expose_to .items () if not v ]
294320
321+ succeeded : dict [str , bool ] = {}
322+
295323 for assistants , should_expose in [
296324 (expose_true , True ),
297325 (expose_false , False ),
@@ -319,28 +347,24 @@ async def ha_set_entity(
319347 if isinstance (error , dict )
320348 else str (error )
321349 )
322- # Return partial success if registry update succeeded
350+ failed = dict . fromkeys ( assistants , should_expose )
323351 response : dict [str , Any ] = {
324352 "success" : False ,
325- "error" : f"Entity registry updated but exposure failed: { error_msg } " ,
353+ "error" : f"Exposure failed: { error_msg } " ,
326354 "entity_id" : entity_id ,
355+ "exposure_succeeded" : succeeded ,
356+ "exposure_failed" : failed ,
327357 }
328358 if has_registry_updates :
329359 response ["partial" ] = True
330- response ["entity_entry" ] = {
331- "entity_id" : entity_entry .get ("entity_id" ),
332- "name" : entity_entry .get ("name" ),
333- "original_name" : entity_entry .get ("original_name" ),
334- "icon" : entity_entry .get ("icon" ),
335- "area_id" : entity_entry .get ("area_id" ),
336- "disabled_by" : entity_entry .get ("disabled_by" ),
337- "hidden_by" : entity_entry .get ("hidden_by" ),
338- "aliases" : entity_entry .get ("aliases" , []),
339- "labels" : entity_entry .get ("labels" , []),
340- }
360+ response ["entity_entry" ] = _format_entity_entry (entity_entry )
341361 return response
342362
343- exposure_result = parsed_expose_to
363+ # Track successful exposures
364+ for a in assistants :
365+ succeeded [a ] = should_expose
366+
367+ exposure_result = succeeded
344368
345369 # If only expose_to was set (no registry updates), fetch current entity state
346370 if not has_registry_updates and parsed_expose_to is not None :
@@ -351,22 +375,23 @@ async def ha_set_entity(
351375 get_result = await client .send_websocket_message (get_msg )
352376 if get_result .get ("success" ):
353377 entity_entry = get_result .get ("result" , {})
378+ else :
379+ return {
380+ "success" : False ,
381+ "error" : f"Entity '{ entity_id } ' not found in registry" ,
382+ "entity_id" : entity_id ,
383+ "suggestions" : [
384+ "Verify the entity_id exists using ha_search_entities()" ,
385+ "Exposure may have been applied but entity state could not be confirmed" ,
386+ ],
387+ "exposure_applied" : exposure_result ,
388+ }
354389
355390 response_data : dict [str , Any ] = {
356391 "success" : True ,
357392 "entity_id" : entity_id ,
358393 "updates" : updates_made ,
359- "entity_entry" : {
360- "entity_id" : entity_entry .get ("entity_id" ),
361- "name" : entity_entry .get ("name" ),
362- "original_name" : entity_entry .get ("original_name" ),
363- "icon" : entity_entry .get ("icon" ),
364- "area_id" : entity_entry .get ("area_id" ),
365- "disabled_by" : entity_entry .get ("disabled_by" ),
366- "hidden_by" : entity_entry .get ("hidden_by" ),
367- "aliases" : entity_entry .get ("aliases" , []),
368- "labels" : entity_entry .get ("labels" , []),
369- },
394+ "entity_entry" : _format_entity_entry (entity_entry ),
370395 "message" : f"Entity updated: { ', ' .join (updates_made )} " ,
371396 }
372397
0 commit comments