diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 5c77bf1..babfea6 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.core.config import settings from app.db.database import AsyncSessionLocal, get_db -from app.db.models import Node, PendingDevice, ScanRun +from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.services.scanner import request_cancel, run_scan @@ -126,23 +126,31 @@ async def bulk_approve_devices( for device in devices: device.status = "approved" node = Node( - label=device.hostname or device.ip, + label=device.hostname or device.friendly_name or device.ip or "device", type=device.suggested_type or "generic", ip=device.ip, hostname=device.hostname, status="unknown", services=device.services or [], + ieee_address=device.ieee_address, ) db.add(node) created_nodes.append(node) await db.flush() # populates node.id from Python-side default before reading node_ids = [n.id for n in created_nodes] approved_device_ids = [d.id for d in devices] + + all_edges: list[dict[str, str]] = [] + for device in devices: + all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address)) + await db.commit() return { "approved": len(node_ids), "node_ids": node_ids, "device_ids": approved_device_ids, + "edges_created": len(all_edges), + "edges": all_edges, "skipped": len(payload.device_ids) - len(node_ids), } @@ -186,12 +194,102 @@ async def approve_device( hostname=node_data.hostname, status=node_data.status, services=node_data.services or [], + ieee_address=device.ieee_address, ) db.add(node) await db.flush() node_id = node.id + + edges = await _resolve_pending_links_for_ieee(db, device.ieee_address) + await db.commit() - return {"approved": True, "node_id": node_id} + return { + "approved": True, + "node_id": node_id, + "edges_created": len(edges), + "edges": edges, + } + + +async def _resolve_pending_links_for_ieee( + db: AsyncSession, ieee: str | None +) -> list[dict[str, str]]: + """Materialize edges for any pending_device_links involving ``ieee``. + + For each link where the other endpoint already exists as a canvas Node + (matched by ``Node.ieee_address``), create the Edge and drop the link + row. Links where the other endpoint is still pending are kept so they + can resolve when that endpoint is approved later. + """ + if not ieee: + return [] + + links_q = await db.execute( + select(PendingDeviceLink).where( + (PendingDeviceLink.source_ieee == ieee) + | (PendingDeviceLink.target_ieee == ieee) + ) + ) + links = list(links_q.scalars().all()) + if not links: + return [] + + # Map every relevant ieee → Node (single query). + other_ieees = { + link.target_ieee if link.source_ieee == ieee else link.source_ieee + for link in links + } + other_ieees.add(ieee) + nodes_q = await db.execute( + select(Node).where(Node.ieee_address.in_(other_ieees)) + ) + by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address} + + self_node = by_ieee.get(ieee) + if self_node is None: + return [] + + # Pre-fetch existing edges between these node ids so we don't create dups + # if the user re-approves a device or had drawn the link manually. + candidate_node_ids = [n.id for n in by_ieee.values()] + existing_q = await db.execute( + select(Edge).where( + Edge.source.in_(candidate_node_ids), + Edge.target.in_(candidate_node_ids), + ) + ) + existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()} + + created: list[dict[str, str]] = [] + for link in links: + other_ieee = ( + link.target_ieee if link.source_ieee == ieee else link.source_ieee + ) + other_node = by_ieee.get(other_ieee) + if other_node is None: + continue + if link.source_ieee == ieee: + src_id, tgt_id = self_node.id, other_node.id + else: + src_id, tgt_id = other_node.id, self_node.id + # Skip if either direction already exists. + if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs: + await db.delete(link) + continue + edge = Edge( + source=src_id, + target=tgt_id, + type="iot", + source_handle="bottom", + target_handle="top-t", + ) + db.add(edge) + await db.flush() + existing_pairs.add((src_id, tgt_id)) + created.append({"id": edge.id, "source": src_id, "target": tgt_id}) + await db.delete(link) + + return created @router.post("/pending/{device_id}/hide") diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py index 0c5784a..b0b5a1b 100644 --- a/backend/app/api/routes/zigbee.py +++ b/backend/app/api/routes/zigbee.py @@ -1,12 +1,20 @@ """FastAPI router for Zigbee2MQTT import.""" import logging +from typing import Any from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete as sa_delete +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user +from app.db.database import get_db +from app.db.models import Node, PendingDevice, PendingDeviceLink from app.schemas.zigbee import ( + ZigbeeCoordinatorOut, ZigbeeEdgeOut, + ZigbeeImportPendingResponse, ZigbeeImportRequest, ZigbeeImportResponse, ZigbeeNodeOut, @@ -58,6 +66,162 @@ async def import_zigbee_network( return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes)) +@router.post("/import-pending", response_model=ZigbeeImportPendingResponse) +async def import_zigbee_to_pending( + payload: ZigbeeImportRequest, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> ZigbeeImportPendingResponse: + """Fetch the Z2M networkmap and store devices in the pending section. + + Coordinator is auto-approved (creates a canvas Node directly with + ``ieee_address`` set). Routers and end devices are upserted into + ``pending_devices`` keyed by IEEE address. The discovered parent→child + edges are persisted as ``pending_device_links`` rows so that approving a + pending device later can auto-create the corresponding Edge when the + other endpoint already exists as a canvas Node. + + Re-importing replaces all zigbee-discovered links and updates pending + rows in place; pending devices not present in the new map are kept + untouched (the user may be mid-approval). + """ + try: + nodes_raw, edges_raw = await fetch_networkmap( + mqtt_host=payload.mqtt_host, + mqtt_port=payload.mqtt_port, + base_topic=payload.base_topic, + username=payload.mqtt_username, + password=payload.mqtt_password, + tls=payload.mqtt_tls, + tls_insecure=payload.mqtt_tls_insecure, + ) + except ImportError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + except ConnectionError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except TimeoutError as exc: + raise HTTPException(status_code=504, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Unexpected error during Zigbee pending import") + raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc + + return await _persist_pending_import(db, nodes_raw, edges_raw) + + +async def _persist_pending_import( + db: AsyncSession, + nodes_raw: list[dict[str, Any]], + edges_raw: list[dict[str, Any]], +) -> ZigbeeImportPendingResponse: + """Upsert nodes/edges into pending_devices + pending_device_links. + + Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE. + All zigbee-source links are wiped and re-inserted from the new map. + """ + coordinator_out: ZigbeeCoordinatorOut | None = None + coordinator_existed = False + pending_created = 0 + pending_updated = 0 + + for n in nodes_raw: + ieee = n.get("ieee_address") + if not ieee: + continue + if n.get("device_type") == "Coordinator": + existing = await db.execute(select(Node).where(Node.ieee_address == ieee)) + existing_node = existing.scalar_one_or_none() + if existing_node: + coordinator_out = ZigbeeCoordinatorOut( + id=existing_node.id, + label=existing_node.label, + ieee_address=ieee, + ) + coordinator_existed = True + continue + label = n.get("friendly_name") or ieee + node = Node( + label=label, + type=n.get("type") or "zigbee_coordinator", + status="unknown", + ieee_address=ieee, + services=[], + ) + db.add(node) + await db.flush() + coordinator_out = ZigbeeCoordinatorOut( + id=node.id, label=label, ieee_address=ieee + ) + continue + + result = await db.execute( + select(PendingDevice).where(PendingDevice.ieee_address == ieee) + ) + pending = result.scalar_one_or_none() + if pending is None: + db.add( + PendingDevice( + ieee_address=ieee, + friendly_name=n.get("friendly_name"), + hostname=n.get("friendly_name"), + suggested_type=n.get("type"), + device_subtype=n.get("device_type"), + model=n.get("model"), + vendor=n.get("vendor"), + lqi=n.get("lqi"), + status="pending", + discovery_source="zigbee", + ) + ) + pending_created += 1 + else: + pending.friendly_name = n.get("friendly_name") or pending.friendly_name + pending.suggested_type = n.get("type") or pending.suggested_type + pending.device_subtype = n.get("device_type") or pending.device_subtype + pending.model = n.get("model") or pending.model + pending.vendor = n.get("vendor") or pending.vendor + if n.get("lqi") is not None: + pending.lqi = n.get("lqi") + if pending.status == "hidden": + # Re-imported a hidden device → leave it hidden, just refresh fields. + pass + pending_updated += 1 + + # Replace all zigbee-source links with the freshly discovered set. + await db.execute( + sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee") + ) + + links_recorded = 0 + seen: set[tuple[str, str]] = set() + for e in edges_raw: + src = e.get("source") + tgt = e.get("target") + if not src or not tgt or (src, tgt) in seen: + continue + seen.add((src, tgt)) + db.add( + PendingDeviceLink( + source_ieee=src, + target_ieee=tgt, + discovery_source="zigbee", + ) + ) + links_recorded += 1 + + await db.commit() + + return ZigbeeImportPendingResponse( + pending_created=pending_created, + pending_updated=pending_updated, + coordinator=coordinator_out, + coordinator_already_existed=coordinator_existed, + links_recorded=links_recorded, + device_count=len(nodes_raw), + ) + + @router.post("/test-connection", response_model=ZigbeeTestConnectionResponse) async def test_zigbee_connection( payload: ZigbeeTestConnectionRequest, diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 73c1c61..0da2d6d 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -80,6 +80,72 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ieee_address TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address " + "ON pending_devices(ieee_address)" + ) + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN model TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN vendor TEXT") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN lqi INTEGER") + # Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP). + # SQLite can't ALTER column nullability — rebuild the table if needed. + with suppress(OperationalError): + info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)") + cols = info.fetchall() + ip_col = next((c for c in cols if c[1] == "ip"), None) + # PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk) + if ip_col and ip_col[3] == 1: + logger.info("Migrating pending_devices: dropping NOT NULL on ip column") + await conn.exec_driver_sql("PRAGMA foreign_keys = OFF") + await conn.exec_driver_sql( + "CREATE TABLE pending_devices_new (" + "id VARCHAR PRIMARY KEY," + "ip VARCHAR," + "mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON," + "suggested_type VARCHAR," + "status VARCHAR," + "discovery_source VARCHAR," + "ieee_address VARCHAR," + "friendly_name VARCHAR," + "device_subtype VARCHAR," + "model VARCHAR," + "vendor VARCHAR," + "lqi INTEGER," + "discovered_at DATETIME" + ")" + ) + await conn.exec_driver_sql( + "INSERT INTO pending_devices_new " + "(id, ip, mac, hostname, os, services, suggested_type, status, " + "discovery_source, ieee_address, friendly_name, device_subtype, " + "model, vendor, lqi, discovered_at) " + "SELECT id, ip, mac, hostname, os, services, suggested_type, status, " + "discovery_source, ieee_address, friendly_name, device_subtype, " + "model, vendor, lqi, discovered_at FROM pending_devices" + ) + await conn.exec_driver_sql("DROP TABLE pending_devices") + await conn.exec_driver_sql( + "ALTER TABLE pending_devices_new RENAME TO pending_devices" + ) + await conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address " + "ON pending_devices(ieee_address)" + ) + await conn.exec_driver_sql("PRAGMA foreign_keys = ON") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") with suppress(OperationalError): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 9eb0c88..be30d76 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -46,6 +46,7 @@ class Node(Base): width: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True) bottom_handles: Mapped[int] = mapped_column(Integer, default=1) + ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) @@ -86,7 +87,7 @@ class PendingDevice(Base): __tablename__ = "pending_devices" id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) - ip: Mapped[str] = mapped_column(String, nullable=False) + ip: Mapped[str | None] = mapped_column(String, nullable=True) mac: Mapped[str | None] = mapped_column(String) hostname: Mapped[str | None] = mapped_column(String) os: Mapped[str | None] = mapped_column(String) @@ -94,6 +95,31 @@ class PendingDevice(Base): suggested_type: Mapped[str | None] = mapped_column(String) status: Mapped[str] = mapped_column(String, default="pending") discovery_source: Mapped[str | None] = mapped_column(String) + ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True) + friendly_name: Mapped[str | None] = mapped_column(String, nullable=True) + device_subtype: Mapped[str | None] = mapped_column(String, nullable=True) + model: Mapped[str | None] = mapped_column(String, nullable=True) + vendor: Mapped[str | None] = mapped_column(String, nullable=True) + lqi: Mapped[int | None] = mapped_column(Integer, nullable=True) + discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class PendingDeviceLink(Base): + """Link between two Zigbee endpoints discovered during import. + + Endpoints are addressed by IEEE (stable across re-imports). Either side may + already exist as a canvas Node (resolved via Node.ieee_address) or still be + a PendingDevice. On approval, the matching Edge is auto-created when both + endpoints exist as canvas Nodes. + """ + + __tablename__ = "pending_device_links" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True) + target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True) + lqi: Mapped[int | None] = mapped_column(Integer, nullable=True) + discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee") discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index 5b7314e..fd86863 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -6,7 +6,7 @@ from pydantic import BaseModel class PendingDeviceResponse(BaseModel): id: str - ip: str + ip: str | None mac: str | None hostname: str | None os: str | None @@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel): suggested_type: str | None status: str discovery_source: str | None + ieee_address: str | None = None + friendly_name: str | None = None + device_subtype: str | None = None + model: str | None = None + vendor: str | None = None + lqi: int | None = None discovered_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/zigbee.py b/backend/app/schemas/zigbee.py index 3831ee6..fe5a270 100644 --- a/backend/app/schemas/zigbee.py +++ b/backend/app/schemas/zigbee.py @@ -76,3 +76,20 @@ class ZigbeeImportResponse(BaseModel): class ZigbeeTestConnectionResponse(BaseModel): connected: bool message: str + + +class ZigbeeCoordinatorOut(BaseModel): + id: str + label: str + ieee_address: str + + +class ZigbeeImportPendingResponse(BaseModel): + """Result of importing a Z2M network into the pending section.""" + + pending_created: int + pending_updated: int + coordinator: ZigbeeCoordinatorOut | None = None + coordinator_already_existed: bool = False + links_recorded: int + device_count: int diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 86ce554..f59f0ad 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -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 diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py index bc3d48d..9058301 100644 --- a/backend/tests/test_zigbee_router.py +++ b/backend/tests/test_zigbee_router.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e998114..018b38e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -540,6 +540,26 @@ export default function App() { open={zigbeeImportOpen} onClose={() => setZigbeeImportOpen(false)} onAddToCanvas={handleZigbeeAddToCanvas} + onPendingImported={(coordinator) => { + useCanvasStore.getState().notifyScanDeviceFound() + if (coordinator) { + const exists = useCanvasStore.getState().nodes.some((n) => n.id === coordinator.id) + if (!exists) { + addNode({ + id: coordinator.id, + type: 'zigbee_coordinator', + position: { x: 600, y: 100 }, + data: { + label: coordinator.label, + type: 'zigbee_coordinator' as NodeData['type'], + status: 'unknown' as const, + services: [], + }, + }) + markUnsaved() + } + } + }} /> )} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 83e28a0..dd4f552 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,10 +58,24 @@ export const scanApi = { hidden: () => api.get('/scan/hidden'), runs: () => api.get('/scan/runs'), clearPending: () => api.delete('/scan/pending'), - approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), + approve: (id: string, nodeData: object) => + api.post<{ + approved: boolean + node_id: string + edges_created: number + edges: { id: string; source: string; target: string }[] + }>(`/scan/pending/${id}/approve`, nodeData), hide: (id: string) => api.post(`/scan/pending/${id}/hide`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), - bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }), + bulkApprove: (ids: string[]) => + api.post<{ + approved: number + node_ids: string[] + device_ids: string[] + edges_created: number + edges: { id: string; source: string; target: string }[] + skipped: number + }>('/scan/pending/bulk-approve', { device_ids: ids }), bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }), stop: (runId: string) => api.post(`/scan/${runId}/stop`), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), @@ -98,4 +112,22 @@ export const zigbeeApi = { edges: import('@/components/zigbee/types').ZigbeeEdge[] device_count: number }>('/zigbee/import', data), + + importToPending: (data: { + mqtt_host: string + mqtt_port: number + mqtt_username?: string + mqtt_password?: string + base_topic?: string + mqtt_tls?: boolean + mqtt_tls_insecure?: boolean + }) => + api.post<{ + pending_created: number + pending_updated: number + coordinator: { id: string; label: string; ieee_address: string } | null + coordinator_already_existed: boolean + links_recorded: number + device_count: number + }>('/zigbee/import-pending', data), } diff --git a/frontend/src/components/canvas/SearchBar.tsx b/frontend/src/components/canvas/SearchBar.tsx index beac37b..5dca55c 100644 --- a/frontend/src/components/canvas/SearchBar.tsx +++ b/frontend/src/components/canvas/SearchBar.tsx @@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { const pendingResults = q ? pendingDevices.filter((d) => - d.ip.toLowerCase().includes(q) || + d.ip?.toLowerCase().includes(q) || d.hostname?.toLowerCase().includes(q) || + d.friendly_name?.toLowerCase().includes(q) || + d.ieee_address?.toLowerCase().includes(q) || d.services.some((s) => s.service_name?.toLowerCase().includes(q) || s.category?.toLowerCase().includes(q) @@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { > pending - {d.hostname ?? d.ip} + {d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'} - {serviceName ?? d.ip} + {serviceName ?? d.ip ?? d.ieee_address ?? ''} ) diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index cbad9c7..aa76069 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -12,7 +12,7 @@ interface Service { export interface PendingDevice { id: string - ip: string + ip: string | null mac: string | null hostname: string | null os: string | null @@ -20,6 +20,12 @@ export interface PendingDevice { suggested_type: string | null status: string discovery_source: string | null + ieee_address?: string | null + friendly_name?: string | null + device_subtype?: string | null + model?: string | null + vendor?: string | null + lqi?: number | null discovered_at: string } @@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor if (!device) return null const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle + const isZigbee = device.discovery_source === 'zigbee' + const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device' const handleApprove = () => { onApprove(device) } const handleHide = () => { onHide(device); onClose() } @@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor - {device.hostname ?? device.ip} + {titleLabel} + {isZigbee && ( + + Zigbee + + )}
{/* Device info */}
- + {device.ip && } {device.hostname && } {device.mac && } {device.os && } + {device.ieee_address && } + {device.friendly_name && device.friendly_name !== device.hostname && ( + + )} + {device.vendor && } + {device.model && } + {device.device_subtype && } + {device.lqi != null && } {device.suggested_type && ( )} @@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
- {/* Services */} -
+ {/* Services (skipped for Zigbee devices — they don't have IP services) */} + {!isZigbee &&

Services found ({device.services.length})

@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor ))}
)} -
+
} {/* Actions */}
diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index b2b6452..6cea8ed 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -180,6 +180,25 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS const COMMON_PORTS = new Set([22, 80, 443]) +function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) { + if (!edges || edges.length === 0) return + useCanvasStore.setState((state) => ({ + edges: [ + ...state.edges, + ...edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: 'bottom', + targetHandle: 'top-t', + type: 'iot', + data: { type: 'iot' as const }, + })), + ], + hasUnsavedChanges: true, + })) +} + function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) @@ -237,14 +256,15 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: approvedDevices.forEach((d, i) => { const nodeId = deviceToNode[d.id] if (!nodeId) return + const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device' addNode({ id: nodeId, type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, data: { - label: d.hostname ?? d.ip, + label: fallbackLabel, type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, - ip: d.ip, + ip: d.ip ?? undefined, hostname: d.hostname ?? undefined, status: 'unknown' as const, services: (d.services ?? []) as import('@/types').ServiceInfo[], @@ -252,9 +272,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: }) onNodeApproved(nodeId) }) + injectAutoEdges(res.data.edges) setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) setCheckedIds(new Set()) - toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`) + const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' + toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`) } catch { toast.error('Failed to bulk approve devices') } @@ -285,10 +307,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: const handleApprove = async (device: PendingDevice) => { try { + const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device' const nodeData = { - label: device.hostname ?? device.ip, + label: fallbackLabel, type: (device.suggested_type ?? 'generic') as import('@/types').NodeType, - ip: device.ip, + ip: device.ip ?? undefined, hostname: device.hostname ?? undefined, status: 'unknown', services: (device.services ?? []) as import('@/types').ServiceInfo[], @@ -301,7 +324,9 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: position: { x: 400, y: 300 }, data: { ...nodeData, status: 'unknown' as const }, }) - toast.success(`Approved ${nodeData.label}`) + injectAutoEdges(res.data.edges) + const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' + toast.success(`Approved ${nodeData.label}${extra}`) setDevices((prev) => prev.filter((d) => d.id !== device.id)) setSelected(null) onNodeApproved(nodeId) @@ -378,20 +403,30 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:

No pending devices

)} {devices.map((d) => { + const isZigbee = d.discovery_source === 'zigbee' const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port)) const titleService = namedService ?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 443) ?? d.services.find((s) => s.port === 22) - const title = titleService?.service_name ?? d.hostname ?? d.ip - const showIpBelow = title !== d.ip + const title = isZigbee + ? (d.friendly_name ?? d.hostname ?? d.ieee_address ?? 'zigbee device') + : (titleService?.service_name ?? d.hostname ?? d.ip ?? 'device') + const showIpBelow = !isZigbee && d.ip != null && title !== d.ip const hasSsh = d.services.some((s) => s.port === 22) const hasHttp = d.services.some((s) => s.port === 80) const hasHttps = d.services.some((s) => s.port === 443) const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length const virtualBadge = detectVirtualBadge(d.mac) - const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e' - const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null + const sourceColor = + d.discovery_source === 'mdns' ? '#a855f7' + : d.discovery_source === 'zigbee' ? '#00d4ff' + : '#8b949e' + const sourceLabel = + d.discovery_source === 'mdns' ? 'mDNS' + : d.discovery_source === 'arp' ? 'ARP' + : d.discovery_source === 'zigbee' ? 'ZIG' + : null const isHighlighted = d.id === highlightId return (
)} diff --git a/frontend/src/components/zigbee/ZigbeeImportModal.tsx b/frontend/src/components/zigbee/ZigbeeImportModal.tsx index 865207a..09bf710 100644 --- a/frontend/src/components/zigbee/ZigbeeImportModal.tsx +++ b/frontend/src/components/zigbee/ZigbeeImportModal.tsx @@ -12,8 +12,13 @@ interface ZigbeeImportModalProps { open: boolean onClose: () => void onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void + onPendingImported?: ( + coordinator?: { id: string; label: string; ieee_address: string } | null, + ) => void } +type ImportMode = 'pending' | 'canvas' + interface ConnectionForm { mqtt_host: string mqtt_port: string @@ -54,7 +59,7 @@ const DEVICE_TYPE_COLOR = { zigbee_enddevice: '#e3b341', } as const -export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) { +export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) { const [form, setForm] = useState(DEFAULT_FORM) const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle') const [connectionMsg, setConnectionMsg] = useState('') @@ -62,6 +67,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport const [devices, setDevices] = useState([]) const [edges, setEdges] = useState([]) const [checked, setChecked] = useState>(new Set()) + const [importMode, setImportMode] = useState('pending') const updateField = (field: keyof ConnectionForm, value: string) => setForm((f) => ({ @@ -120,24 +126,45 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport } } + const extractError = (err: unknown): string | undefined => { + if (err && typeof err === 'object' && 'response' in err) { + return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + } + return undefined + } + const handleFetchDevices = async () => { if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return } setLoading(true) try { - const res = await zigbeeApi.importNetwork(buildPayload()) - setDevices(res.data.nodes) - setEdges(res.data.edges) - setChecked(new Set(res.data.nodes.map((n) => n.id))) - if (res.data.device_count === 0) { - toast.info('No Zigbee devices found in the network map') + if (importMode === 'pending') { + const res = await zigbeeApi.importToPending(buildPayload()) + const { pending_created, pending_updated, coordinator, coordinator_already_existed, device_count } = res.data + if (device_count === 0) { + toast.info('No Zigbee devices found in the network map') + } else { + const coordMsg = coordinator_already_existed + ? 'coordinator already on canvas' + : 'coordinator added to canvas' + toast.success( + `Imported ${pending_created} new, updated ${pending_updated} (${coordMsg})`, + ) + } + onPendingImported?.(coordinator) + handleClose() } else { - toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`) + const res = await zigbeeApi.importNetwork(buildPayload()) + setDevices(res.data.nodes) + setEdges(res.data.edges) + setChecked(new Set(res.data.nodes.map((n) => n.id))) + if (res.data.device_count === 0) { + toast.info('No Zigbee devices found in the network map') + } else { + toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`) + } } } catch (err: unknown) { - const msg = err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail - : undefined - toast.error(msg ?? 'Failed to fetch Zigbee devices') + toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices') } finally { setLoading(false) } @@ -169,6 +196,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport setChecked(new Set()) setConnectionStatus('idle') setConnectionMsg('') + setImportMode('pending') onClose() } @@ -285,6 +313,29 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport )} +
+ Send devices to: + + +

diff --git a/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx index 5252687..54d8dfe 100644 --- a/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx +++ b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx @@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({ zigbeeApi: { testConnection: vi.fn(), importNetwork: vi.fn(), + importToPending: vi.fn(), }, })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) @@ -50,6 +51,7 @@ describe('ZigbeeImportModal', () => { beforeEach(() => { vi.mocked(zigbeeApi.testConnection).mockReset() vi.mocked(zigbeeApi.importNetwork).mockReset() + vi.mocked(zigbeeApi.importToPending).mockReset() vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() vi.mocked(toast.info).mockReset() @@ -109,12 +111,17 @@ describe('ZigbeeImportModal', () => { }) }) + const selectCanvasMode = () => { + fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i })) + } + it('fetches devices and renders them grouped by type', async () => { vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({ data: { nodes: sampleNodes, edges: [], device_count: 2 }, } as never) render() + selectCanvasMode() const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local') fireEvent.change(hostInput, { target: { value: '192.168.1.100' } }) fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) @@ -132,6 +139,7 @@ describe('ZigbeeImportModal', () => { } as never) render() + selectCanvasMode() const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local') fireEvent.change(hostInput, { target: { value: '192.168.1.100' } }) fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) @@ -147,6 +155,7 @@ describe('ZigbeeImportModal', () => { } as never) render() + selectCanvasMode() const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local') fireEvent.change(hostInput, { target: { value: '192.168.1.100' } }) fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) @@ -168,4 +177,45 @@ describe('ZigbeeImportModal', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(defaultProps.onClose).toHaveBeenCalledOnce() }) + + it('imports to pending by default and notifies parent', async () => { + vi.mocked(zigbeeApi.importToPending).mockResolvedValue({ + data: { + pending_created: 2, + pending_updated: 0, + coordinator: { id: 'coord-uuid', label: 'Coordinator', ieee_address: '0x0000' }, + coordinator_already_existed: false, + links_recorded: 1, + device_count: 3, + }, + } as never) + const onPendingImported = vi.fn() + + render() + const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local') + fireEvent.change(hostInput, { target: { value: '192.168.1.100' } }) + fireEvent.click(screen.getByRole('button', { name: /import to pending/i })) + + await waitFor(() => { + expect(zigbeeApi.importToPending).toHaveBeenCalled() + expect(onPendingImported).toHaveBeenCalled() + expect(defaultProps.onClose).toHaveBeenCalled() + }) + expect(zigbeeApi.importNetwork).not.toHaveBeenCalled() + }) + + it('switching to canvas mode calls importNetwork and not importToPending', async () => { + vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({ + data: { nodes: sampleNodes, edges: [], device_count: 2 }, + } as never) + + render() + fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i })) + const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local') + fireEvent.change(hostInput, { target: { value: '192.168.1.100' } }) + fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) + + await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled()) + expect(zigbeeApi.importToPending).not.toHaveBeenCalled() + }) })