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:
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
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.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
@@ -126,23 +126,31 @@ async def bulk_approve_devices(
|
|||||||
for device in devices:
|
for device in devices:
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node = Node(
|
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",
|
type=device.suggested_type or "generic",
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="unknown",
|
status="unknown",
|
||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
|
ieee_address=device.ieee_address,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
created_nodes.append(node)
|
created_nodes.append(node)
|
||||||
await db.flush() # populates node.id from Python-side default before reading
|
await db.flush() # populates node.id from Python-side default before reading
|
||||||
node_ids = [n.id for n in created_nodes]
|
node_ids = [n.id for n in created_nodes]
|
||||||
approved_device_ids = [d.id for d in devices]
|
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()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"approved": len(node_ids),
|
"approved": len(node_ids),
|
||||||
"node_ids": node_ids,
|
"node_ids": node_ids,
|
||||||
"device_ids": approved_device_ids,
|
"device_ids": approved_device_ids,
|
||||||
|
"edges_created": len(all_edges),
|
||||||
|
"edges": all_edges,
|
||||||
"skipped": len(payload.device_ids) - len(node_ids),
|
"skipped": len(payload.device_ids) - len(node_ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,12 +194,102 @@ async def approve_device(
|
|||||||
hostname=node_data.hostname,
|
hostname=node_data.hostname,
|
||||||
status=node_data.status,
|
status=node_data.status,
|
||||||
services=node_data.services or [],
|
services=node_data.services or [],
|
||||||
|
ieee_address=device.ieee_address,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
node_id = node.id
|
node_id = node.id
|
||||||
|
|
||||||
|
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||||
|
|
||||||
await db.commit()
|
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")
|
@router.post("/pending/{device_id}/hide")
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"""FastAPI router for Zigbee2MQTT import."""
|
"""FastAPI router for Zigbee2MQTT import."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.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 (
|
from app.schemas.zigbee import (
|
||||||
|
ZigbeeCoordinatorOut,
|
||||||
ZigbeeEdgeOut,
|
ZigbeeEdgeOut,
|
||||||
|
ZigbeeImportPendingResponse,
|
||||||
ZigbeeImportRequest,
|
ZigbeeImportRequest,
|
||||||
ZigbeeImportResponse,
|
ZigbeeImportResponse,
|
||||||
ZigbeeNodeOut,
|
ZigbeeNodeOut,
|
||||||
@@ -58,6 +66,162 @@ async def import_zigbee_network(
|
|||||||
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
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)
|
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
||||||
async def test_zigbee_connection(
|
async def test_zigbee_connection(
|
||||||
payload: ZigbeeTestConnectionRequest,
|
payload: ZigbeeTestConnectionRequest,
|
||||||
|
|||||||
@@ -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")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Node(Base):
|
|||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: 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)
|
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))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
@@ -86,7 +87,7 @@ class PendingDevice(Base):
|
|||||||
__tablename__ = "pending_devices"
|
__tablename__ = "pending_devices"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
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)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
os: 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)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
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)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class PendingDeviceResponse(BaseModel):
|
class PendingDeviceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
ip: str
|
ip: str | None
|
||||||
mac: str | None
|
mac: str | None
|
||||||
hostname: str | None
|
hostname: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
discovery_source: str | None
|
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
|
discovered_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -76,3 +76,20 @@ class ZigbeeImportResponse(BaseModel):
|
|||||||
class ZigbeeTestConnectionResponse(BaseModel):
|
class ZigbeeTestConnectionResponse(BaseModel):
|
||||||
connected: bool
|
connected: bool
|
||||||
message: str
|
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
|
||||||
|
|||||||
@@ -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]
|
ids = [d.id for d in two_pending_devices]
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||||
assert res.status_code == 401
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
|
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
|||||||
@@ -540,6 +540,26 @@ export default function App() {
|
|||||||
open={zigbeeImportOpen}
|
open={zigbeeImportOpen}
|
||||||
onClose={() => setZigbeeImportOpen(false)}
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,24 @@ export const scanApi = {
|
|||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
clearPending: () => api.delete('/scan/pending'),
|
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`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
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 }),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
@@ -98,4 +112,22 @@ export const zigbeeApi = {
|
|||||||
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||||
device_count: number
|
device_count: number
|
||||||
}>('/zigbee/import', data),
|
}>('/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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
|
|
||||||
const pendingResults = q
|
const pendingResults = q
|
||||||
? pendingDevices.filter((d) =>
|
? pendingDevices.filter((d) =>
|
||||||
d.ip.toLowerCase().includes(q) ||
|
d.ip?.toLowerCase().includes(q) ||
|
||||||
d.hostname?.toLowerCase().includes(q) ||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.friendly_name?.toLowerCase().includes(q) ||
|
||||||
|
d.ieee_address?.toLowerCase().includes(q) ||
|
||||||
d.services.some((s) =>
|
d.services.some((s) =>
|
||||||
s.service_name?.toLowerCase().includes(q) ||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
s.category?.toLowerCase().includes(q)
|
s.category?.toLowerCase().includes(q)
|
||||||
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{d.hostname ?? d.ip}
|
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
{serviceName ?? d.ip}
|
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface Service {
|
|||||||
|
|
||||||
export interface PendingDevice {
|
export interface PendingDevice {
|
||||||
id: string
|
id: string
|
||||||
ip: string
|
ip: string | null
|
||||||
mac: string | null
|
mac: string | null
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
os: string | null
|
os: string | null
|
||||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
|||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
discovery_source: string | null
|
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
|
discovered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
if (!device) return null
|
if (!device) return null
|
||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
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 handleApprove = () => { onApprove(device) }
|
||||||
const handleHide = () => { onHide(device); onClose() }
|
const handleHide = () => { onHide(device); onClose() }
|
||||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||||
{device.hostname ?? device.ip}
|
{titleLabel}
|
||||||
|
{isZigbee && (
|
||||||
|
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||||
|
Zigbee
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
{/* Device info */}
|
{/* Device info */}
|
||||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||||
<InfoRow label="IP" value={device.ip} />
|
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||||
|
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||||
|
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||||
|
<InfoRow label="Name" value={device.friendly_name} />
|
||||||
|
)}
|
||||||
|
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||||
|
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||||
|
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||||
|
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<InfoRow label="Type" value={device.suggested_type} />
|
<InfoRow label="Type" value={device.suggested_type} />
|
||||||
)}
|
)}
|
||||||
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||||
<div>
|
{!isZigbee && <div>
|
||||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||||
Services found ({device.services.length})
|
Services found ({device.services.length})
|
||||||
</p>
|
</p>
|
||||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
|
|||||||
@@ -180,6 +180,25 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
|
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
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 }) {
|
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -237,14 +256,15 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
approvedDevices.forEach((d, i) => {
|
approvedDevices.forEach((d, i) => {
|
||||||
const nodeId = deviceToNode[d.id]
|
const nodeId = deviceToNode[d.id]
|
||||||
if (!nodeId) return
|
if (!nodeId) return
|
||||||
|
const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
data: {
|
data: {
|
||||||
label: d.hostname ?? d.ip,
|
label: fallbackLabel,
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||||
ip: d.ip,
|
ip: d.ip ?? undefined,
|
||||||
hostname: d.hostname ?? undefined,
|
hostname: d.hostname ?? undefined,
|
||||||
status: 'unknown' as const,
|
status: 'unknown' as const,
|
||||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
||||||
@@ -252,9 +272,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
})
|
})
|
||||||
onNodeApproved(nodeId)
|
onNodeApproved(nodeId)
|
||||||
})
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
setCheckedIds(new Set())
|
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 {
|
} catch {
|
||||||
toast.error('Failed to bulk approve devices')
|
toast.error('Failed to bulk approve devices')
|
||||||
}
|
}
|
||||||
@@ -285,10 +307,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
|
const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device'
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
label: device.hostname ?? device.ip,
|
label: fallbackLabel,
|
||||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||||
ip: device.ip,
|
ip: device.ip ?? undefined,
|
||||||
hostname: device.hostname ?? undefined,
|
hostname: device.hostname ?? undefined,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
||||||
@@ -301,7 +324,9 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
position: { x: 400, y: 300 },
|
position: { x: 400, y: 300 },
|
||||||
data: { ...nodeData, status: 'unknown' as const },
|
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))
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
onNodeApproved(nodeId)
|
onNodeApproved(nodeId)
|
||||||
@@ -378,20 +403,30 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||||
)}
|
)}
|
||||||
{devices.map((d) => {
|
{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 namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
|
||||||
const titleService = namedService
|
const titleService = namedService
|
||||||
?? d.services.find((s) => s.port === 80)
|
?? d.services.find((s) => s.port === 80)
|
||||||
?? d.services.find((s) => s.port === 443)
|
?? d.services.find((s) => s.port === 443)
|
||||||
?? d.services.find((s) => s.port === 22)
|
?? d.services.find((s) => s.port === 22)
|
||||||
const title = titleService?.service_name ?? d.hostname ?? d.ip
|
const title = isZigbee
|
||||||
const showIpBelow = title !== d.ip
|
? (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 hasSsh = d.services.some((s) => s.port === 22)
|
||||||
const hasHttp = d.services.some((s) => s.port === 80)
|
const hasHttp = d.services.some((s) => s.port === 80)
|
||||||
const hasHttps = d.services.some((s) => s.port === 443)
|
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 otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
||||||
const virtualBadge = detectVirtualBadge(d.mac)
|
const virtualBadge = detectVirtualBadge(d.mac)
|
||||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
const sourceColor =
|
||||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
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
|
const isHighlighted = d.id === highlightId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -418,7 +453,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
||||||
{virtualBadge && (
|
{virtualBadge && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
||||||
@@ -428,6 +463,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
||||||
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
||||||
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
||||||
|
{isZigbee && d.lqi != null && <ServiceBadge label={`LQI ${d.lqi}`} color="#8b949e" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ interface ZigbeeImportModalProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||||
|
onPendingImported?: (
|
||||||
|
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImportMode = 'pending' | 'canvas'
|
||||||
|
|
||||||
interface ConnectionForm {
|
interface ConnectionForm {
|
||||||
mqtt_host: string
|
mqtt_host: string
|
||||||
mqtt_port: string
|
mqtt_port: string
|
||||||
@@ -54,7 +59,7 @@ const DEVICE_TYPE_COLOR = {
|
|||||||
zigbee_enddevice: '#e3b341',
|
zigbee_enddevice: '#e3b341',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) {
|
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
|
||||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||||
const [connectionMsg, setConnectionMsg] = useState('')
|
const [connectionMsg, setConnectionMsg] = useState('')
|
||||||
@@ -62,6 +67,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
|||||||
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
||||||
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||||
|
|
||||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
setForm((f) => ({
|
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 () => {
|
const handleFetchDevices = async () => {
|
||||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await zigbeeApi.importNetwork(buildPayload())
|
if (importMode === 'pending') {
|
||||||
setDevices(res.data.nodes)
|
const res = await zigbeeApi.importToPending(buildPayload())
|
||||||
setEdges(res.data.edges)
|
const { pending_created, pending_updated, coordinator, coordinator_already_existed, device_count } = res.data
|
||||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
if (device_count === 0) {
|
||||||
if (res.data.device_count === 0) {
|
toast.info('No Zigbee devices found in the network map')
|
||||||
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 {
|
} 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) {
|
} catch (err: unknown) {
|
||||||
const msg = err && typeof err === 'object' && 'response' in err
|
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
|
||||||
: undefined
|
|
||||||
toast.error(msg ?? 'Failed to fetch Zigbee devices')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -169,6 +196,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
|||||||
setChecked(new Set())
|
setChecked(new Set())
|
||||||
setConnectionStatus('idle')
|
setConnectionStatus('idle')
|
||||||
setConnectionMsg('')
|
setConnectionMsg('')
|
||||||
|
setImportMode('pending')
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +313,29 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-muted-foreground">Send devices to:</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zigbee-import-mode"
|
||||||
|
checked={importMode === 'pending'}
|
||||||
|
onChange={() => setImportMode('pending')}
|
||||||
|
className="accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Pending section
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zigbee-import-mode"
|
||||||
|
checked={importMode === 'canvas'}
|
||||||
|
onChange={() => setImportMode('canvas')}
|
||||||
|
className="accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Canvas directly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -306,7 +357,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
|
|||||||
disabled={loading || connectionStatus === 'testing'}
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||||
Fetch Devices
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground italic">
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
zigbeeApi: {
|
zigbeeApi: {
|
||||||
testConnection: vi.fn(),
|
testConnection: vi.fn(),
|
||||||
importNetwork: vi.fn(),
|
importNetwork: vi.fn(),
|
||||||
|
importToPending: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
@@ -50,6 +51,7 @@ describe('ZigbeeImportModal', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(zigbeeApi.testConnection).mockReset()
|
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||||
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
||||||
|
vi.mocked(zigbeeApi.importToPending).mockReset()
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
vi.mocked(toast.info).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 () => {
|
it('fetches devices and renders them grouped by type', async () => {
|
||||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
render(<ZigbeeImportModal {...defaultProps} />)
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
@@ -132,6 +139,7 @@ describe('ZigbeeImportModal', () => {
|
|||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
render(<ZigbeeImportModal {...defaultProps} />)
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
@@ -147,6 +155,7 @@ describe('ZigbeeImportModal', () => {
|
|||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
render(<ZigbeeImportModal {...defaultProps} />)
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
@@ -168,4 +177,45 @@ describe('ZigbeeImportModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
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(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||||
|
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(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user