Skip to content

Commit 6a1f56b

Browse files
authored
fix: resolve disabled entities via entity_registry in helper deletion (homeassistant-ai#1119)
* fix: resolve disabled entities via entity_registry in helper deletion Closes homeassistant-ai#1057. Disabled entities are removed from the state machine but remain in the entity registry (`disabled_by != null`). The existing state_check guard at the top of the registry-lookup loop hit `continue` when state_check was None, skipping the registry lookup for all 3 attempts. The code then fell through to direct_id (often a no-op for disabled helpers) and finally to the `already_deleted` short-circuit, leaving the registry entry in place while the tool reported success. Per option 3.1 from the issue thread (minimal blast radius): drop the `continue` so the registry lookup runs every iteration. The retry/sleep is preserved as the race-condition guard for entities that just transitioned state. Adds a unit test `test_simple_path_disabled_entity_resolves_via_registry` that pins the registry-fallthrough path: state_check returns None, registry returns a valid unique_id, and the standard websocket-delete path runs (rather than misclassifying as already_deleted). Local: 32/32 unit tests pass; ruff and mypy clean. * fix: drop redundant state-check sleep on disabled-entity path Per Gemini G2 review on PR homeassistant-ai#1119: with the `continue` removed in commit 69bb587, the state-check `asyncio.sleep` no longer functions as a race-condition guard — the registry-fail block below already provides retry-backoff. Keeping both produced a measurable but pointless 0.5s/attempt latency on the disabled-entity-deletion path: - common case (registry has uid): pre-fix 1.5s + bug → 69bb587 0.5s + correct → this commit 0s + correct - rare case (registry fails 3x): pre-fix 1.5s + already_deleted → 69bb587 3.0s + already_deleted → this commit 1.5s + already_deleted (parity) The state-check itself stays as an informational debug log; only the sleep is removed. Exception handling at L1000 is unchanged (`HomeAssistantAPIError` only) — Gemini's suggested broader `except Exception` was declined because it would swallow auth/connection errors that the existing comment explicitly says must propagate. Local: 32/32 unit tests pass; ruff and mypy clean. * test(e2e): add disabled-entity regression test for homeassistant-ai#1057 End-to-end mirror of the unit test `test_simple_path_disabled_entity_resolves_via_registry`. Creates an `input_button` helper, disables its entity via `ha_set_entity(enabled=False)`, deletes it via `ha_delete_helpers_integrations`, and asserts the standard `websocket_delete` path ran (rather than the `already_deleted` short-circuit that masked the bug pre-fix). Verifies registry entry actually goes away post-delete — pre-fix the tool reported success while the registry entry stayed. Closes the integration-level coverage gap noted in the IS comment. * docs: tighten state-check comment per Gemini G3 review * test(e2e): adjust post-delete error-code expectation The new disabled-helper deletion test asserted that ha_get_entity returns ENTITY_NOT_FOUND post-delete. The actual error code is SERVICE_CALL_FAILED (with 'Entity not found' in the message) — the HA-side error surfaces through the service-call layer rather than the entity-not-found layer. Loosen the assertion to check the message content and require success=False; the load-bearing claim is 'entity is gone, not still present', not the specific error code. * docs: tighten test comment per G3 principle Mirror the comment-verbosity reduction from 3f764ce to the test-side: shrink the post-delete-verification comment from 6 lines to 2, keeping the load-bearing 'why message-check, not code-check' rationale and dropping verbose padding.
1 parent 2213c89 commit 6a1f56b

3 files changed

Lines changed: 301 additions & 34 deletions

File tree

src/ha_mcp/tools/tools_integrations.py

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,8 @@ async def _delete_simple_helper(
971971
)
972972

973973
try:
974-
# Try to get unique_id with retry logic (race-condition guard)
974+
# Resolve unique_id via the entity registry, with a retry loop
975+
# for transient registry failures.
975976
unique_id = None
976977
registry_result: dict[str, Any] | None = None
977978
max_retries = 3
@@ -982,18 +983,18 @@ async def _delete_simple_helper(
982983
f"(attempt {attempt + 1}/{max_retries})"
983984
)
984985

985-
# Fast state check first
986+
# State check is informational only — disabled entities are
987+
# missing from the state machine but resolved via the registry
988+
# below (issue #1057). Kept as a debug breadcrumb rather than
989+
# removed; full removal is option 3.2 in #1057, deferred to a
990+
# separate PR for minimal blast radius here.
986991
try:
987992
state_check = await client.get_entity_state(entity_id)
988993
if not state_check:
989-
if attempt < max_retries - 1:
990-
wait_time = 0.5 * (2**attempt)
991-
logger.debug(
992-
f"Entity {entity_id} not in state, waiting "
993-
f"{wait_time}s before retry..."
994-
)
995-
await asyncio.sleep(wait_time)
996-
continue
994+
logger.debug(
995+
f"Entity {entity_id} not in state; "
996+
"proceeding to registry lookup"
997+
)
997998
except HomeAssistantAPIError as e:
998999
# State check is best-effort here; an APIError (e.g. 404)
9991000
# is informational. Auth/connection errors must propagate
@@ -1009,8 +1010,8 @@ async def _delete_simple_helper(
10091010
registry_result = await client.send_websocket_message(
10101011
registry_msg
10111012
)
1012-
if registry_result.get("success"):
1013-
entity_entry = registry_result.get("result", {})
1013+
if (registry_result or {}).get("success"):
1014+
entity_entry = (registry_result or {}).get("result") or {}
10141015
unique_id = entity_entry.get("unique_id")
10151016
if unique_id:
10161017
logger.info(
@@ -1075,29 +1076,82 @@ async def _delete_simple_helper(
10751076
)
10761077
return response
10771078

1078-
# Fallback strategy 2: already-deleted check
1079+
# Fallback strategy 2: already-deleted check. Confirm via the
1080+
# registry too — a disabled entity is missing from the state
1081+
# machine but still registry-resident, so state-absence alone
1082+
# is not enough to declare success.
10791083
try:
10801084
final_state_check = await client.get_entity_state(entity_id)
10811085
if not final_state_check:
1082-
logger.info(
1083-
f"Entity {entity_id} no longer exists; "
1084-
"treating as already deleted"
1086+
registry_still_has_entry = False
1087+
try:
1088+
verify_result = await client.send_websocket_message(
1089+
{
1090+
"type": "config/entity_registry/get",
1091+
"entity_id": entity_id,
1092+
}
1093+
)
1094+
if (verify_result or {}).get("success"):
1095+
verify_entry = (verify_result or {}).get("result") or {}
1096+
if verify_entry.get("entity_id"):
1097+
registry_still_has_entry = True
1098+
except HomeAssistantAPIError as verify_err:
1099+
# On verify failure, conservatively assume the
1100+
# entry is still there rather than silently
1101+
# short-circuit to already_deleted.
1102+
logger.debug(
1103+
f"Registry verify for {entity_id} failed: "
1104+
f"{verify_err}"
1105+
)
1106+
registry_still_has_entry = True
1107+
1108+
if not registry_still_has_entry:
1109+
logger.info(
1110+
f"Entity {entity_id} absent from state and "
1111+
"registry; treating as already deleted"
1112+
)
1113+
return {
1114+
"success": True,
1115+
"action": "delete",
1116+
"target": target,
1117+
"helper_type": helper_type,
1118+
"method": "websocket_delete",
1119+
"entry_id": None,
1120+
"entity_ids": [entity_id],
1121+
"require_restart": False,
1122+
"message": (
1123+
f"Helper {target} was already deleted or "
1124+
"never properly registered."
1125+
),
1126+
"fallback_used": "already_deleted",
1127+
}
1128+
1129+
logger.warning(
1130+
f"Entity {entity_id} absent from state but still "
1131+
"in registry; not already_deleted"
1132+
)
1133+
raise_tool_error(
1134+
create_error_response(
1135+
ErrorCode.SERVICE_CALL_FAILED,
1136+
(
1137+
f"Helper {target} could not be deleted: "
1138+
"registry entry exists but unique_id was "
1139+
"absent and the direct-id fallback "
1140+
"delete failed."
1141+
),
1142+
suggestions=[
1143+
"Re-enable the entity via "
1144+
"ha_set_entity(enabled=True), then retry "
1145+
"deletion.",
1146+
"Or inspect the entity registry entry "
1147+
"directly to confirm unique_id presence.",
1148+
],
1149+
context={
1150+
"target": target,
1151+
"entity_id": entity_id,
1152+
},
1153+
)
10851154
)
1086-
return {
1087-
"success": True,
1088-
"action": "delete",
1089-
"target": target,
1090-
"helper_type": helper_type,
1091-
"method": "websocket_delete",
1092-
"entry_id": None,
1093-
"entity_ids": [entity_id],
1094-
"require_restart": False,
1095-
"message": (
1096-
f"Helper {target} was already deleted or "
1097-
"never properly registered."
1098-
),
1099-
"fallback_used": "already_deleted",
1100-
}
11011155
except HomeAssistantAPIError as e:
11021156
# 404 here means the state-check itself confirmed the
11031157
# entity is gone — treat as a soft signal and continue

tests/src/e2e/workflows/config/test_helper_crud.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,104 @@ async def test_input_button_full_lifecycle(self, mcp_client, cleanup_tracker):
620620
)
621621
logger.info("Input button cleanup complete")
622622

623+
async def test_disabled_input_button_deletion_resolves_via_registry(
624+
self, mcp_client, cleanup_tracker
625+
):
626+
"""Issue #1057 regression: a disabled helper (registered but absent
627+
from the state machine) must be resolved via the entity registry,
628+
not silently treated as already-deleted.
629+
630+
End-to-end mirror of the unit test
631+
``test_simple_path_disabled_entity_resolves_via_registry``: creates a
632+
helper, disables its entity via ``ha_set_entity(enabled=False)``,
633+
deletes it, and asserts the deletion took the standard
634+
``websocket_delete`` path (not the ``already_deleted`` fallback that
635+
masked the bug pre-fix).
636+
"""
637+
helper_name = "E2E Disabled Button"
638+
639+
# CREATE input_button
640+
create_result = await mcp_client.call_tool(
641+
"ha_config_set_helper",
642+
{
643+
"helper_type": "input_button",
644+
"name": helper_name,
645+
"icon": "mdi:gesture-tap-button",
646+
},
647+
)
648+
create_data = assert_mcp_success(create_result, "Create input_button")
649+
entity_id = get_entity_id_from_response(create_data, "input_button")
650+
assert entity_id, f"Missing entity_id: {create_data}"
651+
cleanup_tracker.track("input_button", entity_id)
652+
logger.info(f"Created input_button: {entity_id}")
653+
654+
# Wait until entity is queryable
655+
state_reached = await wait_for_entity_state(
656+
mcp_client, entity_id, "unknown", timeout=10
657+
)
658+
assert state_reached, f"Entity {entity_id} not registered within timeout"
659+
660+
# DISABLE entity at registry level — this is what reproduces the bug
661+
disable_result = await mcp_client.call_tool(
662+
"ha_set_entity",
663+
{"entity_id": entity_id, "enabled": False},
664+
)
665+
disable_data = assert_mcp_success(disable_result, "Disable entity")
666+
assert disable_data.get("entity_entry", {}).get("disabled_by") == "user", (
667+
f"Entity not registry-disabled: {disable_data}"
668+
)
669+
logger.info(f"Disabled entity {entity_id} (disabled_by=user)")
670+
671+
# DELETE — pre-fix this fell through to the ``already_deleted``
672+
# short-circuit, leaving the registry entry in place. Post-fix the
673+
# registry lookup runs every iteration and finds the unique_id.
674+
delete_result = await mcp_client.call_tool(
675+
"ha_delete_helpers_integrations",
676+
{
677+
"helper_type": "input_button",
678+
"target": entity_id,
679+
"confirm": True,
680+
},
681+
)
682+
delete_data = assert_mcp_success(delete_result, "Delete disabled helper")
683+
684+
# Standard registry-driven delete path ran — unique_id was resolved
685+
# and no fallback fired. Tighter than `!= "already_deleted"`: also
686+
# rejects `direct_id` and any future fallback variant.
687+
assert delete_data.get("method") == "websocket_delete", (
688+
f"Expected websocket_delete via unique_id; got "
689+
f"method={delete_data.get('method')}, data={delete_data}"
690+
)
691+
assert "unique_id" in delete_data, (
692+
f"Standard path not taken (no unique_id in response): {delete_data}"
693+
)
694+
assert delete_data.get("fallback_used") is None, (
695+
f"Expected no fallback; got fallback_used="
696+
f"{delete_data.get('fallback_used')!r}, data={delete_data}"
697+
)
698+
logger.info(
699+
f"Disabled helper deleted via "
700+
f"{delete_data.get('method')} (unique_id={delete_data.get('unique_id')})"
701+
)
702+
703+
# Verify entity is gone — error code varies (ENTITY_NOT_FOUND vs
704+
# SERVICE_CALL_FAILED), so the assertion targets the message.
705+
get_data = await safe_call_tool(
706+
mcp_client, "ha_get_entity", {"entity_id": entity_id}
707+
)
708+
assert get_data.get("success", True) is False, (
709+
f"Entity still present in registry after delete: {get_data}"
710+
)
711+
err_msg = (get_data.get("error", {}).get("message") or "").lower()
712+
assert "not found" in err_msg, (
713+
f"Expected 'not found' in error message, got: {get_data}"
714+
)
715+
716+
logger.info(
717+
f"Issue #1057 regression test passed: disabled "
718+
f"{entity_id} cleanly resolved via registry"
719+
)
720+
623721

624722
@pytest.mark.asyncio
625723
@pytest.mark.config

0 commit comments

Comments
 (0)