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
Services found ({device.services.length})
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor ))}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 ( } {virtualBadge && (
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(