From e8bcf04b465206f0647ea8b1e9d2f2d9579aedc1 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 31 May 2026 01:54:24 +0200 Subject: [PATCH] fix(zigbee): revive orphaned approved devices on re-import (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Zigbee device was approved (PendingDevice.status=approved + Node created) and the user later deleted the canvas Node, the PendingDevice row was left orphaned as approved. On re-import _persist_pending_import found the existing row, refreshed its fields but left status=approved, so it never reappeared in the Pending list (which filters status==pending) — the import reported devices found but Pending stayed empty. Reset such orphaned approved rows back to pending on re-import. Hidden devices stay hidden (explicit user intent). Adds regression tests for both revive-approved and keep-hidden paths. ha-relevant: yes --- backend/app/api/routes/zigbee.py | 8 ++- backend/tests/test_zigbee_router.py | 86 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py index 1379d23..9a6e1c9 100644 --- a/backend/app/api/routes/zigbee.py +++ b/backend/app/api/routes/zigbee.py @@ -223,7 +223,13 @@ async def _persist_pending_import( pending.vendor = n.get("vendor") or pending.vendor if n.get("lqi") is not None: pending.lqi = n.get("lqi") - if pending.status == "hidden": + if pending.status == "approved": + # The device was approved earlier but its canvas Node no longer + # exists (no Node matched the IEEE above) — it was deleted. Revive + # the row to "pending" so it reappears in the Pending list on + # re-import instead of being silently swallowed. (Issue #167) + pending.status = "pending" + elif pending.status == "hidden": # Re-imported a hidden device → leave it hidden, just refresh fields. pass pending_updated += 1 diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py index b24e0b3..5096531 100644 --- a/backend/tests/test_zigbee_router.py +++ b/backend/tests/test_zigbee_router.py @@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node( assert all(p["visible"] is False for p in refreshed.properties) +@pytest.mark.asyncio +async def test_persist_pending_import_revives_orphaned_approved_device( + db_session, +) -> None: + """Regression for #167: approve → delete node → re-import must re-list device. + + When a device was approved (PendingDevice.status="approved") and its canvas + Node was later deleted, the orphaned "approved" row must be reset to + "pending" on re-import so it shows up in the Pending list again — instead of + being silently swallowed (re-import reports "found" but Pending stays empty). + """ + from sqlalchemy import select + + from app.api.routes.zigbee import _persist_pending_import + from app.db.models import PendingDevice + + # Simulate prior approve: a PendingDevice marked approved, but NO matching + # Node exists (the user deleted the canvas node afterwards). + orphan = PendingDevice( + ieee_address="0xR1", + friendly_name="router_1", + hostname="router_1", + suggested_type="zigbee_router", + device_subtype="Router", + model="CC2530", + vendor="TI", + lqi=220, + status="approved", + discovery_source="zigbee", + ) + db_session.add(orphan) + await db_session.commit() + + result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES) + + # No new row created for 0xR1 — the existing one was updated/revived. + revived = ( + await db_session.execute( + select(PendingDevice).where(PendingDevice.ieee_address == "0xR1") + ) + ).scalar_one() + assert revived.status == "pending" + # End device 0xE1 is brand new → created as pending; router was updated. + assert result.pending_created == 1 + assert result.pending_updated == 1 + + # It is now visible to the Pending list (status filter == "pending"). + listed = ( + await db_session.execute( + select(PendingDevice).where(PendingDevice.status == "pending") + ) + ).scalars().all() + assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"} + + +@pytest.mark.asyncio +async def test_persist_pending_import_keeps_hidden_hidden_on_reimport( + db_session, +) -> None: + """A user-hidden device must stay hidden on re-import (not revived like #167).""" + from sqlalchemy import select + + from app.api.routes.zigbee import _persist_pending_import + from app.db.models import PendingDevice + + hidden = PendingDevice( + ieee_address="0xR1", + friendly_name="router_1", + suggested_type="zigbee_router", + device_subtype="Router", + status="hidden", + discovery_source="zigbee", + ) + db_session.add(hidden) + await db_session.commit() + + await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES) + + still_hidden = ( + await db_session.execute( + select(PendingDevice).where(PendingDevice.ieee_address == "0xR1") + ) + ).scalar_one() + assert still_hidden.status == "hidden" + + @pytest.mark.asyncio async def test_persist_pending_import_preserves_user_visibility(db_session) -> None: """If user has already made props visible, re-import must not flip them back."""