Merge pull request #1 from pranjal-joshi/feat/zigbee
feat: add Zigbee2MQTT network map importer
This commit is contained in:
@@ -74,6 +74,38 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Zigbee2MQTT Import
|
||||||
|
|
||||||
|
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||||
|
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
||||||
|
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
||||||
|
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
||||||
|
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
||||||
|
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
|
||||||
|
| Type | Z2M Device | Icon |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `zigbee_coordinator` | Coordinator | Network hub |
|
||||||
|
| `zigbee_router` | Router (mains-powered) | Radio |
|
||||||
|
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
||||||
|
|
||||||
|
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
||||||
|
LQI (Link Quality Indicator) is stored as a node property.
|
||||||
|
|
||||||
|
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Live View (read-only public canvas)
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +174,41 @@ async def bulk_hide_devices(
|
|||||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{device_id}/restore", response_model=dict)
|
||||||
|
async def restore_device(
|
||||||
|
device_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = await db.get(PendingDevice, device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
if device.status != "hidden":
|
||||||
|
raise HTTPException(status_code=409, detail="Device is not hidden")
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": True, "device_id": device_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-restore", response_model=dict)
|
||||||
|
async def bulk_restore_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "hidden",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
for device in devices:
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -186,12 +229,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")
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""FastAPI router for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, 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 AsyncSessionLocal, get_db
|
||||||
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
|
from app.schemas.scan import ScanRunResponse
|
||||||
|
from app.schemas.zigbee import (
|
||||||
|
ZigbeeCoordinatorOut,
|
||||||
|
ZigbeeEdgeOut,
|
||||||
|
ZigbeeImportPendingResponse,
|
||||||
|
ZigbeeImportRequest,
|
||||||
|
ZigbeeImportResponse,
|
||||||
|
ZigbeeNodeOut,
|
||||||
|
ZigbeeTestConnectionRequest,
|
||||||
|
ZigbeeTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||||
|
async def import_zigbee_network(
|
||||||
|
payload: ZigbeeImportRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeImportResponse:
|
||||||
|
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||||
|
|
||||||
|
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||||
|
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
||||||
|
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
||||||
|
coordinator → router → end-device hierarchy.
|
||||||
|
"""
|
||||||
|
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 import")
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||||
|
|
||||||
|
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||||
|
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||||
|
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||||
|
async def import_zigbee_to_pending(
|
||||||
|
payload: ZigbeeImportRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ScanRun:
|
||||||
|
"""Queue a Zigbee2MQTT pending import as a background scan run.
|
||||||
|
|
||||||
|
Returns the ScanRun row immediately so the UI can close the import
|
||||||
|
modal and surface progress under Scan History (kind=zigbee). The
|
||||||
|
actual MQTT fetch + pending upsert happens in the background.
|
||||||
|
"""
|
||||||
|
run = ScanRun(
|
||||||
|
status="running",
|
||||||
|
kind="zigbee",
|
||||||
|
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(run)
|
||||||
|
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "done"
|
||||||
|
run.devices_found = result.device_count
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Zigbee import %s failed", run_id)
|
||||||
|
await db.rollback()
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "error"
|
||||||
|
run.error = str(exc)[:500]
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeTestConnectionResponse:
|
||||||
|
"""Quick MQTT ping to validate broker connection before importing."""
|
||||||
|
try:
|
||||||
|
await test_mqtt_connection(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
except (ConnectionError, TimeoutError) as exc:
|
||||||
|
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error during connection test")
|
||||||
|
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||||
@@ -5,13 +5,30 @@ from contextlib import suppress
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.core.config import APP_VERSION, settings
|
from app.core.config import APP_VERSION, settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
||||||
|
"""Run an idempotent migration statement, logging any error.
|
||||||
|
|
||||||
|
Distinguishes 'already applied' errors (debug) from genuine failures
|
||||||
|
(warning) so silent corruption is avoided. Used for new in-commit
|
||||||
|
migrations; existing legacy ALTERs above remain wrapped in suppress.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await conn.exec_driver_sql(sql)
|
||||||
|
except OperationalError as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "duplicate column" in msg or "already exists" in msg:
|
||||||
|
logger.debug("Migration %s skipped (already applied): %s", label, exc)
|
||||||
|
else:
|
||||||
|
logger.warning("Migration %s failed: %s", label, exc)
|
||||||
|
|
||||||
# Ensure the data directory exists before SQLite tries to open the file
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -80,6 +97,77 @@ 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 scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
|
||||||
|
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
|
||||||
|
zigbee_migrations: list[tuple[str, str]] = [
|
||||||
|
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
|
||||||
|
(
|
||||||
|
"nodes.ieee_address.index",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
||||||
|
),
|
||||||
|
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
|
||||||
|
(
|
||||||
|
"pending_devices.ieee_address.index",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||||
|
"ON pending_devices(ieee_address)",
|
||||||
|
),
|
||||||
|
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
|
||||||
|
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
|
||||||
|
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
|
||||||
|
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
|
||||||
|
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
|
||||||
|
]
|
||||||
|
for label, sql in zigbee_migrations:
|
||||||
|
await _try_migrate(conn, sql, label=label)
|
||||||
|
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
|
||||||
|
# SQLite can't ALTER column nullability — rebuild the table if needed.
|
||||||
|
try:
|
||||||
|
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")
|
||||||
|
except OperationalError as exc:
|
||||||
|
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||||
|
# --- end Zigbee schema migrations -------------------------------------
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +128,7 @@ class ScanRun(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
status: Mapped[str] = mapped_column(String, default="running")
|
status: Mapped[str] = mapped_column(String, default="running")
|
||||||
|
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
|
||||||
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
|
||||||
from app.api.routes import settings as settings_routes
|
from app.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
@@ -55,6 +55,7 @@ app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
|||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
|
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -22,6 +28,7 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
class ScanRunResponse(BaseModel):
|
class ScanRunResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
|
kind: str = "ip"
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
devices_found: int
|
devices_found: int
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportRequest(BaseModel):
|
||||||
|
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
||||||
|
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
||||||
|
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
||||||
|
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
||||||
|
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
||||||
|
mqtt_tls_insecure: bool = Field(
|
||||||
|
False, description="Skip TLS certificate verification (self-signed only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeTestConnectionRequest(BaseModel):
|
||||||
|
mqtt_host: str
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||||
|
mqtt_username: str | None = None
|
||||||
|
mqtt_password: str | None = None
|
||||||
|
mqtt_tls: bool = False
|
||||||
|
mqtt_tls_insecure: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeDeviceData(BaseModel):
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str # Coordinator, Router, EndDevice
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
last_seen: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeNodeOut(BaseModel):
|
||||||
|
"""A homelable-ready node representation of a Zigbee device."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeEdgeOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportResponse(BaseModel):
|
||||||
|
nodes: list[ZigbeeNodeOut]
|
||||||
|
edges: list[ZigbeeEdgeOut]
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiomqtt
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
aiomqtt = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||||
|
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||||
|
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||||
|
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||||
|
"""Return a generic, credential-free message for an MQTT error.
|
||||||
|
|
||||||
|
The raw aiomqtt/paho error string can include the broker URI with
|
||||||
|
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||||
|
detail that should not leak to API clients. Map known patterns to
|
||||||
|
coarse categories; default to a generic failure message. The original
|
||||||
|
exception is logged at WARNING level for operator debugging.
|
||||||
|
"""
|
||||||
|
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||||
|
raw = str(exc).lower()
|
||||||
|
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||||
|
return "Authentication failed"
|
||||||
|
if "refused" in raw:
|
||||||
|
return "Connection refused by broker"
|
||||||
|
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||||
|
return "Broker hostname could not be resolved"
|
||||||
|
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||||
|
return "TLS handshake failed"
|
||||||
|
if "timed out" in raw or "timeout" in raw:
|
||||||
|
return "Connection to broker timed out"
|
||||||
|
return "MQTT connection failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||||
|
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
if insecure:
|
||||||
|
logger.warning(
|
||||||
|
"MQTT TLS certificate verification is DISABLED — "
|
||||||
|
"use only with self-signed brokers on trusted networks."
|
||||||
|
)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||||
|
"""Map a Z2M device type string to a homelable node type."""
|
||||||
|
mapping = {
|
||||||
|
"Coordinator": "zigbee_coordinator",
|
||||||
|
"Router": "zigbee_router",
|
||||||
|
"EndDevice": "zigbee_enddevice",
|
||||||
|
}
|
||||||
|
return mapping.get(device_type, "zigbee_enddevice")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
|
||||||
|
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
|
||||||
|
if not ieee:
|
||||||
|
return None
|
||||||
|
device_type: str = raw.get("type") or "EndDevice"
|
||||||
|
friendly_name: str = (
|
||||||
|
raw.get("friendlyName") or raw.get("friendly_name") or ieee
|
||||||
|
)
|
||||||
|
definition: dict[str, Any] = raw.get("definition") or {}
|
||||||
|
model: str | None = (
|
||||||
|
raw.get("modelID")
|
||||||
|
or raw.get("model")
|
||||||
|
or definition.get("model")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": friendly_name,
|
||||||
|
"type": _z2m_type_to_homelable(device_type),
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"friendly_name": friendly_name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"model": model,
|
||||||
|
"vendor": vendor,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_networkmap(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
|
||||||
|
|
||||||
|
Z2M raw response shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": false,
|
||||||
|
"value": {
|
||||||
|
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
|
||||||
|
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
|
||||||
|
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
|
||||||
|
"lqi": 200, "depth": 1}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
Older or alternate shapes may put nodes/links directly under ``data``.
|
||||||
|
Both are accepted.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = payload.get("data") or {}
|
||||||
|
value = data.get("value")
|
||||||
|
container: dict[str, Any] = value if isinstance(value, dict) else data
|
||||||
|
|
||||||
|
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
|
||||||
|
raw_links: list[dict[str, Any]] = container.get("links") or []
|
||||||
|
|
||||||
|
if not isinstance(raw_nodes, list):
|
||||||
|
raise ValueError("Malformed networkmap: 'nodes' is not a list")
|
||||||
|
if not isinstance(raw_links, list):
|
||||||
|
raise ValueError("Malformed networkmap: 'links' is not a list")
|
||||||
|
|
||||||
|
nodes_list: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
coordinator_id: str | None = None
|
||||||
|
|
||||||
|
for entry in raw_nodes:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
node = _node_from_z2m(entry)
|
||||||
|
if node is None or node["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(node["id"])
|
||||||
|
nodes_list.append(node)
|
||||||
|
if node["device_type"] == "Coordinator":
|
||||||
|
coordinator_id = node["id"]
|
||||||
|
|
||||||
|
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
|
||||||
|
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
|
||||||
|
# resolve which router an end device hangs off; do NOT emit edges directly
|
||||||
|
# from links. The final edge set is the strict parent→child tree built
|
||||||
|
# from parent_id below — that avoids duplicate edges and keeps the visual
|
||||||
|
# flow consistent (parent bottom → child top).
|
||||||
|
raw_edges: list[dict[str, Any]] = []
|
||||||
|
lqi_by_id: dict[str, int] = {}
|
||||||
|
|
||||||
|
for link in raw_links:
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
continue
|
||||||
|
src_obj = link.get("source") or {}
|
||||||
|
tgt_obj = link.get("target") or {}
|
||||||
|
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
|
||||||
|
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
|
||||||
|
if not src or not tgt:
|
||||||
|
continue
|
||||||
|
if src not in seen_ids or tgt not in seen_ids:
|
||||||
|
continue
|
||||||
|
raw_edges.append({"source": src, "target": tgt})
|
||||||
|
lqi = link.get("lqi") or link.get("linkquality")
|
||||||
|
if isinstance(lqi, int) and tgt not in lqi_by_id:
|
||||||
|
lqi_by_id[tgt] = lqi
|
||||||
|
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["id"] in lqi_by_id:
|
||||||
|
node["lqi"] = lqi_by_id[node["id"]]
|
||||||
|
|
||||||
|
# Build parent_id hierarchy: coordinator → routers → end devices
|
||||||
|
if coordinator_id:
|
||||||
|
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["device_type"] == "Router":
|
||||||
|
node["parent_id"] = coordinator_id
|
||||||
|
elif node["device_type"] == "EndDevice":
|
||||||
|
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||||
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
|
# Final edges = strict parent → child tree (one edge per non-coordinator)
|
||||||
|
edges_list: list[dict[str, Any]] = [
|
||||||
|
{"source": node["parent_id"], "target": node["id"]}
|
||||||
|
for node in nodes_list
|
||||||
|
if node.get("parent_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
return nodes_list, edges_list
|
||||||
|
|
||||||
|
|
||||||
|
def _find_parent_router(
|
||||||
|
device_id: str,
|
||||||
|
router_ids: set[str],
|
||||||
|
edges: list[dict[str, Any]],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the first router that has a direct edge to device_id."""
|
||||||
|
for edge in edges:
|
||||||
|
src: str = edge["source"]
|
||||||
|
tgt: str = edge["target"]
|
||||||
|
if tgt == device_id and src in router_ids:
|
||||||
|
return src
|
||||||
|
if src == device_id and tgt in router_ids:
|
||||||
|
return tgt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_networkmap(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
base_topic: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: if the broker does not respond in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is malformed.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"aiomqtt is required for Zigbee import. "
|
||||||
|
"Install it with: pip install aiomqtt"
|
||||||
|
)
|
||||||
|
|
||||||
|
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||||
|
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||||
|
|
||||||
|
response_payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
) as client:
|
||||||
|
await client.subscribe(response_topic)
|
||||||
|
# Give the broker a brief window to register the subscription
|
||||||
|
# before we publish the request. Without this, brokers that
|
||||||
|
# race SUBACK with our PUBLISH may deliver the response before
|
||||||
|
# the subscription is active and we'd hang until timeout.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await client.publish(
|
||||||
|
request_topic,
|
||||||
|
json.dumps({"type": "raw", "routes": False}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait_for_response() -> None:
|
||||||
|
async for message in client.messages:
|
||||||
|
if str(message.topic) != response_topic:
|
||||||
|
continue
|
||||||
|
raw = message.payload
|
||||||
|
try:
|
||||||
|
payload_str = (
|
||||||
|
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||||
|
)
|
||||||
|
response_payload.update(json.loads(payload_str))
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Malformed networkmap response: {exc}"
|
||||||
|
) from exc
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||||
|
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
||||||
|
|
||||||
|
if not response_payload:
|
||||||
|
raise ValueError("Empty networkmap response received")
|
||||||
|
|
||||||
|
return parse_networkmap(response_payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mqtt_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||||
|
|
||||||
|
Returns True on success, raises ConnectionError on failure.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError("aiomqtt is required")
|
||||||
|
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Connection to broker timed out") from exc
|
||||||
@@ -9,7 +9,7 @@ pydantic-settings==2.5.2
|
|||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.26
|
python-multipart==0.0.27
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
@@ -17,6 +17,7 @@ types-PyYAML==6.0.12.20240917
|
|||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.131.0
|
zeroconf==0.131.0
|
||||||
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
|
|||||||
@@ -140,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert len(hidden_res.json()) == 1
|
assert len(hidden_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Restore hidden device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide first
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] is True
|
||||||
|
|
||||||
|
# Now back in pending, gone from hidden
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert hidden_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-restore",
|
||||||
|
headers=headers,
|
||||||
|
json={"device_ids": [pending_device.id]},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] == 1
|
||||||
|
assert res.json()["skipped"] == 0
|
||||||
|
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
# --- Ignore device ---
|
# --- Ignore device ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -542,3 +585,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
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""API endpoint tests for /api/v1/zigbee/*."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/test-connection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is True
|
||||||
|
assert "success" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is False
|
||||||
|
assert "refused" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422 # pydantic validation error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_NODES = [
|
||||||
|
{
|
||||||
|
"id": "0x00000000",
|
||||||
|
"label": "Coordinator",
|
||||||
|
"type": "zigbee_coordinator",
|
||||||
|
"ieee_address": "0x00000000",
|
||||||
|
"friendly_name": "Coordinator",
|
||||||
|
"device_type": "Coordinator",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0x00000001",
|
||||||
|
"label": "router_1",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ieee_address": "0x00000001",
|
||||||
|
"friendly_name": "router_1",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "CC2530",
|
||||||
|
"vendor": "Texas Instruments",
|
||||||
|
"lqi": 230,
|
||||||
|
"parent_id": "0x00000000",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SAMPLE_EDGES = [
|
||||||
|
{"source": "0x00000000", "target": "0x00000001"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"base_topic": "zigbee2mqtt",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 2
|
||||||
|
assert len(data["nodes"]) == 2
|
||||||
|
assert len(data["edges"]) == 1
|
||||||
|
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
|
||||||
|
assert coordinator["ieee_address"] == "0x00000000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_username": "admin",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
"base_topic": "z2m",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
mock_fetch.assert_called_once_with(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="z2m",
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
tls=False,
|
||||||
|
tls_insecure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 502
|
||||||
|
assert "broker unreachable" in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = TimeoutError("timed out")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ValueError("malformed response")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
||||||
|
"""An empty Zigbee network (coordinator only) is a valid response."""
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 0
|
||||||
|
assert data["nodes"] == []
|
||||||
|
assert data["edges"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_tls": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
kwargs = mock_fetch.call_args.kwargs
|
||||||
|
assert kwargs["tls"] is True
|
||||||
|
assert kwargs["tls_insecure"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_tls": False,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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_endpoint_creates_zigbee_scan_run(
|
||||||
|
client: AsyncClient, headers: dict
|
||||||
|
) -> None:
|
||||||
|
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
|
||||||
|
the actual networkmap fetch + pending persist runs in the background."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.zigbee._background_zigbee_import",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
run = res.json()
|
||||||
|
assert run["kind"] == "zigbee"
|
||||||
|
assert run["status"] == "running"
|
||||||
|
assert run["ranges"] == ["localhost:1883"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_creates_coordinator_and_pending(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
assert result.device_count == 3
|
||||||
|
assert result.pending_created == 2
|
||||||
|
assert result.pending_updated == 0
|
||||||
|
assert result.coordinator is not None
|
||||||
|
assert result.coordinator.ieee_address == "0xCOORD"
|
||||||
|
assert result.coordinator_already_existed is False
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_idempotent_updates_existing(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 99
|
||||||
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
assert result.pending_created == 0
|
||||||
|
assert result.pending_updated == 2
|
||||||
|
assert result.coordinator_already_existed is True
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_replaces_links(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDeviceLink
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
||||||
|
|
||||||
|
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].source_ieee, rows[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:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_tls": True,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
kwargs = mock_conn.call_args.kwargs
|
||||||
|
assert kwargs["tls"] is True
|
||||||
|
assert kwargs["tls_insecure"] is True
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
"""Unit tests for zigbee_service: parser and hierarchy builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import aiomqtt # noqa: F401
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
_find_parent_router,
|
||||||
|
_z2m_type_to_homelable,
|
||||||
|
fetch_networkmap,
|
||||||
|
parse_networkmap,
|
||||||
|
)
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
test_mqtt_connection as _test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper builders — real Z2M `bridge/response/networkmap` shape
|
||||||
|
# (data.value.nodes + data.value.links)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_node(
|
||||||
|
ieee: str,
|
||||||
|
device_type: str = "EndDevice",
|
||||||
|
friendly_name: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
vendor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"ieeeAddr": ieee,
|
||||||
|
"type": device_type,
|
||||||
|
"friendlyName": friendly_name or ieee,
|
||||||
|
}
|
||||||
|
if model or vendor:
|
||||||
|
entry["definition"] = {"model": model, "vendor": vendor}
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": {"ieeeAddr": source_ieee},
|
||||||
|
"target": {"ieeeAddr": target_ieee},
|
||||||
|
"lqi": lqi,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": False,
|
||||||
|
"value": {"nodes": nodes, "links": links or []},
|
||||||
|
},
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _z2m_type_to_homelable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestZ2mTypeToHomelable:
|
||||||
|
def test_coordinator(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
|
||||||
|
|
||||||
|
def test_router(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Router") == "zigbee_router"
|
||||||
|
|
||||||
|
def test_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
def test_unknown_defaults_to_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_networkmap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseNetworkmap:
|
||||||
|
def test_empty_payload(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap({})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_empty_value(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap(_wrap([], []))
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_only(self) -> None:
|
||||||
|
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||||
|
assert nodes[0]["ieee_address"] == "0x0000000000000000"
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_router_enddevice(self) -> None:
|
||||||
|
coord_ieee = "0x0000000000000000"
|
||||||
|
router_ieee = "0x0000000000000001"
|
||||||
|
end_ieee = "0x0000000000000002"
|
||||||
|
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[
|
||||||
|
_make_node(coord_ieee, "Coordinator", "Coordinator"),
|
||||||
|
_make_node(router_ieee, "Router", "my_router"),
|
||||||
|
_make_node(end_ieee, "EndDevice"),
|
||||||
|
],
|
||||||
|
links=[
|
||||||
|
_make_link(coord_ieee, router_ieee),
|
||||||
|
_make_link(router_ieee, end_ieee),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes}
|
||||||
|
|
||||||
|
assert coord_ieee in node_by_id
|
||||||
|
assert router_ieee in node_by_id
|
||||||
|
assert end_ieee in node_by_id
|
||||||
|
|
||||||
|
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
|
||||||
|
assert node_by_id[router_ieee]["type"] == "zigbee_router"
|
||||||
|
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
|
||||||
|
|
||||||
|
# Parent hierarchy
|
||||||
|
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
|
||||||
|
assert node_by_id[end_ieee]["parent_id"] == router_ieee
|
||||||
|
assert len(edges) == 2
|
||||||
|
|
||||||
|
def test_no_duplicate_nodes(self) -> None:
|
||||||
|
ieee = "0x0000000000000001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
|
||||||
|
)
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
|
||||||
|
def test_edges_built_correctly(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
router = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||||
|
links=[_make_link(coord, router)],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert len(edges) == 1
|
||||||
|
assert edges[0]["source"] == coord
|
||||||
|
assert edges[0]["target"] == router
|
||||||
|
|
||||||
|
def test_friendly_name_used_as_label(self) -> None:
|
||||||
|
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert nodes[0]["label"] == "Living Room Sensor"
|
||||||
|
|
||||||
|
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0003"
|
||||||
|
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
end_node = next(n for n in nodes if n["id"] == end)
|
||||||
|
assert end_node["parent_id"] == coord
|
||||||
|
|
||||||
|
def test_missing_ieee_skipped(self) -> None:
|
||||||
|
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_lqi_propagated_from_link_to_target_node(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||||
|
links=[_make_link(coord, end, lqi=180)],
|
||||||
|
)
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
end_node = next(n for n in nodes if n["id"] == end)
|
||||||
|
assert end_node["lqi"] == 180
|
||||||
|
|
||||||
|
def test_definition_model_and_vendor_extracted(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
|
||||||
|
])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert nodes[0]["model"] == "WSDCGQ11LM"
|
||||||
|
assert nodes[0]["vendor"] == "Aqara"
|
||||||
|
|
||||||
|
def test_legacy_shape_without_value_wrapper(self) -> None:
|
||||||
|
"""Some Z2M variants put nodes/links directly under data."""
|
||||||
|
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||||
|
|
||||||
|
def test_routes_bool_is_ignored(self) -> None:
|
||||||
|
"""`routes: false` echo from the request must not crash the parser."""
|
||||||
|
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_malformed_nodes_not_list_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a list"):
|
||||||
|
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
|
||||||
|
|
||||||
|
def test_link_to_unknown_node_dropped(self) -> None:
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node("0x01", "Coordinator")],
|
||||||
|
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_bidirectional_links_yield_single_edge(self) -> None:
|
||||||
|
"""Z2M links are bidirectional — every pair appears twice. The output
|
||||||
|
must collapse to a single parent→child edge (no back-link, no dup)."""
|
||||||
|
coord = "0x0000"
|
||||||
|
router = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||||
|
links=[
|
||||||
|
_make_link(coord, router),
|
||||||
|
_make_link(router, coord), # reverse direction
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert edges == [{"source": coord, "target": router}]
|
||||||
|
|
||||||
|
def test_router_mesh_siblings_dropped(self) -> None:
|
||||||
|
"""Router↔router mesh paths in `links` must NOT produce sibling edges
|
||||||
|
in the final tree. Each router gets exactly one edge from coordinator."""
|
||||||
|
coord = "0x0000"
|
||||||
|
r1 = "0x0001"
|
||||||
|
r2 = "0x0002"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[
|
||||||
|
_make_node(coord, "Coordinator"),
|
||||||
|
_make_node(r1, "Router"),
|
||||||
|
_make_node(r2, "Router"),
|
||||||
|
],
|
||||||
|
links=[
|
||||||
|
_make_link(coord, r1),
|
||||||
|
_make_link(coord, r2),
|
||||||
|
_make_link(r1, r2), # mesh sibling — must be dropped
|
||||||
|
_make_link(r2, r1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
pairs = {(e["source"], e["target"]) for e in edges}
|
||||||
|
assert pairs == {(coord, r1), (coord, r2)}
|
||||||
|
|
||||||
|
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||||
|
links=[_make_link(end, coord)], # back-edge from end to coord
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
# No edge should target the coordinator
|
||||||
|
assert all(e["target"] != coord for e in edges)
|
||||||
|
assert edges == [{"source": coord, "target": end}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _find_parent_router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFindParentRouter:
|
||||||
|
def test_finds_router_as_source(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "r1", "target": "e1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_finds_router_as_target(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "e1", "target": "r1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_router(self) -> None:
|
||||||
|
router_ids: set[str] = set()
|
||||||
|
edges = [{"source": "e1", "target": "e2"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) is None
|
||||||
|
|
||||||
|
def test_returns_none_empty_edges(self) -> None:
|
||||||
|
assert _find_parent_router("e1", {"r1"}, []) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_networkmap (integration-style with mocked aiomqtt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SAMPLE_RESPONSE_PAYLOAD = {
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": False,
|
||||||
|
"value": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"ieeeAddr": "0x00000000",
|
||||||
|
"type": "Coordinator",
|
||||||
|
"friendlyName": "Coordinator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ieeeAddr": "0x00000001",
|
||||||
|
"type": "Router",
|
||||||
|
"friendlyName": "router_1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"source": {"ieeeAddr": "0x00000000"},
|
||||||
|
"target": {"ieeeAddr": "0x00000001"},
|
||||||
|
"lqi": 230,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_success() -> None:
|
||||||
|
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
|
||||||
|
|
||||||
|
class _FakeMessage:
|
||||||
|
topic = "zigbee2mqtt/bridge/response/networkmap"
|
||||||
|
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
|
||||||
|
_yielded = False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._yielded:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._yielded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, _topic: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish(self, _topic: str, _payload: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self):
|
||||||
|
return _FakeMessage()
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
nodes, edges = await fetch_networkmap(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
|
||||||
|
assert any(n["type"] == "zigbee_router" for n in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_connection_error() -> None:
|
||||||
|
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await fetch_networkmap(
|
||||||
|
mqtt_host="bad-host",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_success() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
result = await _test_mqtt_connection("localhost", 1883)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_failure() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await _test_mqtt_connection("bad-host", 1883)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TLS context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import ssl # noqa: E402
|
||||||
|
|
||||||
|
from app.services.zigbee_service import _build_tls_context # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tls_context_secure_verifies_cert() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=False)
|
||||||
|
assert ctx.check_hostname is True
|
||||||
|
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tls_context_insecure_disables_verification() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=True)
|
||||||
|
assert ctx.check_hostname is False
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_passes_tls_context() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 8883, tls=True)
|
||||||
|
kwargs = mock_aiomqtt.Client.call_args.kwargs
|
||||||
|
assert kwargs["tls_context"] is not None
|
||||||
|
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 1883, tls=False)
|
||||||
|
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
|
||||||
|
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
assert ctx.check_hostname is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sanitize MQTT errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(
|
||||||
|
Exception("Not authorized: bad username or password for user=admin pwd=secret")
|
||||||
|
)
|
||||||
|
assert msg == "Authentication failed"
|
||||||
|
assert "admin" not in msg
|
||||||
|
assert "secret" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_refused() -> None:
|
||||||
|
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_dns_failure_strips_host() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(
|
||||||
|
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
|
||||||
|
)
|
||||||
|
assert msg == "Broker hostname could not be resolved"
|
||||||
|
assert "broker.internal.lan" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_tls_error() -> None:
|
||||||
|
assert _sanitize_mqtt_error(
|
||||||
|
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||||
|
) == "TLS handshake failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_unknown_falls_back_to_generic() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
|
||||||
|
assert msg == "MQTT connection failed"
|
||||||
|
assert "hunter2" not in msg
|
||||||
|
assert "admin" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError) as ei:
|
||||||
|
await fetch_networkmap(
|
||||||
|
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
||||||
|
)
|
||||||
|
msg = str(ei.value)
|
||||||
|
assert "hunter2" not in msg
|
||||||
|
assert "admin" not in msg
|
||||||
|
assert msg == "Authentication failed"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Zigbee2MQTT Network Map Importer
|
||||||
|
|
||||||
|
This feature lets you connect Homelable to your MQTT broker, fetch the Zigbee2MQTT network topology, and drop all Zigbee devices onto the canvas as typed nodes with proper hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
- **Automatic device discovery** — Requests the Z2M networkmap via the MQTT bridge API and parses the full device list
|
||||||
|
- **Typed nodes** — Devices are mapped to three homelable node types:
|
||||||
|
- `zigbee_coordinator` — The Zigbee coordinator (hub)
|
||||||
|
- `zigbee_router` — Mains-powered router devices
|
||||||
|
- `zigbee_enddevice` — Battery-powered end devices (sensors, bulbs, etc.)
|
||||||
|
- **Hierarchy** — `parent_id` is set automatically: coordinator → routers → end devices
|
||||||
|
- **LQI display** — Link Quality Indicator is stored as a node property
|
||||||
|
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
|
||||||
|
2. **Zigbee2MQTT** connected to the broker and running
|
||||||
|
3. Z2M must respond to networkmap requests on:
|
||||||
|
- **Request topic:** `<base_topic>/bridge/request/networkmap`
|
||||||
|
- **Response topic:** `<base_topic>/bridge/response/networkmap`
|
||||||
|
- The default base topic is `zigbee2mqtt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step Usage
|
||||||
|
|
||||||
|
### 1. Open the Zigbee Import dialog
|
||||||
|
|
||||||
|
Click **Zigbee Import** in the left sidebar (below "Scan Network").
|
||||||
|
|
||||||
|
### 2. Configure the MQTT connection
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Broker Host | — | IP or hostname of your MQTT broker |
|
||||||
|
| Port | 1883 | MQTT broker port |
|
||||||
|
| Base Topic | `zigbee2mqtt` | Zigbee2MQTT base topic |
|
||||||
|
| Username | _(optional)_ | MQTT username if authentication is enabled |
|
||||||
|
| Password | _(optional)_ | MQTT password |
|
||||||
|
|
||||||
|
### 3. Test the connection (optional)
|
||||||
|
|
||||||
|
Click **Test Connection** to verify broker reachability before fetching devices.
|
||||||
|
A green indicator confirms success; red shows the error message from the broker.
|
||||||
|
|
||||||
|
### 4. Fetch devices
|
||||||
|
|
||||||
|
Click **Fetch Devices**. Homelable will:
|
||||||
|
1. Connect to the broker
|
||||||
|
2. Subscribe to the response topic
|
||||||
|
3. Publish `{"type": "raw", "routes": false}` to the request topic
|
||||||
|
4. Wait up to 60 seconds for the network map response (large meshes can take 30 s+)
|
||||||
|
5. Parse and group devices by type
|
||||||
|
|
||||||
|
### 5. Select and add to canvas
|
||||||
|
|
||||||
|
Devices are grouped by type (Coordinator / Router / End Device).
|
||||||
|
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
|
||||||
|
|
||||||
|
> **Tip:** All devices are selected by default. Uncheck any you don't want.
|
||||||
|
|
||||||
|
### 6. Arrange on the canvas
|
||||||
|
|
||||||
|
Devices are placed in a grid at the top-right of the canvas.
|
||||||
|
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MQTT Configuration Tips
|
||||||
|
|
||||||
|
### Mosquitto without authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mosquitto with password file
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
password_file /etc/mosquitto/passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a user:
|
||||||
|
```bash
|
||||||
|
mosquitto_passwd -c /etc/mosquitto/passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zigbee2MQTT `configuration.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
base_topic: zigbee2mqtt
|
||||||
|
server: mqtt://localhost:1883
|
||||||
|
# user: mqtt_user
|
||||||
|
# password: mqtt_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Z2M Versions
|
||||||
|
|
||||||
|
The networkmap bridge API is available in **Zigbee2MQTT 1.x and 2.x**.
|
||||||
|
Tested against Z2M 1.35+ and 2.x.
|
||||||
|
|
||||||
|
The importer uses the `raw` topology format (`routes: false`) which is the most widely supported mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
|
||||||
|
| "Timed out waiting for networkmap" | Z2M not running or wrong base_topic | Verify Z2M is connected, check base_topic setting |
|
||||||
|
| 0 devices returned | Z2M has no devices paired | Pair at least one device first |
|
||||||
|
| "Malformed networkmap response" | Z2M returned unexpected format | Check Z2M version; open an issue |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_(Screenshots will be added in a future release)_
|
||||||
Generated
+29
-92
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.10.2",
|
"version": "1.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.10.2",
|
"version": "1.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
@@ -1646,9 +1646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.12",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -2520,9 +2520,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2537,9 +2534,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2554,9 +2548,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2571,9 +2562,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2588,9 +2576,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2605,9 +2590,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2622,9 +2604,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2639,9 +2618,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2656,9 +2632,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2673,9 +2646,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2690,9 +2660,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2707,9 +2674,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2724,9 +2688,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2975,9 +2936,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2995,9 +2953,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3015,9 +2970,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3035,9 +2987,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4186,12 +4135,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -5602,12 +5551,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.3.2",
|
"version": "8.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "10.1.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -5693,9 +5642,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5857,9 +5806,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6196,9 +6145,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.11",
|
"version": "4.12.18",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||||
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -6354,9 +6303,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -6935,9 +6884,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6959,9 +6905,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6983,9 +6926,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7007,9 +6947,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7878,9 +7815,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|||||||
+85
-20
@@ -19,9 +19,11 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -30,6 +32,7 @@ import { canvasApi } from '@/api/client'
|
|||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -45,8 +48,16 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
|
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||||
|
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||||
|
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
|
||||||
|
setPendingHighlightId(undefined)
|
||||||
|
setPendingModalStatus(status)
|
||||||
|
setPendingModalOpen(true)
|
||||||
|
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
|
||||||
|
}, [])
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
@@ -55,6 +66,7 @@ export default function App() {
|
|||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -315,6 +327,54 @@ export default function App() {
|
|||||||
setExportModalOpen(true)
|
setExportModalOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
// Place nodes in a grid starting at x=500, y=100
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 170
|
||||||
|
const SPACING_Y = 100
|
||||||
|
zigbeeNodes.forEach((zn, i) => {
|
||||||
|
const id = zn.id
|
||||||
|
const col = i % COLS
|
||||||
|
const row = Math.floor(i / COLS)
|
||||||
|
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
||||||
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: zn.type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: zn.friendly_name,
|
||||||
|
type: zn.type as NodeData['type'],
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: [],
|
||||||
|
...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
|
||||||
|
...(zn.model ? { os: zn.model } : {}),
|
||||||
|
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Add IoT edges between Zigbee devices: parent bottom -> child top
|
||||||
|
zigbeeEdges.forEach((ze) => {
|
||||||
|
onConnect({
|
||||||
|
source: ze.source,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: ze.target,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
// Auto-select only the freshly imported nodes so the user can drag the
|
||||||
|
// whole subtree as a group.
|
||||||
|
const importedIds = new Set(zigbeeNodes.map((zn) => zn.id))
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||||
|
selectedNodeIds: Array.from(importedIds),
|
||||||
|
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||||
|
}))
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -384,10 +444,10 @@ export default function App() {
|
|||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
highlightPendingId={highlightPendingId}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -409,14 +469,7 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
@@ -483,6 +536,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZigbeeImportModal
|
||||||
|
open={zigbeeImportOpen}
|
||||||
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -528,17 +593,17 @@ export default function App() {
|
|||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<PendingDevicesModal
|
||||||
|
open={pendingModalOpen}
|
||||||
|
onClose={() => setPendingModalOpen(false)}
|
||||||
|
highlightId={pendingHighlightId}
|
||||||
|
initialStatus={pendingModalStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
open={exportModalOpen}
|
open={exportModalOpen}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
|||||||
@@ -58,11 +58,27 @@ 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 }),
|
||||||
|
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||||
|
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { 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'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
@@ -72,3 +88,50 @@ export const settingsApi = {
|
|||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const zigbeeApi = {
|
||||||
|
testConnection: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (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<{
|
||||||
|
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||||
|
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<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -26,3 +26,7 @@ export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
|||||||
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
// Zigbee node types
|
||||||
|
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
|
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||||
|
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
@@ -24,4 +24,7 @@ export const nodeTypes = {
|
|||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
|
zigbee_router: ZigbeeRouterNode,
|
||||||
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||||
|
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
|
|
||||||
|
interface PendingDevicesModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
highlightId?: string
|
||||||
|
initialStatus?: 'pending' | 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT_COLORS: Record<number, string> = {
|
||||||
|
22: '#a855f7', // SSH purple
|
||||||
|
80: '#00d4ff', // HTTP cyan
|
||||||
|
443: '#39d353', // HTTPS green
|
||||||
|
53: '#e3b341', // DNS amber
|
||||||
|
3306: '#a855f7', // MySQL
|
||||||
|
5432: '#a855f7', // Postgres
|
||||||
|
6379: '#f85149', // Redis
|
||||||
|
9090: '#e3b341', // Prometheus
|
||||||
|
3000: '#00d4ff', // Grafana/dev
|
||||||
|
8080: '#00d4ff',
|
||||||
|
8443: '#39d353',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
hypervisor: '#ff6e00',
|
||||||
|
nas: '#39d353',
|
||||||
|
automation: '#a855f7',
|
||||||
|
containers: '#00d4ff',
|
||||||
|
network: '#39d353',
|
||||||
|
security: '#f85149',
|
||||||
|
monitoring: '#e3b341',
|
||||||
|
database: '#a855f7',
|
||||||
|
web: '#00d4ff',
|
||||||
|
media: '#ff6e00',
|
||||||
|
iot: '#e3b341',
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceColor(port: number | null | undefined, category?: string | null): string {
|
||||||
|
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
|
||||||
|
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
|
||||||
|
return '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
isp: Globe,
|
||||||
|
router: Router,
|
||||||
|
server: Server,
|
||||||
|
proxmox: Layers,
|
||||||
|
vm: Box,
|
||||||
|
lxc: Container,
|
||||||
|
nas: HardDrive,
|
||||||
|
iot: Cpu,
|
||||||
|
ap: Wifi,
|
||||||
|
switch: Network,
|
||||||
|
generic: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||||
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
|
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||||
|
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||||
|
return 'ip'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_PORTS = new Set([22, 80, 443])
|
||||||
|
|
||||||
|
function specialServiceName(d: PendingDevice): string | undefined {
|
||||||
|
const candidates = (d.services ?? []).filter(
|
||||||
|
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||||
|
)
|
||||||
|
// Deprioritize generic web category so apps like home assistant / jellyfin win
|
||||||
|
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
|
||||||
|
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(d: PendingDevice): string {
|
||||||
|
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||||
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||||
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||||
|
setDevices(res.data)
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to load ${statusFilter} devices`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => { if (open) load() }, [open, load])
|
||||||
|
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||||
|
|
||||||
|
// Reset transient state when reopening
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setSearch('')
|
||||||
|
} else {
|
||||||
|
setStatusFilter(initialStatus)
|
||||||
|
}
|
||||||
|
}, [open, initialStatus])
|
||||||
|
|
||||||
|
const distinctTypes = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||||
|
return [...set].sort()
|
||||||
|
}, [devices])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return devices.filter((d) => {
|
||||||
|
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||||
|
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||||
|
if (q) {
|
||||||
|
const hay = [
|
||||||
|
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||||
|
...d.services.map((s) => s.service_name),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [devices, search, sourceFilter, typeFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || loading || !open) return
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, [highlightId, loading, open, filtered])
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (d: PendingDevice) => {
|
||||||
|
if (selectMode) { toggleSelect(d.id); return }
|
||||||
|
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||||
|
setSelected(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.restore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
toast.success(`Restored ${deviceLabel(device)}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to restore device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkRestore = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkRestore(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk restore devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterSelectMode = () => {
|
||||||
|
setSelectMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitSelectMode = () => {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllVisible = () => {
|
||||||
|
setSelectedIds(new Set(filtered.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
const targets = filtered
|
||||||
|
if (targets.length === 0) return
|
||||||
|
const filtersActive = targets.length !== devices.length
|
||||||
|
try {
|
||||||
|
if (filtersActive) {
|
||||||
|
const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id)))
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length
|
||||||
|
const removedIds = new Set(
|
||||||
|
targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id)
|
||||||
|
)
|
||||||
|
setDevices((prev) => prev.filter((d) => !removedIds.has(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
||||||
|
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
||||||
|
} else {
|
||||||
|
await scanApi.clearPending()
|
||||||
|
setDevices([])
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success('Pending devices cleared')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to clear pending devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
const fallbackLabel = deviceLabel(device)
|
||||||
|
const nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type: (device.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: 'unknown',
|
||||||
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
}
|
||||||
|
const res = await scanApi.approve(device.id, nodeData)
|
||||||
|
const nodeId = res.data.node_id
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: nodeData.type,
|
||||||
|
position: { x: 400, y: 300 },
|
||||||
|
data: { ...nodeData, status: 'unknown' as const },
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to approve device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHide = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.hide(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
toast.success('Device hidden')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to hide device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIgnore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.ignore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkApprove = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkApprove(ids)
|
||||||
|
const deviceToNode: Record<string, string> = {}
|
||||||
|
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||||
|
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||||
|
approvedDevices.forEach((d, i) => {
|
||||||
|
const nodeId = deviceToNode[d.id]
|
||||||
|
if (!nodeId) return
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
|
data: {
|
||||||
|
label: deviceLabel(d),
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: d.ip ?? undefined,
|
||||||
|
hostname: d.hostname ?? undefined,
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk approve devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkHide = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkHide(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk hide devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (inField) return
|
||||||
|
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||||
|
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||||
|
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||||
|
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, selectMode, selectedIds, filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{statusFilter === 'pending' && devices.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
|
||||||
|
title={filtered.length !== devices.length ? `Remove ${filtered.length} filtered` : 'Clear all pending'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search name, IP, MAC, IEEE, service…"
|
||||||
|
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('all')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('ip')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
IP scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('zigbee')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Zigbee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
aria-label="Type filter"
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('pending')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('hidden')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Hidden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||||
|
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||||
|
title="Toggle select mode (s)"
|
||||||
|
>
|
||||||
|
{selectMode ? 'Exit select' : 'Select mode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-10">
|
||||||
|
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||||
|
{filtered.map((d) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={d.id}
|
||||||
|
device={d}
|
||||||
|
selected={selectedIds.has(d.id)}
|
||||||
|
selectMode={selectMode}
|
||||||
|
highlighted={d.id === highlightId}
|
||||||
|
onClick={() => handleCardClick(d)}
|
||||||
|
cardRef={d.id === highlightId ? highlightRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection action bar */}
|
||||||
|
{selectMode && (
|
||||||
|
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={selectAllVisible}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Select all visible ({filtered.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Approve ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkHide}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Hide ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{statusFilter === 'hidden' && (
|
||||||
|
<button
|
||||||
|
onClick={handleBulkRestore}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Restore ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<PendingDeviceModal
|
||||||
|
device={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onHide={handleHide}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceCardProps {
|
||||||
|
device: PendingDevice
|
||||||
|
selected: boolean
|
||||||
|
selectMode: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
onClick: () => void
|
||||||
|
cardRef?: React.Ref<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||||
|
const source = inferSource(device)
|
||||||
|
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
const label = deviceLabel(device)
|
||||||
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||||
|
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||||
|
const services = device.services ?? []
|
||||||
|
const visibleServices = services.slice(0, 4)
|
||||||
|
const moreServices = services.length - visibleServices.length
|
||||||
|
|
||||||
|
const borderClass = highlighted
|
||||||
|
? 'border-[#e3b341] bg-[#2d3748]'
|
||||||
|
: selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
|
||||||
|
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={cardRef}
|
||||||
|
onClick={onClick}
|
||||||
|
data-testid={`pending-card-${device.id}`}
|
||||||
|
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
||||||
|
>
|
||||||
|
{selectMode && selected && (
|
||||||
|
<CheckCircle2
|
||||||
|
size={18}
|
||||||
|
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selectMode && device.status === 'hidden' && (
|
||||||
|
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
||||||
|
>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
{device.suggested_type && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
{device.suggested_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tech grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
|
||||||
|
{device.ip && <InfoLine label="IP" value={device.ip} />}
|
||||||
|
{device.mac && <InfoLine label="MAC" value={device.mac} />}
|
||||||
|
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
|
||||||
|
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
|
||||||
|
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
|
||||||
|
{device.model && <InfoLine label="Model" value={device.model} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
{visibleServices.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{visibleServices.map((s, i) => {
|
||||||
|
const color = serviceColor(s.port, s.category)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${s.port}-${s.protocol}-${i}`}
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${color}22`, color }}
|
||||||
|
title={`${s.service_name} (${s.protocol}/${s.port})`}
|
||||||
|
>
|
||||||
|
{s.service_name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{moreServices > 0 && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
|
||||||
|
+{moreServices}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||||
|
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
|
||||||
|
<span className="font-mono text-foreground truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,8 +33,10 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
|||||||
).slice(0, 6)
|
).slice(0, 6)
|
||||||
|
|
||||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
const pendingResults = q.length === 0 ? [] : 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)
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
const mockBulkApprove = vi.fn()
|
||||||
|
const mockBulkHide = vi.fn()
|
||||||
|
const mockRestore = vi.fn()
|
||||||
|
const mockBulkRestore = vi.fn()
|
||||||
|
const mockApprove = vi.fn()
|
||||||
|
const mockHide = vi.fn()
|
||||||
|
const mockPending = vi.fn()
|
||||||
|
const mockHidden = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
pending: (...a: unknown[]) => mockPending(...a),
|
||||||
|
hidden: (...a: unknown[]) => mockHidden(...a),
|
||||||
|
clearPending: vi.fn().mockResolvedValue({}),
|
||||||
|
approve: (...a: unknown[]) => mockApprove(...a),
|
||||||
|
hide: (...a: unknown[]) => mockHide(...a),
|
||||||
|
ignore: vi.fn().mockResolvedValue({}),
|
||||||
|
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
|
||||||
|
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
|
||||||
|
restore: (...a: unknown[]) => mockRestore(...a),
|
||||||
|
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||||
|
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
||||||
|
device ? <div data-testid="approval-modal" /> : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const DEVICE_IP = {
|
||||||
|
id: 'dev-a',
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
hostname: 'host-a',
|
||||||
|
mac: 'aa:bb:cc:dd:ee:01',
|
||||||
|
os: null,
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
|
||||||
|
suggested_type: 'server',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
discovered_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_ZIGBEE = {
|
||||||
|
id: 'dev-b',
|
||||||
|
ip: null,
|
||||||
|
hostname: null,
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'iot',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'zigbee',
|
||||||
|
ieee_address: '0x00124b001234abcd',
|
||||||
|
friendly_name: 'living-room-bulb',
|
||||||
|
vendor: 'Philips',
|
||||||
|
model: 'Hue White',
|
||||||
|
discovered_at: '2026-01-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
|
// setState is used by injectAutoEdges
|
||||||
|
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||||
|
mockHidden.mockResolvedValue({ data: [] })
|
||||||
|
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||||
|
mockHide.mockResolvedValue({ data: {} })
|
||||||
|
mockBulkApprove.mockResolvedValue({
|
||||||
|
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
||||||
|
})
|
||||||
|
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||||
|
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||||
|
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PendingDevicesModal', () => {
|
||||||
|
it('loads and renders pending devices on open', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by search query', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (zigbee only)', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by suggested type', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
|
||||||
|
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to hidden status loads hidden devices', async () => {
|
||||||
|
mockHidden.mockResolvedValue({
|
||||||
|
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
|
||||||
|
expect(mockHidden).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles selection in select mode instead of opening approval', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('select all visible selects only filtered devices', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk approve calls API with selected ids', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk hide calls API with selected ids', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not load when closed', () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects initialStatus=hidden', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a hidden card restores it instead of opening approval', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk restore in hidden mode calls API with selected ids', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -8,23 +8,19 @@ import { scanApi, settingsApi } from '@/api/client'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||||
|
|
||||||
const ALL_VIEWS = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
|
|
||||||
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
|
|
||||||
]
|
]
|
||||||
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
|
|
||||||
|
|
||||||
interface ScanRun {
|
interface ScanRun {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
ranges: string[]
|
ranges: string[]
|
||||||
devices_found: number
|
devices_found: number
|
||||||
started_at: string
|
started_at: string
|
||||||
@@ -36,13 +32,13 @@ interface SidebarProps {
|
|||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
highlightPendingId?: string
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -87,23 +83,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
|
|
||||||
{/* Views */}
|
{/* Views */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<nav className="flex flex-col gap-0.5 p-2">
|
||||||
{VIEWS.map(({ id, icon: Icon, label }) => (
|
<SidebarItem
|
||||||
|
icon={LayoutDashboard}
|
||||||
|
label="Canvas"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'canvas'}
|
||||||
|
onClick={() => setActiveView('canvas')}
|
||||||
|
/>
|
||||||
|
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={id}
|
key={t.kind}
|
||||||
icon={Icon}
|
icon={t.icon}
|
||||||
label={label}
|
label={t.label}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === id}
|
onClick={() => onOpenPending(undefined, t.kind)}
|
||||||
onClick={() => setActiveView(id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={Clock}
|
||||||
|
label="Scan History"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'history'}
|
||||||
|
onClick={() => setActiveView('history')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +146,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={hideIp ? EyeOff : Eye}
|
icon={hideIp ? EyeOff : Eye}
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||||
@@ -176,325 +186,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
|
||||||
|
|
||||||
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
|
||||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
const allChecked = devices.length > 0 && checkedIds.size === devices.length
|
|
||||||
const someChecked = checkedIds.size > 0
|
|
||||||
|
|
||||||
const toggleCheck = (id: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setCheckedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.pending()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load pending devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
|
||||||
try {
|
|
||||||
await scanApi.clearPending()
|
|
||||||
setDevices([])
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success('Pending devices cleared')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to clear pending devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkApprove = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkApprove(ids)
|
|
||||||
const deviceToNode: Record<string, string> = {}
|
|
||||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
|
||||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
|
||||||
approvedDevices.forEach((d, i) => {
|
|
||||||
const nodeId = deviceToNode[d.id]
|
|
||||||
if (!nodeId) return
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
|
||||||
data: {
|
|
||||||
label: d.hostname ?? d.ip,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: d.ip,
|
|
||||||
hostname: d.hostname ?? undefined,
|
|
||||||
status: 'unknown' as const,
|
|
||||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
})
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk approve devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkHide = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkHide(ids)
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk hide devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scanEventTs > 0) load()
|
|
||||||
}, [scanEventTs, load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightId || loading) return
|
|
||||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}, [highlightId, loading])
|
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
const nodeData = {
|
|
||||||
label: device.hostname ?? device.ip,
|
|
||||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: device.ip,
|
|
||||||
hostname: device.hostname ?? undefined,
|
|
||||||
status: 'unknown',
|
|
||||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
}
|
|
||||||
const res = await scanApi.approve(device.id, nodeData)
|
|
||||||
const nodeId = res.data.node_id
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: nodeData.type,
|
|
||||||
position: { x: 400, y: 300 },
|
|
||||||
data: { ...nodeData, status: 'unknown' as const },
|
|
||||||
})
|
|
||||||
toast.success(`Approved ${nodeData.label}`)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
setSelected(null)
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to approve device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHide = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.hide(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
toast.success('Device hidden')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to hide device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIgnore = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to ignore device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allChecked}
|
|
||||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
|
|
||||||
onChange={toggleAll}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
|
||||||
title="Select all"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{someChecked && (
|
|
||||||
<div className="flex items-center gap-1 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={handleBulkApprove}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Approve ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBulkHide}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Hide ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => {
|
|
||||||
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 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 isHighlighted = d.id === highlightId
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
ref={isHighlighted ? highlightRef : null}
|
|
||||||
onClick={() => setSelected(d)}
|
|
||||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checkedIds.has(d.id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-foreground truncate font-medium">{title}</span>
|
|
||||||
</div>
|
|
||||||
{showIpBelow && (
|
|
||||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
|
||||||
)}
|
|
||||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
|
||||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
|
||||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
|
||||||
{virtualBadge && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
|
|
||||||
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
|
||||||
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
|
||||||
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PendingDeviceModal
|
|
||||||
device={selected}
|
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
onApprove={handleApprove}
|
|
||||||
onHide={handleHide}
|
|
||||||
onIgnore={handleIgnore}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HiddenDevicesPanel() {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.hidden()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load hidden devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleIgnore = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to remove device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => (
|
|
||||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
|
||||||
<div className="font-mono text-foreground">{d.ip}</div>
|
|
||||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
|
||||||
<div className="flex gap-1 mt-1.5">
|
|
||||||
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScanHistoryPanel() {
|
function ScanHistoryPanel() {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
@@ -507,12 +198,19 @@ function ScanHistoryPanel() {
|
|||||||
const res = await scanApi.runs()
|
const res = await scanApi.runs()
|
||||||
const next: ScanRun[] = res.data
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
// Toast when a run transitions from running → error
|
// Surface transitions and refresh dependent UI
|
||||||
for (const run of next) {
|
for (const run of next) {
|
||||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
if (prev?.status === 'running' && run.status === 'error') {
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
}
|
}
|
||||||
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
|
if (run.kind === 'zigbee') {
|
||||||
|
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
// Notify pending modal/canvas to refresh
|
||||||
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevRunsRef.current = next
|
prevRunsRef.current = next
|
||||||
setRuns(next)
|
setRuns(next)
|
||||||
@@ -573,6 +271,14 @@ function ScanHistoryPanel() {
|
|||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={r.kind === 'zigbee'
|
||||||
|
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||||
|
: { background: '#a855f722', color: '#a855f7' }}
|
||||||
|
>
|
||||||
|
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
||||||
|
</span>
|
||||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||||
{r.status === 'running' && (
|
{r.status === 'running' && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -693,55 +399,6 @@ function VersionBadge() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
|
||||||
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
|
|
||||||
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectVirtualBadge(mac: string | null) {
|
|
||||||
if (!mac) return null
|
|
||||||
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceBadge({ label, color }: { label: string; color: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
|
|
||||||
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionButtonProps {
|
|
||||||
icon: React.ElementType
|
|
||||||
label: string
|
|
||||||
color?: 'green' | 'red'
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
|
|
||||||
const colorClass =
|
|
||||||
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
|
|
||||||
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
|
|
||||||
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
|
|
||||||
<Icon size={11} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -11,22 +11,11 @@ import type { NodeData } from '@/types'
|
|||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
vi.mock('@/stores/authStore')
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
const mockBulkApprove = vi.fn()
|
|
||||||
const mockBulkHide = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn().mockResolvedValue({}),
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn().mockResolvedValue({}),
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
clearPending: vi.fn().mockResolvedValue({}),
|
|
||||||
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
|
|
||||||
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
|
|
||||||
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
|
|
||||||
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
|
|
||||||
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
|
|
||||||
},
|
},
|
||||||
settingsApi: {
|
settingsApi: {
|
||||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
@@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
|||||||
TooltipContent: () => null,
|
TooltipContent: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
|
||||||
PendingDeviceModal: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||||
@@ -86,8 +71,9 @@ const defaultProps = {
|
|||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onNodeApproved: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -129,26 +115,22 @@ describe('Sidebar', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total (excludes groupRect)
|
|
||||||
expect(screen.getByText('4')).toBeInTheDocument()
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
// Online
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument()
|
expect(screen.getByText('2')).toBeInTheDocument()
|
||||||
// Offline
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument()
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from stats', () => {
|
it('excludes groupRect nodes from stats', () => {
|
||||||
mockStore({
|
mockStore({
|
||||||
nodes: [
|
nodes: [
|
||||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
makeNode('n1', 'unknown'),
|
||||||
makeNode('zone', 'unknown', 'groupRect'),
|
makeNode('zone', 'unknown', 'groupRect'),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
|
||||||
const totalRow = screen.getByText('Total').closest('div')!
|
const totalRow = screen.getByText('Total').closest('div')!
|
||||||
expect(totalRow).toHaveTextContent('1')
|
expect(totalRow).toHaveTextContent('1')
|
||||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||||
@@ -225,7 +207,6 @@ describe('Sidebar', () => {
|
|||||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||||
mockStore({ hasUnsavedChanges: true })
|
mockStore({ hasUnsavedChanges: true })
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// The badge is a span sibling of the Save Canvas button icon
|
|
||||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
const badge = saveBtn.querySelector('span.rounded-full')
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
expect(badge).toBeInTheDocument()
|
expect(badge).toBeInTheDocument()
|
||||||
@@ -241,24 +222,24 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
it('calls onScan prop when Scan Network is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||||
@@ -267,16 +248,13 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression: forceView used to override local state on every render, freezing
|
// Regression: forceView must not freeze local state across rerenders.
|
||||||
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
|
||||||
it('allows switching views after forceView is set by parent', async () => {
|
it('allows switching views after forceView is set by parent', async () => {
|
||||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
// Parent keeps forceView as 'history'; user clicks another nav item.
|
|
||||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Canvas'))
|
||||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||||
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
@@ -285,7 +263,6 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||||
)
|
)
|
||||||
// Click the nav button again to close (use role to avoid matching the panel heading)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -303,101 +280,3 @@ describe('Sidebar', () => {
|
|||||||
expect(mockLogout).toHaveBeenCalledOnce()
|
expect(mockLogout).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEVICE_A = {
|
|
||||||
id: 'dev-a',
|
|
||||||
ip: '192.168.1.10',
|
|
||||||
hostname: 'host-a',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEVICE_B = {
|
|
||||||
id: 'dev-b',
|
|
||||||
ip: '192.168.1.11',
|
|
||||||
hostname: 'host-b',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PendingDevicesPanel — bulk select', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockStore()
|
|
||||||
mockAuth()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockBulkApprove.mockResolvedValue({
|
|
||||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
|
||||||
})
|
|
||||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function renderWithDevices() {
|
|
||||||
const { scanApi } = await import('@/api/client')
|
|
||||||
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
|
|
||||||
render(<Sidebar {...defaultProps} forceView="pending" />)
|
|
||||||
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders checkboxes for each device', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox')
|
|
||||||
// select-all + 2 device checkboxes
|
|
||||||
expect(checkboxes.length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows bulk action bar when a device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(firstDeviceCheckbox)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
|
|
||||||
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides bulk action bar when no device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all checks all devices', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all unchecks all when all are selected', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll) // select all
|
|
||||||
fireEvent.click(selectAll) // deselect all
|
|
||||||
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkApprove with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Approve \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkHide with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Hide \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||||
|
|
||||||
|
interface ZigbeeImportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||||
|
onPendingImported?: (
|
||||||
|
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportMode = 'pending' | 'canvas'
|
||||||
|
|
||||||
|
interface ConnectionForm {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: string
|
||||||
|
mqtt_username: string
|
||||||
|
mqtt_password: string
|
||||||
|
base_topic: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
mqtt_tls_insecure: boolean
|
||||||
|
port_user_edited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ConnectionForm = {
|
||||||
|
mqtt_host: '',
|
||||||
|
mqtt_port: '1883',
|
||||||
|
mqtt_username: '',
|
||||||
|
mqtt_password: '',
|
||||||
|
base_topic: 'zigbee2mqtt',
|
||||||
|
mqtt_tls: false,
|
||||||
|
mqtt_tls_insecure: false,
|
||||||
|
port_user_edited: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_TYPE_ICON = {
|
||||||
|
zigbee_coordinator: Network,
|
||||||
|
zigbee_router: Router,
|
||||||
|
zigbee_enddevice: Cpu,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_LABEL = {
|
||||||
|
zigbee_coordinator: 'Coordinator',
|
||||||
|
zigbee_router: 'Router',
|
||||||
|
zigbee_enddevice: 'End Device',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR = {
|
||||||
|
zigbee_coordinator: '#00d4ff',
|
||||||
|
zigbee_router: '#39d353',
|
||||||
|
zigbee_enddevice: '#e3b341',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
|
||||||
|
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||||
|
const [connectionMsg, setConnectionMsg] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
||||||
|
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||||
|
|
||||||
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[field]: value,
|
||||||
|
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleTls = (next: boolean) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const port = f.port_user_edited
|
||||||
|
? f.mqtt_port
|
||||||
|
: next
|
||||||
|
? '8883'
|
||||||
|
: '1883'
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
mqtt_tls: next,
|
||||||
|
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||||
|
mqtt_port: port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
try {
|
||||||
|
const res = await zigbeeApi.testConnection({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
if (res.data.connected) {
|
||||||
|
setConnectionStatus('ok')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg('Request failed — check broker address')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractError = (err: unknown): string | undefined => {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchDevices = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (importMode === 'pending') {
|
||||||
|
await zigbeeApi.importToPending(buildPayload())
|
||||||
|
toast.success('Zigbee import started — track progress in Scan History')
|
||||||
|
onPendingImported?.(null)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCanvas = () => {
|
||||||
|
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||||
|
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||||
|
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
onAddToCanvas(selectedDevices, selectedEdges)
|
||||||
|
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDevices([])
|
||||||
|
setEdges([])
|
||||||
|
setChecked(new Set())
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
setConnectionMsg('')
|
||||||
|
setImportMode('pending')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDevices = {
|
||||||
|
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
|
||||||
|
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
|
||||||
|
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_enddevice'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Network size={16} className="text-[#00d4ff]" />
|
||||||
|
Zigbee2MQTT Import
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||||
|
{/* Connection Form */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_host}
|
||||||
|
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||||
|
placeholder="192.168.1.x or mqtt.local"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_port}
|
||||||
|
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||||
|
placeholder="1883"
|
||||||
|
type="number"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Base Topic</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_topic}
|
||||||
|
onChange={(e) => updateField('base_topic', e.target.value)}
|
||||||
|
placeholder="zigbee2mqtt"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_username}
|
||||||
|
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||||
|
placeholder="mqtt_user"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_password}
|
||||||
|
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls}
|
||||||
|
onChange={(e) => toggleTls(e.target.checked)}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Use TLS (port 8883)
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||||
|
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls_insecure}
|
||||||
|
disabled={!form.mqtt_tls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
Skip cert verify (self-signed only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
{connectionStatus !== 'idle' && (
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||||
|
connectionStatus === 'ok'
|
||||||
|
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||||
|
: connectionStatus === 'fail'
|
||||||
|
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||||
|
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||||
|
}`}>
|
||||||
|
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||||
|
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||||
|
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||||
|
</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">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={connectionStatus === 'testing' || loading}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'testing'
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <CheckCircle2 size={13} />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleFetchDevices}
|
||||||
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||||
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
Fetching the network map can take several minutes on large meshes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device List */}
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.size === devices.length}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title="Select all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Devices ({checked.size}/{devices.length} selected)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZigbeeNode[]][])
|
||||||
|
.filter(([, group]) => group.length > 0)
|
||||||
|
.map(([type, group]) => {
|
||||||
|
const Icon = DEVICE_TYPE_ICON[type]
|
||||||
|
const color = DEVICE_TYPE_COLOR[type]
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Icon size={11} style={{ color }} />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||||
|
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||||
|
checked.has(device.id)
|
||||||
|
? 'bg-[#21262d] border-[#00d4ff]/40'
|
||||||
|
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCheck(device.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.has(device.id)}
|
||||||
|
onChange={() => toggleCheck(device.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||||
|
{(device.model || device.vendor) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
||||||
|
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
||||||
|
>
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddToCanvas}
|
||||||
|
disabled={checked.size === 0}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add {checked.size} to Canvas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||||
|
|
||||||
|
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() } }))
|
||||||
|
|
||||||
|
import { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddToCanvas: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleNodes = [
|
||||||
|
{
|
||||||
|
id: '0x0000',
|
||||||
|
label: 'Coordinator',
|
||||||
|
type: 'zigbee_coordinator' as const,
|
||||||
|
ieee_address: '0x0000',
|
||||||
|
friendly_name: 'Coordinator',
|
||||||
|
device_type: 'Coordinator',
|
||||||
|
model: null,
|
||||||
|
vendor: null,
|
||||||
|
lqi: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0x0001',
|
||||||
|
label: 'router_1',
|
||||||
|
type: 'zigbee_router' as const,
|
||||||
|
ieee_address: '0x0001',
|
||||||
|
friendly_name: 'router_1',
|
||||||
|
device_type: 'Router',
|
||||||
|
model: 'CC2530',
|
||||||
|
vendor: 'TI',
|
||||||
|
lqi: 200,
|
||||||
|
parent_id: '0x0000',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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()
|
||||||
|
defaultProps.onClose.mockReset()
|
||||||
|
defaultProps.onAddToCanvas.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ZigbeeImportModal {...defaultProps} open={false} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the modal with form fields when open', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('1883')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when testing connection without a host', async () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||||
|
})
|
||||||
|
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success status when connection test passes', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: true, message: 'Connection successful' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
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: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failure status when connection test fails', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: false, message: 'Connection refused' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection refused')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||||
|
expect(screen.getByText('router_1')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows info toast when no devices found', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: [], edges: [], device_count: 0 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Coordinator'))
|
||||||
|
|
||||||
|
// Click "Add N to Canvas" button
|
||||||
|
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports to pending by default and notifies parent', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'zigbee',
|
||||||
|
ranges: ['192.168.1.100:1883'],
|
||||||
|
devices_found: 0,
|
||||||
|
started_at: '2026-01-01T00:00:00Z',
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
} 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Shared Zigbee type definitions for the frontend. */
|
||||||
|
|
||||||
|
export interface ZigbeeNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_enddevice'
|
||||||
|
ieee_address: string
|
||||||
|
friendly_name: string
|
||||||
|
device_type: string
|
||||||
|
model?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
lqi?: number | null
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeImportResponse {
|
||||||
|
nodes: ZigbeeNode[]
|
||||||
|
edges: ZigbeeEdge[]
|
||||||
|
device_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionRequest {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionResponse {
|
||||||
|
connected: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export type NodeType =
|
|||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'zigbee_coordinator'
|
||||||
|
| 'zigbee_router'
|
||||||
|
| 'zigbee_enddevice'
|
||||||
|
|
||||||
export type TextPosition =
|
export type TextPosition =
|
||||||
| 'top-left'
|
| 'top-left'
|
||||||
@@ -136,6 +139,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
group: 'Node Group',
|
group: 'Node Group',
|
||||||
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
|
zigbee_router: 'Zigbee Router',
|
||||||
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
|||||||
cpl: PlugZap,
|
cpl: PlugZap,
|
||||||
docker_host: Anchor,
|
docker_host: Anchor,
|
||||||
docker_container: Package,
|
docker_container: Package,
|
||||||
|
zigbee_coordinator: Radio,
|
||||||
|
zigbee_router: Zap,
|
||||||
|
zigbee_enddevice: Lightbulb,
|
||||||
generic: Circle,
|
generic: Circle,
|
||||||
group: Circle,
|
group: Circle,
|
||||||
groupRect: Circle,
|
groupRect: Circle,
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||||
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||||
|
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
@@ -116,6 +119,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||||
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
||||||
|
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
|
||||||
|
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
|
||||||
|
zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' },
|
||||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
@@ -173,6 +179,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#b45309', icon: '#b45309' },
|
cpl: { border: '#b45309', icon: '#b45309' },
|
||||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||||
docker_container: { border: '#0369a1', icon: '#0369a1' },
|
docker_container: { border: '#0369a1', icon: '#0369a1' },
|
||||||
|
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
|
||||||
|
zigbee_router: { border: '#b45309', icon: '#b45309' },
|
||||||
|
zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
|
||||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||||
group: { border: '#0284c7', icon: '#0284c7' },
|
group: { border: '#0284c7', icon: '#0284c7' },
|
||||||
@@ -230,6 +239,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||||
docker_host: { border: '#00aaff', icon: '#00aaff' },
|
docker_host: { border: '#00aaff', icon: '#00aaff' },
|
||||||
docker_container: { border: '#00ddff', icon: '#00ddff' },
|
docker_container: { border: '#00ddff', icon: '#00ddff' },
|
||||||
|
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
|
||||||
|
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
|
||||||
|
zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
|
||||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||||
group: { border: '#00ffff', icon: '#00ffff' },
|
group: { border: '#00ffff', icon: '#00ffff' },
|
||||||
@@ -287,6 +299,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||||
docker_host: { border: '#00cc88', icon: '#00cc88' },
|
docker_host: { border: '#00cc88', icon: '#00cc88' },
|
||||||
docker_container: { border: '#00aacc', icon: '#00aacc' },
|
docker_container: { border: '#00aacc', icon: '#00aacc' },
|
||||||
|
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
|
||||||
|
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
|
||||||
|
zigbee_enddevice: { border: '#008822', icon: '#008822' },
|
||||||
generic: { border: '#006600', icon: '#006600' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||||
group: { border: '#00ff41', icon: '#00ff41' },
|
group: { border: '#00ff41', icon: '#00ff41' },
|
||||||
@@ -344,6 +359,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||||
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||||
|
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
|||||||
Reference in New Issue
Block a user