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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user