Skip to content

Commit 1b10b43

Browse files
committed
fix: address PR review — exposure tracking, error handling, and test coverage
Addresses all critical, high, and medium issues from code review: - CRITICAL #1: Track per-assistant success/failure in exposure loop. On partial failure, response now includes exposure_succeeded and exposure_failed dicts showing exactly which assistants were updated. - HIGH #2/#7: Handle fetch failure on expose-only path. If entity registry get fails after exposure, return error with exposure_applied instead of silently returning empty entity_entry. - HIGH #3: Wrap coerce_bool_param calls for enabled/hidden in try/except ValueError, returning VALIDATION_INVALID_PARAMETER instead of falling through to generic Exception handler. - MEDIUM #6: Replace inline json.loads with parse_json_param utility, removing the import json dependency. - MEDIUM #8: Standardize no-updates error to use "suggestions" (plural list) matching all other error responses. - MEDIUM #9: Track actual server-confirmed exposures via succeeded dict instead of echoing back user input. - MEDIUM #10: Extract _format_entity_entry helper to eliminate duplicated entity_entry dict construction. Adds 8 new unit tests covering: - Expose-only failure (no partial flag) - Mixed partial failure with succeeded tracking - Expose-only entity not found - Invalid enabled/hidden values ("maybe") - All 3 assistants in single call - List type for expose_to (rejected) - Registry failure with labels https://claude.ai/code/session_01LL5wZ2K7KQUU2AmMyKzNWV
1 parent f145e2b commit 1b10b43

2 files changed

Lines changed: 252 additions & 51 deletions

File tree

src/ha_mcp/tools/tools_entities.py

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
via the Home Assistant entity registry API.
66
"""
77

8-
import json
98
import logging
109
from typing import Annotated, Any
1110

@@ -14,11 +13,26 @@
1413
from ..errors import ErrorCode, create_error_response
1514
from .helpers import exception_to_structured_error, log_tool_usage
1615
from .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

1918
logger = 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+
2236
def 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

Comments
 (0)