feat(zigbee): import to pending section with edge persistence

Coordinator auto-approves to a canvas Node; routers/end devices land in
pending_devices keyed by IEEE. Discovered parent->child edges are stored
in pending_device_links so that approving a pending device later
auto-creates the Edge once both endpoints exist as canvas Nodes.

- new POST /api/v1/zigbee/import-pending (default mode in modal)
- new pending_device_links table; ieee_address on nodes + pending_devices
- pending_devices.ip migrated to nullable (table rebuild on existing DBs)
- approve / bulk-approve return auto-created edges; sidebar pushes them
  into the canvas store with bottom -> top-t handles
- ZigbeeImportModal: radio toggle pending vs canvas; reset on close
- PendingDeviceModal: zigbee badge, IEEE/LQI/vendor/model rows, services
  hidden for zigbee
- Sidebar pending row: ZIG source badge, LQI badge, friendly_name fallback
- SearchBar: null-safe IP, also searches friendly_name and ieee_address
- Tooltip trigger uses asChild to avoid nested-button hydration error
This commit is contained in:
Pouzor
2026-05-07 23:02:03 +02:00
parent 5e567d4628
commit 3ae159d8d6
15 changed files with 988 additions and 40 deletions
+198
View File
@@ -542,3 +542,201 @@ async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices)
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
# ---------------------------------------------------------------------------
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
# ---------------------------------------------------------------------------
async def _seed_zigbee_pending_pair(db_session):
"""Create a coordinator Node + a pending device + a link between them."""
from app.db.models import Node, PendingDevice, PendingDeviceLink
coord = Node(
label="Coordinator",
type="zigbee_coordinator",
status="unknown",
ieee_address="0xCOORD",
)
db_session.add(coord)
pending = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="pending",
discovery_source="zigbee",
)
db_session.add(pending)
db_session.add(
PendingDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
)
)
await db_session.commit()
return coord, pending
@pytest.mark.asyncio
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
client: AsyncClient, headers, db_session
):
from sqlalchemy import select
from app.db.models import Edge
coord, pending = await _seed_zigbee_pending_pair(db_session)
res = await client.post(
f"/api/v1/scan/pending/{pending.id}/approve",
json={
"label": "router_1",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert data["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
assert edges[0].source == coord.id
assert edges[0].target == data["node_id"]
assert edges[0].source_handle == "bottom"
assert edges[0].target_handle == "top-t"
assert edges[0].type == "iot"
@pytest.mark.asyncio
async def test_approve_zigbee_skips_duplicate_edge(
client: AsyncClient, headers, db_session
):
"""Re-running the resolution does not create a second edge for the same pair."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
# Simulate a second pending row + link between same coord and a new device,
# but keep an existing edge in place to verify dedupe also handles
# the swapped-direction case.
new_pending = PendingDevice(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
)
assert res.json()["edges_created"] == 1 # only the new pair
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 2 # original + new, no duplicate
@pytest.mark.asyncio
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
client: AsyncClient, headers, db_session
):
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{a.id}/approve",
json={
"label": "a",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
assert res.json()["edges_created"] == 0
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@pytest.mark.asyncio
async def test_approve_zigbee_resolves_link_after_second_approval(
client: AsyncClient, headers, db_session
):
"""First approval keeps link; second approval creates the edge."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
assert res.json()["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert links == [] # consumed
+161
View File
@@ -264,6 +264,167 @@ async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: di
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "0xCOORD",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0xCOORD",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0xR1",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0xR1",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "TI",
"lqi": 220,
"parent_id": "0xCOORD",
},
{
"id": "0xE1",
"label": "bulb_kitchen",
"type": "zigbee_enddevice",
"ieee_address": "0xE1",
"friendly_name": "bulb_kitchen",
"device_type": "EndDevice",
"model": "TRADFRI",
"vendor": "IKEA",
"lqi": 180,
"parent_id": "0xR1",
},
]
_PENDING_EDGES = [
{"source": "0xCOORD", "target": "0xR1"},
{"source": "0xR1", "target": "0xE1"},
]
@pytest.mark.asyncio
async def test_import_pending_creates_coordinator_and_pending(
client: AsyncClient, headers: dict
) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 3
assert data["pending_created"] == 2 # router + enddevice
assert data["pending_updated"] == 0
assert data["coordinator"] is not None
assert data["coordinator"]["ieee_address"] == "0xCOORD"
assert data["coordinator_already_existed"] is False
assert data["links_recorded"] == 2
pending = await client.get("/api/v1/scan/pending", headers=headers)
assert pending.status_code == 200
rows = pending.json()
ieees = {r["ieee_address"] for r in rows}
assert ieees == {"0xR1", "0xE1"}
router = next(r for r in rows if r["ieee_address"] == "0xR1")
assert router["model"] == "CC2530"
assert router["lqi"] == 220
assert router["device_subtype"] == "Router"
assert router["discovery_source"] == "zigbee"
@pytest.mark.asyncio
async def test_import_pending_idempotent_updates_existing(
client: AsyncClient, headers: dict
) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
# second call: returns the bumped data
mock_fetch.return_value = (bumped, _PENDING_EDGES)
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["pending_created"] == 0
assert data["pending_updated"] == 2
assert data["coordinator_already_existed"] is True
assert data["links_recorded"] == 2
pending = await client.get("/api/v1/scan/pending", headers=headers)
router = next(r for r in pending.json() if r["ieee_address"] == "0xR1")
assert router["lqi"] == 99
@pytest.mark.asyncio
async def test_import_pending_replaces_links(
client: AsyncClient, headers: dict, db_session
) -> None:
"""Re-importing wipes old zigbee links and inserts only the fresh set."""
from sqlalchemy import select
from app.db.models import PendingDeviceLink
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
mock_fetch.return_value = (_PENDING_NODES[:2], new_edges)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
result = await db_session.execute(select(PendingDeviceLink))
links = result.scalars().all()
assert len(links) == 1
assert (links[0].source_ieee, links[0].target_ieee) == ("0xCOORD", "0xR1")
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn: