diff --git a/.env.example b/.env.example
index 52fa748..1f2b9b9 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,12 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
+# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
+# custom ports. Defaults below are overridable per-scan from the scan dialog.
+SCANNER_HTTP_RANGES=[]
+SCANNER_HTTP_PROBE_ENABLED=false
+SCANNER_HTTP_VERIFY_TLS=false
+
# Status checker interval in seconds
STATUS_CHECKER_INTERVAL=60
diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py
index 390c86a..5377cb6 100644
--- a/backend/app/api/routes/scan.py
+++ b/backend/app/api/routes/scan.py
@@ -14,10 +14,30 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
-from app.services.scanner import request_cancel, run_scan
+from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import build_zigbee_properties
+from app.services.zwave_service import build_zwave_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
+_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
+
+
+def _is_wireless(node_type: str | None) -> bool:
+ """Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
+ return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
+
+
+def _wireless_properties(
+ node_type: str | None,
+ ieee: str | None,
+ vendor: str | None,
+ model: str | None,
+ lqi: int | None,
+) -> list[dict[str, Any]]:
+ """Build the right property rows for a mesh device (Z-Wave has no LQI)."""
+ if node_type in _ZWAVE_TYPES:
+ return build_zwave_properties(ieee, vendor, model)
+ return build_zigbee_properties(ieee, vendor, model, lqi)
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
@@ -50,10 +70,26 @@ def merge_mac_property(
class BulkActionRequest(BaseModel):
device_ids: list[str]
+ # Target design for approved nodes. Falls back to the first design when
+ # omitted (keeps older clients working), but the UI should send the active
+ # design so approved devices land on the canvas the user is looking at.
+ design_id: str | None = None
+
+
+def _check_port_ranges(v: list[str]) -> list[str]:
+ for r in v:
+ if not _valid_port_range(r.strip()):
+ raise ValueError(f"Invalid port range: {r!r}")
+ return v
class ScanConfig(BaseModel):
+ """Persisted scan defaults (Options page). Deep-scan fields are optional."""
+
ranges: list[str]
+ http_ranges: list[str] = []
+ http_probe_enabled: bool = False
+ verify_tls: bool = False
@field_validator("ranges")
@classmethod
@@ -65,15 +101,35 @@ class ScanConfig(BaseModel):
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
+ @field_validator("http_ranges")
+ @classmethod
+ def validate_http_ranges(cls, v: list[str]) -> list[str]:
+ return _check_port_ranges(v)
+
+
+class TriggerScanRequest(BaseModel):
+ """Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
+
+ http_ranges: list[str] | None = None
+ http_probe_enabled: bool | None = None
+ verify_tls: bool | None = None
+
+ @field_validator("http_ranges")
+ @classmethod
+ def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
+ return None if v is None else _check_port_ranges(v)
+
logger = logging.getLogger(__name__)
router = APIRouter()
-async def _background_scan(run_id: str, ranges: list[str]) -> None:
+async def _background_scan(
+ run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
+) -> None:
async with AsyncSessionLocal() as db:
try:
- await run_scan(ranges, db, run_id)
+ await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
@@ -83,18 +139,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None:
await db.commit()
+def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
+ """Merge per-scan overrides over persisted settings defaults."""
+ p = payload or TriggerScanRequest()
+ return DeepScanOptions(
+ http_ranges=(
+ p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
+ ),
+ http_probe_enabled=(
+ p.http_probe_enabled
+ if p.http_probe_enabled is not None
+ else settings.scanner_http_probe_enabled
+ ),
+ verify_tls=(
+ p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
+ ),
+ )
+
+
@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(
background_tasks: BackgroundTasks,
+ payload: TriggerScanRequest | None = None,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
ranges = settings.scanner_ranges
+ deep_scan = _resolve_deep_scan(payload)
run = ScanRun(status="running", ranges=ranges)
db.add(run)
await db.commit()
await db.refresh(run)
- background_tasks.add_task(_background_scan, run.id, ranges)
+ background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
return run
@@ -117,10 +193,60 @@ async def stop_scan(
return {"stopping": True}
+async def _canvas_counts(
+ db: AsyncSession, devices: list[PendingDevice]
+) -> dict[str, int]:
+ """Map each device id → number of distinct canvases (designs) it appears on.
+
+ Correlates a scanned device to existing nodes by ``ieee_address`` (exact) or
+ ``ip`` (exact). Runs a single node query and groups in Python — node counts are
+ small for a homelab, so this avoids an N+1 per device.
+ """
+ if not devices:
+ return {}
+ rows = (
+ await db.execute(
+ select(Node.ip, Node.ieee_address, Node.design_id).where(
+ Node.design_id.isnot(None)
+ )
+ )
+ ).all()
+ # ip → set(design_id), ieee → set(design_id)
+ by_ip: dict[str, set[str]] = {}
+ by_ieee: dict[str, set[str]] = {}
+ for ip, ieee, design_id in rows:
+ if ip:
+ by_ip.setdefault(ip, set()).add(design_id)
+ if ieee:
+ by_ieee.setdefault(ieee, set()).add(design_id)
+
+ counts: dict[str, int] = {}
+ for d in devices:
+ designs: set[str] = set()
+ if d.ieee_address:
+ designs |= by_ieee.get(d.ieee_address, set())
+ if d.ip:
+ designs |= by_ip.get(d.ip, set())
+ counts[d.id] = len(designs)
+ return counts
+
+
+async def _with_canvas_counts(
+ db: AsyncSession, devices: list[PendingDevice]
+) -> list[PendingDevice]:
+ """Attach a transient ``canvas_count`` to each device for the response schema."""
+ counts = await _canvas_counts(db, devices)
+ for d in devices:
+ d.canvas_count = counts.get(d.id, 0)
+ return devices
+
+
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
- result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
- return list(result.scalars().all())
+ # Inventory: every scanned device except the user-hidden ones. Approved devices
+ # stay listed so they keep showing with a canvas-presence badge.
+ result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
+ return await _with_canvas_counts(db, list(result.scalars().all()))
@router.delete("/pending", response_model=dict)
@@ -137,7 +263,7 @@ async def clear_pending(
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
- return list(result.scalars().all())
+ return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending/bulk-approve", response_model=dict)
@@ -146,9 +272,11 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
- # Determine target design (use first design as fallback)
- first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
- default_design_id = first_design.id if first_design else None
+ # Target the design the user is on; fall back to the first design.
+ default_design_id = payload.design_id
+ if default_design_id is None:
+ first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
+ default_design_id = first_design.id if first_design else None
result = await db.execute(
select(PendingDevice).where(
@@ -161,22 +289,22 @@ async def bulk_approve_devices(
for device in devices:
device.status = "approved"
node_type = device.suggested_type or "generic"
- is_zigbee = node_type in _ZIGBEE_TYPES
+ is_wireless = _is_wireless(node_type)
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
ip=device.ip,
mac=device.mac,
hostname=device.hostname,
- status="online" if is_zigbee else "unknown",
+ status="online" if is_wireless else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
- properties=build_zigbee_properties(
- device.ieee_address, device.vendor, device.model, device.lqi
- ) if is_zigbee else build_mac_property(device.mac),
+ properties=_wireless_properties(
+ node_type, device.ieee_address, device.vendor, device.model, device.lqi
+ ) if is_wireless else build_mac_property(device.mac),
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
- check_method="none" if is_zigbee else ("ping" if device.ip else None),
+ check_method="none" if is_wireless else ("ping" if device.ip else None),
design_id=default_design_id,
)
db.add(node)
@@ -273,7 +401,7 @@ async def approve_device(
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
- _is_zigbee = node_data.type in _ZIGBEE_TYPES
+ wireless = _is_wireless(node_data.type)
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
@@ -283,14 +411,14 @@ async def approve_device(
ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname,
- status="online" if _is_zigbee else node_data.status,
+ status="online" if wireless else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
- properties=build_zigbee_properties(
- device.ieee_address, device.vendor, device.model, device.lqi
- ) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
- check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
- check_target=None if _is_zigbee else node_data.check_target,
+ properties=_wireless_properties(
+ node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
+ ) if wireless else merge_mac_property(node_data.properties, _mac),
+ check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
+ check_target=None if wireless else node_data.check_target,
design_id=node_design_id,
)
db.add(node)
@@ -427,17 +555,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
- return ScanConfig(ranges=settings.scanner_ranges)
+ return ScanConfig(
+ ranges=settings.scanner_ranges,
+ http_ranges=settings.scanner_http_ranges,
+ http_probe_enabled=settings.scanner_http_probe_enabled,
+ verify_tls=settings.scanner_http_verify_tls,
+ )
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
- previous = settings.scanner_ranges
+ previous = (
+ settings.scanner_ranges,
+ settings.scanner_http_ranges,
+ settings.scanner_http_probe_enabled,
+ settings.scanner_http_verify_tls,
+ )
settings.scanner_ranges = payload.ranges
+ settings.scanner_http_ranges = payload.http_ranges
+ settings.scanner_http_probe_enabled = payload.http_probe_enabled
+ settings.scanner_http_verify_tls = payload.verify_tls
try:
settings.save_overrides()
return payload
except Exception as exc:
- settings.scanner_ranges = previous
+ (
+ settings.scanner_ranges,
+ settings.scanner_http_ranges,
+ settings.scanner_http_probe_enabled,
+ settings.scanner_http_verify_tls,
+ ) = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
diff --git a/backend/app/api/routes/zwave.py b/backend/app/api/routes/zwave.py
new file mode 100644
index 0000000..241dc38
--- /dev/null
+++ b/backend/app/api/routes/zwave.py
@@ -0,0 +1,284 @@
+"""FastAPI router for Z-Wave JS UI (zwavejs2mqtt) 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 Design, Node, PendingDevice, PendingDeviceLink, ScanRun
+from app.schemas.scan import ScanRunResponse
+from app.schemas.zwave import (
+ ZwaveCoordinatorOut,
+ ZwaveEdgeOut,
+ ZwaveImportPendingResponse,
+ ZwaveImportRequest,
+ ZwaveImportResponse,
+ ZwaveNodeOut,
+ ZwaveTestConnectionRequest,
+ ZwaveTestConnectionResponse,
+)
+from app.services.zwave_service import (
+ build_zwave_properties,
+ fetch_zwave_network,
+ merge_zwave_properties,
+ test_zwave_connection,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+@router.post("/import", response_model=ZwaveImportResponse)
+async def import_zwave_network(
+ payload: ZwaveImportRequest,
+ _: str = Depends(get_current_user),
+) -> ZwaveImportResponse:
+ """Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
+
+ Connects to the broker, publishes a ``getNodes`` request to the Z-Wave JS UI
+ gateway, and waits for the response. Devices are returned as typed homelable
+ nodes with a coordinator → router → end-device hierarchy.
+ """
+ try:
+ nodes_raw, edges_raw = await fetch_zwave_network(
+ mqtt_host=payload.mqtt_host,
+ mqtt_port=payload.mqtt_port,
+ prefix=payload.prefix,
+ gateway_name=payload.gateway_name,
+ 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 Z-Wave import")
+ raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
+
+ nodes = [ZwaveNodeOut(**n) for n in nodes_raw]
+ edges = [ZwaveEdgeOut(**e) for e in edges_raw]
+ return ZwaveImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
+
+
+@router.post("/import-pending", response_model=ScanRunResponse)
+async def import_zwave_to_pending(
+ payload: ZwaveImportRequest,
+ background_tasks: BackgroundTasks,
+ db: AsyncSession = Depends(get_db),
+ _: str = Depends(get_current_user),
+) -> ScanRun:
+ """Queue a Z-Wave pending import as a background scan run (kind=zwave)."""
+ run = ScanRun(
+ status="running",
+ kind="zwave",
+ ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
+ )
+ db.add(run)
+ await db.commit()
+ await db.refresh(run)
+ background_tasks.add_task(_background_zwave_import, run.id, payload)
+ return run
+
+
+async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
+ async with AsyncSessionLocal() as db:
+ try:
+ nodes_raw, edges_raw = await fetch_zwave_network(
+ mqtt_host=payload.mqtt_host,
+ mqtt_port=payload.mqtt_port,
+ prefix=payload.prefix,
+ gateway_name=payload.gateway_name,
+ 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("Z-Wave 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]],
+) -> ZwaveImportPendingResponse:
+ """Upsert nodes/edges into pending_devices + pending_device_links.
+
+ Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
+ identity. All zwave-source links are wiped and re-inserted from the new map.
+ """
+ first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
+ default_design_id = first_design.id if first_design else None
+
+ coordinator_out: ZwaveCoordinatorOut | 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
+ props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
+
+ if n.get("type") == "zwave_coordinator":
+ existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
+ existing_node = existing.scalar_one_or_none()
+ if existing_node:
+ existing_node.properties = merge_zwave_properties(
+ existing_node.properties, props
+ )
+ coordinator_out = ZwaveCoordinatorOut(
+ 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 "zwave_coordinator",
+ status="online",
+ check_method="none",
+ ieee_address=ieee,
+ services=[],
+ properties=props,
+ design_id=default_design_id,
+ )
+ db.add(node)
+ await db.flush()
+ coordinator_out = ZwaveCoordinatorOut(
+ id=node.id, label=label, ieee_address=ieee
+ )
+ continue
+
+ # Already approved as a canvas Node → refresh props, skip pending row.
+ existing_node_q = await db.execute(
+ select(Node).where(Node.ieee_address == ieee)
+ )
+ existing_node = existing_node_q.scalar_one_or_none()
+ if existing_node:
+ existing_node.properties = merge_zwave_properties(
+ existing_node.properties, props
+ )
+ 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="zwave",
+ )
+ )
+ 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 pending.status == "approved":
+ # Approved earlier but the canvas Node is gone (deleted) — revive
+ # to "pending" so it reappears in the list instead of vanishing.
+ pending.status = "pending"
+ elif pending.status == "hidden":
+ pass
+ pending_updated += 1
+
+ # Replace all zwave-source links with the freshly discovered set.
+ await db.execute(
+ sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
+ )
+
+ 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="zwave",
+ )
+ )
+ links_recorded += 1
+
+ await db.commit()
+
+ return ZwaveImportPendingResponse(
+ 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=ZwaveTestConnectionResponse)
+async def test_connection_endpoint(
+ payload: ZwaveTestConnectionRequest,
+ _: str = Depends(get_current_user),
+) -> ZwaveTestConnectionResponse:
+ """Quick MQTT ping to validate broker connection before importing."""
+ try:
+ await test_zwave_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 ZwaveTestConnectionResponse(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 ZwaveTestConnectionResponse(connected=False, message=str(exc))
+ except Exception:
+ logger.exception("Unexpected error during connection test")
+ return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index f599f74..b9f4016 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -48,6 +48,12 @@ class Settings(BaseSettings):
# Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
+ # Deep scan — persisted defaults (overridable per-scan from the scan dialog).
+ # http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
+ scanner_http_ranges: list[str] = []
+ scanner_http_probe_enabled: bool = False
+ scanner_http_verify_tls: bool = False
+
# Status checker
status_checker_interval: int = 60
@@ -85,6 +91,12 @@ class Settings(BaseSettings):
self.service_check_enabled = bool(data["service_check_enabled"])
if "service_check_interval" in data:
self.service_check_interval = int(data["service_check_interval"])
+ if "scanner_http_ranges" in data:
+ self.scanner_http_ranges = list(data["scanner_http_ranges"])
+ if "scanner_http_probe_enabled" in data:
+ self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
+ if "scanner_http_verify_tls" in data:
+ self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
except Exception:
pass
@@ -96,6 +108,9 @@ class Settings(BaseSettings):
"status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
+ "scanner_http_ranges": self.scanner_http_ranges,
+ "scanner_http_probe_enabled": self.scanner_http_probe_enabled,
+ "scanner_http_verify_tls": self.scanner_http_verify_tls,
}))
diff --git a/backend/app/data/oui_database.json b/backend/app/data/oui_database.json
new file mode 100644
index 0000000..af99dd8
--- /dev/null
+++ b/backend/app/data/oui_database.json
@@ -0,0 +1,221 @@
+[
+ {
+ "vendor": "Proxmox / QEMU / KVM",
+ "type": "vm",
+ "prefixes": ["52:54:00", "bc:24:11"]
+ },
+ {
+ "vendor": "VMware",
+ "type": "vm",
+ "prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"]
+ },
+ {
+ "vendor": "VirtualBox",
+ "type": "vm",
+ "prefixes": ["08:00:27"]
+ },
+ {
+ "vendor": "Microsoft Hyper-V",
+ "type": "vm",
+ "prefixes": ["00:15:5d"]
+ },
+ {
+ "vendor": "Xen",
+ "type": "vm",
+ "prefixes": ["00:16:3e"]
+ },
+
+ {
+ "vendor": "MikroTik",
+ "type": "router",
+ "prefixes": [
+ "00:0c:42",
+ "08:55:31",
+ "18:fd:74",
+ "2c:c8:1b",
+ "48:8f:5a",
+ "4c:5e:0c",
+ "64:d1:54",
+ "6c:3b:6b",
+ "74:4d:28",
+ "b8:69:f4",
+ "c4:ad:34",
+ "cc:2d:e0",
+ "d4:ca:6d",
+ "dc:2c:6e",
+ "e4:8d:8c"
+ ]
+ },
+ {
+ "vendor": "Ubiquiti",
+ "type": "ap",
+ "prefixes": [
+ "00:15:6d",
+ "00:27:22",
+ "04:18:d6",
+ "24:5a:4c",
+ "24:a4:3c",
+ "44:d9:e7",
+ "68:72:51",
+ "68:d7:9a",
+ "74:83:c2",
+ "78:8a:20",
+ "78:45:58",
+ "80:2a:a8",
+ "94:2a:6f",
+ "9c:05:d6",
+ "b4:fb:e4",
+ "dc:9f:db",
+ "e0:63:da",
+ "f0:9f:c2",
+ "fc:ec:da"
+ ]
+ },
+ {
+ "vendor": "Ruckus Wireless",
+ "type": "ap",
+ "prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"]
+ },
+ {
+ "vendor": "Aruba Networks (HPE)",
+ "type": "ap",
+ "prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"]
+ },
+
+ {
+ "vendor": "Cisco Systems",
+ "type": "switch",
+ "prefixes": [
+ "00:00:0c",
+ "00:1b:0d",
+ "00:1c:f6",
+ "00:1e:13",
+ "00:23:04",
+ "00:24:13",
+ "00:25:45",
+ "00:50:0b",
+ "b0:00:b4",
+ "b8:38:61",
+ "f8:c0:01"
+ ]
+ },
+ {
+ "vendor": "Juniper Networks",
+ "type": "switch",
+ "prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"]
+ },
+ {
+ "vendor": "Zyxel",
+ "type": "switch",
+ "prefixes": ["00:13:49", "60:31:97", "ec:43:f6"]
+ },
+
+ {
+ "vendor": "Netgear",
+ "type": "router",
+ "prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"]
+ },
+ {
+ "vendor": "TP-Link",
+ "type": "router",
+ "prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"]
+ },
+
+ {
+ "vendor": "Synology",
+ "type": "nas",
+ "prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"]
+ },
+ {
+ "vendor": "QNAP Systems",
+ "type": "nas",
+ "prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"]
+ },
+ {
+ "vendor": "Asustor",
+ "type": "nas",
+ "prefixes": ["e8:9c:25"]
+ },
+
+ {
+ "vendor": "Hikvision",
+ "type": "camera",
+ "prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"]
+ },
+ {
+ "vendor": "Dahua / Amcrest",
+ "type": "camera",
+ "prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"]
+ },
+ {
+ "vendor": "Reolink",
+ "type": "camera",
+ "prefixes": ["ec:71:db"]
+ },
+ {
+ "vendor": "Axis Communications",
+ "type": "camera",
+ "prefixes": ["00:40:8c", "ac:cc:8e"]
+ },
+
+ {
+ "vendor": "Raspberry Pi Foundation",
+ "type": "server",
+ "prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"]
+ },
+ {
+ "vendor": "Dell",
+ "type": "server",
+ "prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"]
+ },
+ {
+ "vendor": "Supermicro",
+ "type": "server",
+ "prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"]
+ },
+
+ {
+ "vendor": "Shelly",
+ "type": "iot",
+ "prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"]
+ },
+ {
+ "vendor": "Espressif (ESP8266 / ESP32)",
+ "type": "iot",
+ "prefixes": [
+ "24:62:ab",
+ "30:ae:a4",
+ "3c:71:bf",
+ "8c:aa:b5",
+ "a0:20:a6",
+ "ac:67:b2",
+ "b4:e6:2d",
+ "cc:50:e3"
+ ]
+ },
+ {
+ "vendor": "Sonoff / ITEAD",
+ "type": "iot",
+ "prefixes": ["dc:4f:22", "e8:db:84"]
+ },
+ {
+ "vendor": "TP-Link Tapo / Kasa",
+ "type": "iot",
+ "prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"]
+ },
+ {
+ "vendor": "Philips Hue",
+ "type": "iot",
+ "prefixes": ["00:17:88", "ec:b5:fa"]
+ },
+ {
+ "vendor": "IKEA Tradfri",
+ "type": "iot",
+ "prefixes": ["00:21:2e", "34:13:e8"]
+ },
+ {
+ "vendor": "Tuya / Smart Life",
+ "type": "iot",
+ "prefixes": ["68:57:2d", "d8:f1:5b"]
+ }
+]
diff --git a/backend/app/data/service_signatures.json b/backend/app/data/service_signatures.json
index 412b14c..54821fb 100644
--- a/backend/app/data/service_signatures.json
+++ b/backend/app/data/service_signatures.json
@@ -142,5 +142,69 @@
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
- {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
+ {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
]
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index bdcd6d2..1e49386 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -117,6 +117,10 @@ class PendingDevice(Base):
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ # Transient (not persisted): populated per-request by the scan routes to report
+ # how many canvases this device already appears on. Not a mapped column.
+ canvas_count: int = 0
+
class PendingDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
diff --git a/backend/app/main.py b/backend/app/main.py
index 0c0e8f7..46172f9 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
+from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
@@ -57,6 +57,7 @@ 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(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
+app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py
index 92151bf..b0f5b78 100644
--- a/backend/app/schemas/scan.py
+++ b/backend/app/schemas/scan.py
@@ -21,6 +21,9 @@ class PendingDeviceResponse(BaseModel):
vendor: str | None = None
lqi: int | None = None
discovered_at: datetime
+ # Number of distinct canvases (designs) this device already appears on,
+ # correlated by ip / ieee_address against existing nodes. Computed per-request.
+ canvas_count: int = 0
model_config = {"from_attributes": True}
diff --git a/backend/app/schemas/zwave.py b/backend/app/schemas/zwave.py
new file mode 100644
index 0000000..ec6a0ed
--- /dev/null
+++ b/backend/app/schemas/zwave.py
@@ -0,0 +1,85 @@
+"""Pydantic v2 schemas for Z-Wave JS UI (zwavejs2mqtt) import."""
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class ZwaveImportRequest(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)")
+ prefix: str = Field("zwave", description="Z-Wave JS UI MQTT prefix")
+ gateway_name: str = Field("zwavejs2mqtt", description="Z-Wave JS UI gateway name")
+ 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) -> "ZwaveImportRequest":
+ if self.mqtt_tls_insecure and not self.mqtt_tls:
+ raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
+ return self
+
+
+class ZwaveTestConnectionRequest(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) -> "ZwaveTestConnectionRequest":
+ if self.mqtt_tls_insecure and not self.mqtt_tls:
+ raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
+ return self
+
+
+class ZwaveNodeOut(BaseModel):
+ """A homelable-ready node representation of a Z-Wave device."""
+
+ id: str
+ label: str
+ type: str # zwave_coordinator | zwave_router | zwave_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 ZwaveEdgeOut(BaseModel):
+ source: str
+ target: str
+
+
+class ZwaveImportResponse(BaseModel):
+ nodes: list[ZwaveNodeOut]
+ edges: list[ZwaveEdgeOut]
+ device_count: int
+
+
+class ZwaveTestConnectionResponse(BaseModel):
+ connected: bool
+ message: str
+
+
+class ZwaveCoordinatorOut(BaseModel):
+ id: str
+ label: str
+ ieee_address: str
+
+
+class ZwaveImportPendingResponse(BaseModel):
+ """Result of importing a Z-Wave network into the pending section."""
+
+ pending_created: int
+ pending_updated: int
+ coordinator: ZwaveCoordinatorOut | None = None
+ coordinator_already_existed: bool = False
+ links_recorded: int
+ device_count: int
diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py
index 1a2b4a8..4532644 100644
--- a/backend/app/services/fingerprint.py
+++ b/backend/app/services/fingerprint.py
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
_SIGNATURES: list[dict[str, Any]] | None = None
+_OUI_MAP: dict[str, str] | None = None
_LOCK = threading.Lock()
@@ -26,25 +27,124 @@ def _load() -> list[dict[str, Any]]:
return _SIGNATURES
-def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
- """Return the first signature matching port+protocol, optionally banner."""
- for sig in _load():
- if sig["port"] != port or sig["protocol"] != protocol:
- continue
- if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
- continue
- return sig
- return None
+def _load_oui() -> dict[str, str]:
+ """Load OUI database and flatten to {prefix: node_type}."""
+ global _OUI_MAP
+ if _OUI_MAP is None:
+ with _LOCK:
+ if _OUI_MAP is None:
+ path = Path(__file__).parent.parent / "data" / "oui_database.json"
+ try:
+ with open(path) as f:
+ entries = json.load(f)
+ except FileNotFoundError as err:
+ raise FileNotFoundError(
+ f"oui_database.json not found at {path}. "
+ "This file should be bundled with the application."
+ ) from err
+ _OUI_MAP = {
+ prefix.lower(): entry["type"]
+ for entry in entries
+ for prefix in entry["prefixes"]
+ }
+ return _OUI_MAP
-def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
+def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
+ """True when the signature's http_regex matches the probe's title/headers."""
+ rx = sig.get("http_regex")
+ if not rx or not http_signals:
+ return False
+ headers = http_signals.get("headers") or {}
+ haystack = " ".join(
+ s for s in (
+ http_signals.get("title"),
+ headers.get("Server"),
+ headers.get("X-Powered-By"),
+ ) if s
+ )
+ return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
+
+
+def _service_tier(
+ sig: dict[str, Any],
+ port: int,
+ protocol: str,
+ banner: str | None,
+ http_signals: dict[str, Any] | None,
+) -> int | None:
"""
- Given a list of {port, protocol, banner?} dicts, return matched services.
- Unknown ports are included as unknown_service.
+ Rank how well a signature matches (lower = stronger). None = not a match.
+
+ Tier 1: port match + http_regex confirmed
+ Tier 2: port match + banner_regex confirmed
+ Tier 3: port-agnostic (port: null) + http_regex confirmed
+ Tier 4: port match only (no regex, or http_regex with probe disabled)
+
+ When http_signals is None (probe not run) an http_regex entry degrades to
+ a port-only match — identical to pre-probe behaviour, no regression.
+ When http_signals is provided, http_regex is strict: a miss disqualifies.
+ """
+ probe_ran = http_signals is not None
+ has_http = bool(sig.get("http_regex"))
+
+ # Port-agnostic entries (port: null) match purely on HTTP signals.
+ if sig.get("port") is None:
+ if has_http and _http_regex_hit(sig, http_signals):
+ return 3
+ return None
+
+ if sig["port"] != port or sig["protocol"] != protocol:
+ return None
+
+ # http_regex is authoritative once a probe has run.
+ if has_http and probe_ran:
+ return 1 if _http_regex_hit(sig, http_signals) else None
+
+ if sig.get("banner_regex"):
+ if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
+ return 2
+ return None
+
+ # No regex constraint (or http_regex but probe disabled) → port-only guess.
+ return 4
+
+
+def match_service(
+ port: int,
+ protocol: str,
+ banner: str | None = None,
+ http_signals: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ """Return the best signature for a port, walking tiers most-specific first."""
+ best: dict[str, Any] | None = None
+ best_tier = 99
+ for sig in _load():
+ tier = _service_tier(sig, port, protocol, banner, http_signals)
+ if tier is not None and tier < best_tier:
+ best, best_tier = sig, tier
+ if best_tier == 1:
+ break # strongest possible — stop early
+ return best
+
+
+def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
+ """Back-compat alias: match without HTTP-probe signals."""
+ return match_service(port, protocol, banner)
+
+
+def fingerprint_ports(
+ open_ports: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """
+ Given a list of {port, protocol, banner?, http_signals?} dicts, return
+ matched services. Unknown ports are included as unknown_service.
"""
results = []
for p in open_ports:
- sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
+ sig = match_service(
+ p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
+ )
if sig:
results.append({
"port": p["port"],
@@ -65,55 +165,12 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
return results
-# Known OUI prefixes — lowercase, colon-separated, first 3 octets
-_MAC_OUI_TYPES: dict[str, str] = {
- # Hypervisors / VMs
- "52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
- "bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
- "00:50:56": "vm", # VMware
- "00:0c:29": "vm", # VMware Workstation / Fusion
- "08:00:27": "vm", # VirtualBox
- "00:15:5d": "vm", # Hyper-V
- # Shelly
- "34:94:54": "iot",
- "84:f3:eb": "iot",
- "ec:fa:bc": "iot",
- "30:c6:f7": "iot",
- # Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
- "a0:20:a6": "iot",
- "24:62:ab": "iot",
- "30:ae:a4": "iot",
- "cc:50:e3": "iot",
- "ac:67:b2": "iot",
- "b4:e6:2d": "iot",
- "3c:71:bf": "iot",
- "8c:aa:b5": "iot",
- # Sonoff / ITEAD
- "dc:4f:22": "iot",
- "e8:db:84": "iot",
- # Tapo / TP-Link smart home
- "b0:a7:b9": "iot",
- "50:c7:bf": "iot",
- "1c:3b:f3": "iot",
- "10:27:f5": "iot",
- # Philips Hue
- "00:17:88": "iot",
- "ec:b5:fa": "iot",
- # IKEA Tradfri
- "34:13:e8": "iot",
- "00:21:2e": "iot",
- # Tuya / Smart Life (widely used chip in many brands)
- "d8:f1:5b": "iot",
- "68:57:2d": "iot",
-}
-
-
def suggest_type_from_mac(mac: str | None) -> str | None:
"""Return a suggested node type from MAC OUI, or None if unknown."""
if not mac:
return None
prefix = mac.lower()[:8]
- return _MAC_OUI_TYPES.get(prefix)
+ return _load_oui().get(prefix)
_PORT_TYPE_HINTS: dict[int, str] = {
diff --git a/backend/app/services/http_probe.py b/backend/app/services/http_probe.py
new file mode 100644
index 0000000..8ed9319
--- /dev/null
+++ b/backend/app/services/http_probe.py
@@ -0,0 +1,85 @@
+"""HTTP probe: GET a discovered port and extract identifying signals.
+
+Used by the optional deep-scan mode to confirm what service sits behind an
+open port, regardless of port number. Returns the page
plus a small
+set of identifying response headers, which fingerprint.match_service() then
+matches against signature http_regex fields.
+"""
+import asyncio
+import logging
+import re
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# Headers that commonly carry the application name.
+_SIGNAL_HEADERS = ("Server", "X-Powered-By")
+# Cap how much body we read when hunting for — avoids large downloads.
+_MAX_BODY_BYTES = 64 * 1024
+_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL)
+_PROBE_TIMEOUT = 3.0
+# Ports we never bother probing over HTTP (not web services).
+_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
+
+
+def _extract_title(body: str) -> str | None:
+ m = _TITLE_RE.search(body)
+ if not m:
+ return None
+ title = re.sub(r"\s+", " ", m.group(1)).strip()
+ return title or None
+
+
+async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
+ try:
+ resp = await client.get(url, follow_redirects=True)
+ except (httpx.HTTPError, OSError):
+ return None
+ headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
+ body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
+ title = _extract_title(body)
+ if not title and not headers:
+ return None
+ return {"title": title, "headers": headers}
+
+
+async def probe_port(
+ ip: str, port: int, verify_tls: bool = False
+) -> dict[str, Any] | None:
+ """
+ GET https:// then http:// for a port and return {title, headers} or None.
+
+ None means the port did not answer HTTP or yielded no usable signal.
+ """
+ if port in _NON_HTTP_PORTS:
+ return None
+ async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
+ for scheme in ("https", "http"):
+ result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
+ if result is not None:
+ return result
+ return None
+
+
+async def probe_open_ports(
+ ip: str,
+ open_ports: list[dict[str, Any]],
+ verify_tls: bool = False,
+ concurrency: int = 50,
+) -> list[dict[str, Any]]:
+ """
+ Probe every open port for HTTP signals (option 2: probe all, match after).
+
+ Returns the same port dicts, each enriched with an http_signals key
+ (None when the port gave no HTTP signal).
+ """
+ sem = asyncio.Semaphore(concurrency)
+
+ async def _one(p: dict[str, Any]) -> dict[str, Any]:
+ async with sem:
+ signals = await probe_port(ip, p["port"], verify_tls)
+ return {**p, "http_signals": signals}
+
+ return await asyncio.gather(*(_one(p) for p in open_ports))
diff --git a/backend/app/services/mqtt_common.py b/backend/app/services/mqtt_common.py
new file mode 100644
index 0000000..f9e915c
--- /dev/null
+++ b/backend/app/services/mqtt_common.py
@@ -0,0 +1,168 @@
+"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
+
+Holds the credential-safe error sanitizer, the TLS context builder, and a
+generic request/response round-trip over MQTT used by gateway-style APIs
+(publish a request topic, wait for a single response topic message).
+"""
+
+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]
+
+_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
+_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are 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
+
+
+async def request_response(
+ mqtt_host: str,
+ mqtt_port: int,
+ request_topic: str,
+ response_topic: str,
+ request_payload: dict[str, Any],
+ username: str | None = None,
+ password: str | None = None,
+ tls: bool = False,
+ tls_insecure: bool = False,
+ response_timeout: float = _RESPONSE_TIMEOUT,
+) -> dict[str, Any]:
+ """Publish ``request_payload`` to ``request_topic`` and return the first
+ JSON message received on ``response_topic`` as a dict.
+
+ Raises:
+ ImportError: if aiomqtt is not installed.
+ TimeoutError: if no response arrives in time.
+ ConnectionError: if the broker cannot be reached.
+ ValueError: if the response payload is not valid JSON / is empty.
+ """
+ if aiomqtt is None: # pragma: no cover
+ raise ImportError(
+ "aiomqtt is required for MQTT import. "
+ "Install it with: pip install aiomqtt"
+ )
+
+ 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(request_payload))
+
+ 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 MQTT response: {exc}") from exc
+ return
+
+ await asyncio.wait_for(_wait_for_response(), timeout=response_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 MQTT response") from exc
+
+ if not response_payload:
+ raise ValueError("Empty MQTT response received")
+
+ return response_payload
+
+
+async def test_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/TimeoutError 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
diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py
index 4f0ac43..c19d1a5 100644
--- a/backend/app/services/scanner.py
+++ b/backend/app/services/scanner.py
@@ -7,14 +7,16 @@ import re
import socket
import subprocess
import threading
+from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import Node, PendingDevice, ScanRun
+from app.db.models import PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type
+from app.services.http_probe import probe_open_ports
logger = logging.getLogger(__name__)
@@ -34,6 +36,37 @@ _EXTRA_PORTS = (
"16686,34567,37777,51413,64738"
)
+# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
+_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
+
+
+@dataclass
+class DeepScanOptions:
+ """Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
+
+ http_ranges: list[str] = field(default_factory=list)
+ http_probe_enabled: bool = False
+ verify_tls: bool = False
+
+
+def _valid_port_range(spec: str) -> bool:
+ if not _PORT_RANGE_RE.match(spec):
+ return False
+ parts = [int(p) for p in spec.split("-")]
+ if any(p < 1 or p > 65535 for p in parts):
+ return False
+ return len(parts) == 1 or parts[0] <= parts[1]
+
+
+def _build_port_spec(http_ranges: list[str] | None) -> str:
+ """Combine the default port list with validated user ranges for nmap -p."""
+ if not http_ranges:
+ return _EXTRA_PORTS
+ extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
+ if not extra:
+ return _EXTRA_PORTS
+ return _EXTRA_PORTS + "," + ",".join(extra)
+
_MDNS_SERVICE_TYPES = [
"_http._tcp.local.",
"_shelly._tcp.local.",
@@ -195,7 +228,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
return alive
-def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
+def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
@@ -210,11 +243,11 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
- scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
+ scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
- scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
+ scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
@@ -252,7 +285,9 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
return host_dict
-async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
+async def _nmap_port_scan(
+ alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS
+) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
@@ -266,7 +301,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
- return await asyncio.to_thread(_nmap_scan_single, host_dict)
+ return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
@@ -279,7 +314,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
return results
-async def _nmap_scan(target: str) -> list[dict[str, Any]]:
+async def _nmap_scan(target: str, port_spec: str = _EXTRA_PORTS) -> list[dict[str, Any]]:
"""
Two-phase scan for a CIDR range.
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
@@ -296,7 +331,7 @@ async def _nmap_scan(target: str) -> list[dict[str, Any]]:
except Exception as exc:
logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
- return await _nmap_port_scan(alive)
+ return await _nmap_port_scan(alive, port_spec)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
@@ -375,10 +410,50 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
]
-async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
+async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
+ """Collapse duplicate non-hidden inventory rows that share an IP into one.
+
+ Keeps an ``approved`` row when present (it carries canvas-link semantics),
+ otherwise the oldest row, and deletes the rest. Returns the number deleted.
+ """
+ rows = (await db.execute(
+ select(PendingDevice)
+ .where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
+ .order_by(PendingDevice.discovered_at)
+ )).scalars().all()
+
+ by_ip: dict[str, list[PendingDevice]] = {}
+ for row in rows:
+ if row.ip is None: # guarded by the query, but keeps the type checker happy
+ continue
+ by_ip.setdefault(row.ip, []).append(row)
+
+ deleted = 0
+ for group in by_ip.values():
+ if len(group) < 2:
+ continue
+ keep = next((r for r in group if r.status == "approved"), group[0])
+ for dup in group:
+ if dup is not keep:
+ await db.delete(dup)
+ deleted += 1
+ if deleted:
+ await db.commit()
+ return deleted
+
+
+async def run_scan(
+ ranges: list[str],
+ db: AsyncSession,
+ run_id: str,
+ deep_scan: DeepScanOptions | None = None,
+) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices."""
from app.api.routes.status import broadcast_scan_update
+ deep_scan = deep_scan or DeepScanOptions()
+ port_spec = _build_port_spec(deep_scan.http_ranges)
+
devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try:
@@ -389,25 +464,18 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
- # Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
- canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
- canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
-
+ # Pre-fetch hidden IPs once — avoids N+1 queries per host.
+ # Devices already on a canvas are intentionally NOT suppressed: they stay
+ # in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
- # Clean up stale pending devices whose IPs are already in the canvas
- if canvas_ips:
- from sqlalchemy import delete as sa_delete
- await db.execute(
- sa_delete(PendingDevice).where(
- PendingDevice.status == "pending",
- PendingDevice.ip.in_(canvas_ips),
- )
- )
- await db.commit()
+ # Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
+ # left over from older scans, so the device shows up exactly once even if
+ # it isn't re-discovered this run (e.g. now offline).
+ await _dedupe_pending_by_ip(db)
# Start mDNS discovery in the background while nmap scans run
mdns_task = asyncio.create_task(_mdns_discover())
@@ -419,30 +487,48 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
nonlocal devices_found
ip = host["ip"]
- # Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
- if ip in canvas_ips:
- logger.debug("Skipping %s — already in canvas", ip)
- return
+ # Skip only user-hidden devices. On-canvas devices are kept so they
+ # surface in the inventory with a canvas-presence badge.
if ip in hidden_ips:
logger.debug("Skipping %s — hidden by user", ip)
return
- services = fingerprint_ports(host["open_ports"])
- suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
-
- existing_result = await db.execute(
- select(PendingDevice).where(
- PendingDevice.ip == ip,
- PendingDevice.status == "pending",
+ open_ports = host["open_ports"]
+ # Deep-scan HTTP probe: enrich open ports with title/header signals so
+ # fingerprint can confirm services on custom ports. No-op when disabled
+ # or when the host has no open ports (e.g. mDNS-only discovery).
+ if deep_scan.http_probe_enabled and open_ports:
+ open_ports = await probe_open_ports(
+ ip, open_ports, verify_tls=deep_scan.verify_tls
)
- )
- existing = existing_result.scalar_one_or_none()
- if existing:
- existing.mac = host.get("mac") or existing.mac
- existing.hostname = host.get("hostname") or existing.hostname
- existing.os = host.get("os") or existing.os
- existing.services = services
- existing.suggested_type = suggested_type
+
+ services = fingerprint_ports(open_ports)
+ suggested_type = suggest_node_type(open_ports, host.get("mac"))
+
+ # One inventory row per device (by IP). Match across pending AND
+ # approved so a re-scan of an already-approved device refreshes its
+ # row instead of spawning a fresh "pending" duplicate. Hidden rows
+ # are already skipped above.
+ existing_rows = (await db.execute(
+ select(PendingDevice)
+ .where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
+ .order_by(PendingDevice.discovered_at)
+ )).scalars().all()
+
+ if existing_rows:
+ # Prefer an approved row (it owns the canvas link semantics),
+ # otherwise the oldest. Collapse any leftover duplicates created
+ # by earlier scans.
+ keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
+ for dup in existing_rows:
+ if dup is not keep:
+ await db.delete(dup)
+ keep.mac = host.get("mac") or keep.mac
+ keep.hostname = host.get("hostname") or keep.hostname
+ keep.os = host.get("os") or keep.os
+ keep.services = services
+ keep.suggested_type = suggested_type
+ # status preserved — an approved device stays approved.
else:
db.add(PendingDevice(
ip=ip,
@@ -463,7 +549,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
for cidr in ranges:
if _is_cancelled(run_id):
break
- hosts = await _nmap_scan(cidr)
+ hosts = await _nmap_scan(cidr, port_spec)
for host in hosts:
if _is_cancelled(run_id):
break
diff --git a/backend/app/services/zigbee_service.py b/backend/app/services/zigbee_service.py
index d09e255..93b1bdb 100644
--- a/backend/app/services/zigbee_service.py
+++ b/backend/app/services/zigbee_service.py
@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio
import json
import logging
-import ssl
from typing import Any
+from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
+
logger = logging.getLogger(__name__)
try:
@@ -20,42 +21,8 @@ _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
+# Re-exported for backwards compatibility — these now live in mqtt_common.
+__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
def build_zigbee_properties(
diff --git a/backend/app/services/zwave_service.py b/backend/app/services/zwave_service.py
new file mode 100644
index 0000000..b32ea80
--- /dev/null
+++ b/backend/app/services/zwave_service.py
@@ -0,0 +1,235 @@
+"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
+
+Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
+MQTT: publish to ``/_CLIENTS/ZWAVE_GATEWAY-/api/getNodes/set`` and
+read the answer from ``/_CLIENTS/ZWAVE_GATEWAY-/api/getNodes``.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from app.services.mqtt_common import request_response, test_connection
+from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
+
+logger = logging.getLogger(__name__)
+
+# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
+merge_zwave_properties = merge_zigbee_properties
+
+_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
+_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
+
+
+def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
+ """Map a Z-Wave node's role flags to a homelable node type.
+
+ Controller → coordinator. Mains-powered / routing nodes → router.
+ Everything else (battery sensors, etc.) → end device.
+ """
+ if raw.get("isControllerNode"):
+ return "zwave_coordinator"
+ if raw.get("isRouting"):
+ return "zwave_router"
+ return "zwave_enddevice"
+
+
+def _role_label(node_type: str) -> str:
+ """Human role string stored as ``device_subtype`` / ``device_type``."""
+ return {
+ "zwave_coordinator": "Controller",
+ "zwave_router": "Router",
+ "zwave_enddevice": "EndDevice",
+ }.get(node_type, "EndDevice")
+
+
+def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
+ """Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
+ node_id = raw.get("id")
+ if node_id is None:
+ return None
+ ieee = f"zwave-{home_id}-{node_id}"
+ node_type = _zwave_type_to_homelable(raw)
+ name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
+ model = raw.get("productLabel") or raw.get("productDescription") or None
+ vendor = raw.get("manufacturer") or None
+ return {
+ "id": ieee,
+ "label": name,
+ "type": node_type,
+ "ieee_address": ieee,
+ "friendly_name": name,
+ "device_type": _role_label(node_type),
+ "node_id": node_id,
+ "model": model,
+ "vendor": vendor,
+ "lqi": None, # Z-Wave has no LQI; RSSI may be added later.
+ "parent_id": None,
+ "neighbors": raw.get("neighbors") or [],
+ }
+
+
+def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
+ """Pick a home id for the network: prefer the controller's, else any node's."""
+ controller_home = None
+ for entry in raw_nodes:
+ if not isinstance(entry, dict):
+ continue
+ home = entry.get("homeId")
+ if home is None:
+ continue
+ if entry.get("isControllerNode"):
+ return str(home)
+ if controller_home is None:
+ controller_home = str(home)
+ return controller_home or "0"
+
+
+def parse_zwave_nodes(
+ payload: dict[str, Any],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
+
+ Expected shape::
+
+ {"success": true, "result": [ {}, ... ]}
+
+ Edges are a strict coordinator → router → end-device tree, derived from
+ each node's ``neighbors`` list (same approach as the Zigbee parser).
+ """
+ if payload.get("success") is False:
+ raise ValueError("Z-Wave gateway reported failure")
+
+ result = payload.get("result")
+ if result is None:
+ result = []
+ if not isinstance(result, list):
+ raise ValueError("Malformed getNodes response: 'result' is not a list")
+
+ home_id = _resolve_home_id(result)
+
+ nodes_list: list[dict[str, Any]] = []
+ seen_ids: set[str] = set()
+ coordinator_id: str | None = None
+ # Map nodeId (int) → identity string, to translate neighbors → edges.
+ id_by_node_id: dict[Any, str] = {}
+
+ for entry in result:
+ if not isinstance(entry, dict):
+ continue
+ node = _node_from_zwave(entry, home_id)
+ if node is None or node["id"] in seen_ids:
+ continue
+ seen_ids.add(node["id"])
+ id_by_node_id[node["node_id"]] = node["id"]
+ nodes_list.append(node)
+ if node["type"] == "zwave_coordinator":
+ coordinator_id = node["id"]
+
+ # Translate neighbor lists into candidate edges (only between known nodes).
+ raw_edges: list[dict[str, Any]] = []
+ for node in nodes_list:
+ src = node["id"]
+ for neighbor in node.get("neighbors") or []:
+ tgt = id_by_node_id.get(neighbor)
+ if tgt and tgt != src:
+ raw_edges.append({"source": src, "target": tgt})
+
+ # Build parent_id hierarchy: coordinator → routers → end devices.
+ if coordinator_id:
+ router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
+ for node in nodes_list:
+ if node["type"] == "zwave_router":
+ node["parent_id"] = coordinator_id
+ elif node["type"] == "zwave_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")
+ ]
+
+ # Drop transient helper keys before returning.
+ for node in nodes_list:
+ node.pop("neighbors", None)
+ node.pop("node_id", None)
+
+ return nodes_list, edges_list
+
+
+def build_zwave_properties(
+ ieee: str | None,
+ vendor: str | None,
+ model: str | None,
+) -> list[dict[str, Any]]:
+ """Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
+
+ Z-Wave has no LQI, so that row is omitted. New props default to
+ ``visible=False`` — users opt in from the right panel.
+ """
+ props: list[dict[str, Any]] = []
+ if ieee:
+ props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
+ if vendor:
+ props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
+ if model:
+ props.append({"key": "Model", "value": model, "icon": None, "visible": False})
+ return props
+
+
+async def fetch_zwave_network(
+ mqtt_host: str,
+ mqtt_port: int,
+ prefix: str = "zwave",
+ gateway_name: str = "zwavejs2mqtt",
+ 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 broker, request the Z-Wave node list, return (nodes, edges).
+
+ Raises:
+ TimeoutError: if the gateway does not respond in time.
+ ConnectionError: if the broker cannot be reached.
+ ValueError: if the response payload is malformed.
+ """
+ request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
+ response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
+
+ payload = await request_response(
+ mqtt_host=mqtt_host,
+ mqtt_port=mqtt_port,
+ request_topic=request_topic,
+ response_topic=response_topic,
+ request_payload={"args": []},
+ username=username,
+ password=password,
+ tls=tls,
+ tls_insecure=tls_insecure,
+ )
+
+ return parse_zwave_nodes(payload)
+
+
+async def test_zwave_connection(
+ mqtt_host: str,
+ mqtt_port: int,
+ username: str | None = None,
+ password: str | None = None,
+ tls: bool = False,
+ tls_insecure: bool = False,
+) -> bool:
+ """Quick MQTT reachability check for the Z-Wave broker."""
+ return await test_connection(
+ mqtt_host=mqtt_host,
+ mqtt_port=mqtt_port,
+ username=username,
+ password=password,
+ tls=tls,
+ tls_insecure=tls_insecure,
+ )
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 01720fc..0b02927 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -15,7 +15,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917
websockets==13.1
httpx==0.27.2
-zeroconf==0.149.12
+zeroconf==0.149.16
aiomqtt==2.3.0
# Dev
diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py
index 713b9bf..bdf487e 100644
--- a/backend/tests/test_fingerprint.py
+++ b/backend/tests/test_fingerprint.py
@@ -2,7 +2,13 @@ from unittest.mock import patch
import pytest
-from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type
+from app.services.fingerprint import (
+ fingerprint_ports,
+ match_port,
+ match_service,
+ suggest_node_type,
+ suggest_type_from_mac,
+)
MOCK_SIGNATURES = [
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
@@ -173,3 +179,157 @@ def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
{"port": 1883, "protocol": "tcp"},
])
assert result == "iot"
+
+
+# ── OUI vendor detection ──────────────────────────────────────────────────────
+
+def test_suggest_type_from_mac_mikrotik_returns_router():
+ # The motivating case: MikroTik MAC should be recognized as a router
+ assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
+ assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
+
+
+def test_suggest_type_from_mac_ubiquiti_returns_ap():
+ # Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
+ # so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
+ assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
+ assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
+
+
+def test_suggest_type_from_mac_synology_returns_nas():
+ assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
+
+
+def test_suggest_type_from_mac_qnap_returns_nas():
+ assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
+
+
+def test_suggest_type_from_mac_hikvision_returns_camera():
+ assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
+
+
+def test_suggest_type_from_mac_dahua_returns_camera():
+ assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
+
+
+def test_suggest_type_from_mac_cisco_returns_switch():
+ assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
+
+
+def test_suggest_type_from_mac_raspberry_pi_returns_server():
+ assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
+
+
+def test_suggest_type_from_mac_handles_uppercase():
+ # MACs may arrive in any case; lookup must be case-insensitive
+ assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
+
+
+def test_suggest_type_from_mac_unknown_oui_returns_none():
+ assert suggest_type_from_mac("00:00:01:11:22:33") is None
+
+
+def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
+ # MikroTik device with no scanned ports should still be classified as router via MAC
+ assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
+
+
+def test_suggest_node_type_synology_mac_with_http_returns_nas():
+ # NAS priority beats server, so a Synology MAC + open HTTP → nas
+ result = suggest_node_type(
+ [{"port": 80, "protocol": "tcp"}],
+ mac="00:11:32:11:22:33",
+ )
+ assert result == "nas"
+
+
+def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
+ # Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
+ result = suggest_node_type(
+ [{"port": 179, "protocol": "tcp"}],
+ mac="24:a4:3c:11:22:33",
+ )
+ assert result == "router"
+
+
+# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
+
+HTTP_SIGNATURES = [
+ # Generic web fallback on 8096 (port-only guess)
+ {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
+ "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
+ # Same port, but confirmed by HTML title → should win when probe confirms
+ {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
+ "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
+ # Port-agnostic: matches on HTTP content regardless of port
+ {"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
+ "service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
+ # Banner-based entry, no http
+ {"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
+ "service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
+]
+
+
+@pytest.fixture
+def http_signatures():
+ with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
+ yield
+
+
+def test_http_regex_confirmed_beats_port_only(http_signatures):
+ # Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": "Jellyfin", "headers": {}})
+ assert sig["service_name"] == "Jellyfin"
+
+
+def test_http_regex_matches_on_header(http_signatures):
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
+ assert sig["service_name"] == "Jellyfin"
+
+
+def test_http_regex_miss_falls_back_to_port_only(http_signatures):
+ # Probe ran but nothing matched the http_regex → generic port-only entry wins
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": "Some Other App", "headers": {}})
+ assert sig["service_name"] == "HTTP"
+
+
+def test_probe_disabled_ignores_http_regex(http_signatures):
+ # http_signals=None (deep scan off) → http_regex entry degrades to port-only,
+ # generic entry (listed first) wins — identical to pre-probe behaviour.
+ sig = match_service(8096, "tcp", banner=None, http_signals=None)
+ assert sig["service_name"] == "HTTP"
+
+
+def test_port_agnostic_match_on_custom_port(http_signatures):
+ # Portainer found on a non-standard port, recognised purely by HTTP content
+ sig = match_service(54321, "tcp", banner=None,
+ http_signals={"title": "Portainer", "headers": {}})
+ assert sig["service_name"] == "Portainer"
+
+
+def test_port_agnostic_requires_probe(http_signatures):
+ # Same custom port, probe off → no signal → no match
+ assert match_service(54321, "tcp", banner=None, http_signals=None) is None
+
+
+def test_banner_match_still_works_with_probe(http_signatures):
+ sig = match_service(9090, "tcp", banner="prometheus 2.x",
+ http_signals={"title": "x", "headers": {}})
+ assert sig["service_name"] == "Prometheus"
+
+
+def test_match_port_alias_has_no_http(http_signatures):
+ # match_port() is the probe-less alias → http_regex entry degrades to port-only
+ sig = match_port(8096, "tcp")
+ assert sig["service_name"] == "HTTP"
+
+
+def test_fingerprint_ports_uses_http_signals(http_signatures):
+ results = fingerprint_ports([
+ {"port": 8096, "protocol": "tcp", "banner": None,
+ "http_signals": {"title": "Jellyfin", "headers": {}}},
+ ])
+ assert results[0]["service_name"] == "Jellyfin"
diff --git a/backend/tests/test_http_probe.py b/backend/tests/test_http_probe.py
new file mode 100644
index 0000000..4175d63
--- /dev/null
+++ b/backend/tests/test_http_probe.py
@@ -0,0 +1,118 @@
+"""Tests for the HTTP probe used by deep-scan service identification."""
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from app.services.http_probe import (
+ _extract_title,
+ probe_open_ports,
+ probe_port,
+)
+
+
+def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
+ return httpx.Response(status_code=status, text=text, headers=headers or {})
+
+
+# ── _extract_title ──────────────────────────────────────────────────────────
+
+def test_extract_title_basic():
+ assert _extract_title("Jellyfin") == "Jellyfin"
+
+
+def test_extract_title_collapses_whitespace():
+ assert _extract_title("\n My App\n") == "My App"
+
+
+def test_extract_title_missing():
+ assert _extract_title("no title") is None
+
+
+def test_extract_title_case_insensitive():
+ assert _extract_title("Portainer") == "Portainer"
+
+
+# ── probe_port ──────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_probe_port_reads_title():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("Jellyfin"))):
+ result = await probe_port("10.0.0.5", 8096)
+ assert result == {"title": "Jellyfin", "headers": {}}
+
+
+@pytest.mark.asyncio
+async def test_probe_port_reads_headers():
+ resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
+ result = await probe_port("10.0.0.5", 3000)
+ assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
+
+
+@pytest.mark.asyncio
+async def test_probe_port_falls_back_to_http():
+ # https raises, http succeeds
+ calls = {"n": 0}
+
+ async def fake_get(self, url, **kw):
+ calls["n"] += 1
+ if url.startswith("https"):
+ raise httpx.ConnectError("tls fail")
+ return _response("HTTP App")
+
+ with patch("httpx.AsyncClient.get", new=fake_get):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result["title"] == "HTTP App"
+ assert calls["n"] == 2 # tried https then http
+
+
+@pytest.mark.asyncio
+async def test_probe_port_no_signal_returns_none():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_probe_port_timeout_returns_none():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_probe_port_skips_non_http_ports():
+ # SSH should never trigger an HTTP request
+ get = AsyncMock()
+ with patch("httpx.AsyncClient.get", new=get):
+ result = await probe_port("10.0.0.5", 22)
+ assert result is None
+ get.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_probe_port_verify_tls_flag_passed():
+ with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
+ instance = client_cls.return_value.__aenter__.return_value
+ instance.get = AsyncMock(return_value=_response("X"))
+ await probe_port("10.0.0.5", 8443, verify_tls=True)
+ assert client_cls.call_args.kwargs["verify"] is True
+
+
+# ── probe_open_ports ─────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_probe_open_ports_enriches_each_port():
+ async def fake_get(self, url, **kw):
+ if ":8096" in url:
+ return _response("Jellyfin")
+ return _response("")
+
+ ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
+ with patch("httpx.AsyncClient.get", new=fake_get):
+ result = await probe_open_ports("10.0.0.5", ports)
+
+ by_port = {p["port"]: p for p in result}
+ assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
+ assert by_port[9999]["http_signals"] is None
diff --git a/backend/tests/test_mqtt_common.py b/backend/tests/test_mqtt_common.py
new file mode 100644
index 0000000..cd18675
--- /dev/null
+++ b/backend/tests/test_mqtt_common.py
@@ -0,0 +1,183 @@
+"""Unit tests for the shared MQTT helpers in mqtt_common."""
+
+from __future__ import annotations
+
+import json
+import ssl
+from unittest.mock import patch
+
+import pytest
+
+from app.services.mqtt_common import (
+ _build_tls_context,
+ _sanitize_mqtt_error,
+ request_response,
+)
+from app.services.mqtt_common import test_connection as _test_connection
+
+# ---------------------------------------------------------------------------
+# _sanitize_mqtt_error — never leak credentials
+# ---------------------------------------------------------------------------
+
+
+def test_sanitize_auth_error() -> None:
+ msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
+ assert msg == "Authentication failed"
+ 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() -> None:
+ msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
+ assert msg == "Broker hostname could not be resolved"
+ assert "broker.lan" not in msg
+
+
+def test_sanitize_tls() -> None:
+ assert _sanitize_mqtt_error(
+ Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
+ ) == "TLS handshake failed"
+
+
+def test_sanitize_timeout() -> None:
+ assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
+
+
+def test_sanitize_unknown_falls_back() -> None:
+ msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
+ assert msg == "MQTT connection failed"
+ assert "hunter2" not in msg
+
+
+# ---------------------------------------------------------------------------
+# _build_tls_context
+# ---------------------------------------------------------------------------
+
+
+def test_tls_secure_verifies() -> None:
+ ctx = _build_tls_context(insecure=False)
+ assert ctx.check_hostname is True
+ assert ctx.verify_mode == ssl.CERT_REQUIRED
+
+
+def test_tls_insecure_disables_verification() -> None:
+ ctx = _build_tls_context(insecure=True)
+ assert ctx.check_hostname is False
+ assert ctx.verify_mode == ssl.CERT_NONE
+
+
+# ---------------------------------------------------------------------------
+# request_response (mocked aiomqtt)
+# ---------------------------------------------------------------------------
+
+_SAMPLE = {"success": True, "result": []}
+
+
+def _fake_client_factory(topic: str, payload: dict):
+ class _FakeMessage:
+ _yielded = False
+
+ def __init__(self) -> None:
+ self.topic = topic
+ self.payload = json.dumps(payload).encode()
+
+ 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()
+
+ return _FakeClient
+
+
+@pytest.mark.asyncio
+async def test_request_response_success() -> None:
+ topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
+ mock_aiomqtt.MqttError = Exception
+ out = await request_response(
+ "localhost", 1883, "req/topic", topic, {"args": []}
+ )
+ assert out == _SAMPLE
+
+
+@pytest.mark.asyncio
+async def test_request_response_connection_error() -> None:
+ class _FakeClient:
+ async def __aenter__(self):
+ raise Exception("Connection refused")
+
+ async def __aexit__(self, *_):
+ pass
+
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _FakeClient()
+ mock_aiomqtt.MqttError = Exception
+ with pytest.raises(ConnectionError):
+ await request_response("bad", 1883, "req", "resp", {})
+
+
+@pytest.mark.asyncio
+async def test_request_response_passes_tls_context() -> None:
+ topic = "resp"
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
+ mock_aiomqtt.MqttError = Exception
+ await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
+ ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
+ assert ctx.verify_mode == ssl.CERT_NONE
+
+
+@pytest.mark.asyncio
+async def test_test_connection_success() -> None:
+ class _FakeClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_):
+ pass
+
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _FakeClient()
+ mock_aiomqtt.MqttError = Exception
+ assert await _test_connection("localhost", 1883) is True
+
+
+@pytest.mark.asyncio
+async def test_test_connection_failure() -> None:
+ class _FakeClient:
+ async def __aenter__(self):
+ raise Exception("refused")
+
+ async def __aexit__(self, *_):
+ pass
+
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _FakeClient()
+ mock_aiomqtt.MqttError = Exception
+ with pytest.raises(ConnectionError):
+ await _test_connection("bad", 1883)
diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py
index 6feb0b9..b9213c8 100644
--- a/backend/tests/test_scan.py
+++ b/backend/tests/test_scan.py
@@ -7,7 +7,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import Node, PendingDevice, ScanRun
+from app.db.models import Design, Node, PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@@ -122,7 +122,8 @@ async def test_background_scan_success_path_invokes_run_scan(mem_db):
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
):
- await _background_scan(run_id, ["10.0.0.0/24"])
+ from app.services.scanner import DeepScanOptions
+ await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
mock_run_scan.assert_awaited_once()
@@ -166,6 +167,66 @@ async def test_list_pending_returns_device(client: AsyncClient, headers, pending
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server"
+ # No matching node → not on any canvas.
+ assert data[0]["canvas_count"] == 0
+
+
+# --- Canvas-presence correlation (canvas_count) ---
+
+async def _add_design(db_session, name: str) -> str:
+ design = Design(id=str(uuid.uuid4()), name=name)
+ db_session.add(design)
+ await db_session.commit()
+ return design.id
+
+
+def _node(design_id: str, *, ip=None, ieee=None) -> Node:
+ return Node(
+ id=str(uuid.uuid4()), label="n", type="server", status="online",
+ ip=ip, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
+ design_id=design_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
+ # Same IP placed on two different canvases → canvas_count == 2.
+ d1 = await _add_design(db_session, "Home")
+ d2 = await _add_design(db_session, "Lab")
+ db_session.add(_node(d1, ip="192.168.1.100"))
+ db_session.add(_node(d2, ip="192.168.1.100"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ data = res.json()
+ assert len(data) == 1
+ assert data[0]["canvas_count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
+ device = PendingDevice(
+ id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
+ suggested_type="zigbee_enddevice", services=[], status="pending",
+ )
+ db_session.add(device)
+ d1 = await _add_design(db_session, "Zigbee")
+ db_session.add(_node(d1, ieee="0x00124b001"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ by_id = {d["id"]: d for d in res.json()}
+ assert by_id[device.id]["canvas_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
+ # A node with no design_id is not "on a canvas".
+ db_session.add(_node(None, ip="192.168.1.100"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ assert res.json()[0]["canvas_count"] == 0
# --- Approve device ---
@@ -190,9 +251,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
assert data["approved"] is True
assert "node_id" in data
- # Device should no longer appear in pending list
+ # Approved devices stay in the inventory (status != "hidden") so they keep
+ # showing with an "In N canvas" badge — they are no longer dropped.
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
- assert pending_res.json() == []
+ inventory = pending_res.json()
+ assert len(inventory) == 1
+ assert inventory[0]["id"] == pending_device.id
+ assert inventory[0]["status"] == "approved"
@pytest.mark.asyncio
@@ -331,8 +396,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
@pytest.mark.asyncio
-async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
- """Pending devices that were already in canvas before scan starts must be removed."""
+async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
+ """Pending devices whose IP is already on a canvas are NOT purged — they stay
+ in the inventory and are surfaced with an "In N canvas" badge."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
@@ -371,12 +437,13 @@ async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncS
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
- assert result.scalar_one_or_none() is None
+ assert result.scalar_one_or_none() is not None
@pytest.mark.asyncio
-async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
- """Devices whose IP already exists as a canvas Node must not appear in pending."""
+async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
+ """A scanned IP that already exists as a canvas Node still produces a pending
+ device (no longer suppressed)."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
@@ -404,7 +471,62 @@ async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
- assert result.scalar_one_or_none() is None
+ device = result.scalar_one_or_none()
+ assert device is not None
+ assert device.status == "pending"
+
+
+@pytest.mark.asyncio
+async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
+ """Re-scanning an already-approved device updates its row in place instead of
+ spawning a fresh pending duplicate, and keeps it approved."""
+ approved = PendingDevice(
+ id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
+ os=None, services=[], suggested_type="server", status="approved",
+ )
+ db_session.add(approved)
+ run_id = str(uuid.uuid4())
+ db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
+ await db_session.commit()
+
+ with (
+ patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
+ ):
+ await run_scan(["192.168.1.0/24"], db_session, run_id)
+
+ rows = (await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
+ )).scalars().all()
+ assert len(rows) == 1
+ assert rows[0].status == "approved"
+ assert rows[0].hostname == "myhost.lan" # refreshed from the scan
+
+
+@pytest.mark.asyncio
+async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
+ """Pre-existing duplicate inventory rows for one IP are collapsed to a single
+ row at scan start, even if the device is not re-discovered."""
+ for status in ("approved", "pending", "pending"):
+ db_session.add(PendingDevice(
+ id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
+ os=None, services=[], suggested_type="server", status=status,
+ ))
+ run_id = str(uuid.uuid4())
+ db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
+ await db_session.commit()
+
+ with (
+ patch("app.services.scanner._nmap_scan", return_value=[]),
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
+ ):
+ await run_scan(["192.168.1.0/24"], db_session, run_id)
+
+ rows = (await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
+ )).scalars().all()
+ assert len(rows) == 1
+ assert rows[0].status == "approved" # approved row is the one kept
@pytest.mark.asyncio
@@ -518,7 +640,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
call_count = 0
- def nmap_side_effect(target: str):
+ def nmap_side_effect(target: str, port_spec: str | None = None):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
@@ -612,9 +734,11 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
- # Pending list should now be empty
+ # Approved devices stay in the inventory, now marked "approved".
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
- assert pending_res.json() == []
+ inventory = pending_res.json()
+ assert len(inventory) == 2
+ assert all(d["status"] == "approved" for d in inventory)
@pytest.fixture
@@ -1122,3 +1246,187 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert links == [] # consumed
+
+
+# --- Deep scan: trigger overrides + config persistence ---
+
+@pytest.mark.asyncio
+async def test_resolve_deep_scan_falls_back_to_settings():
+ from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_http_ranges = ["7000-7100"]
+ mock_settings.scanner_http_probe_enabled = True
+ mock_settings.scanner_http_verify_tls = False
+ # Empty payload → all values come from settings defaults
+ ds = _resolve_deep_scan(TriggerScanRequest())
+ assert ds.http_ranges == ["7000-7100"]
+ assert ds.http_probe_enabled is True
+ assert ds.verify_tls is False
+
+
+@pytest.mark.asyncio
+async def test_resolve_deep_scan_override_wins():
+ from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ ds = _resolve_deep_scan(
+ TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
+ )
+ assert ds.http_ranges == ["9000"]
+ assert ds.http_probe_enabled is True
+ assert ds.verify_tls is True
+
+
+@pytest.mark.asyncio
+async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
+ captured = {}
+
+ async def fake_bg(run_id, ranges, deep_scan):
+ captured["deep_scan"] = deep_scan
+
+ with (
+ patch("app.api.routes.scan._background_scan", new=fake_bg),
+ patch("app.api.routes.scan.settings") as mock_settings,
+ ):
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ res = await client.post(
+ "/api/v1/scan/trigger",
+ json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ assert captured["deep_scan"].http_probe_enabled is True
+ assert captured["deep_scan"].http_ranges == ["8000-8100"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ res = await client.post(
+ "/api/v1/scan/trigger",
+ json={"http_ranges": ["70000-80000"]},
+ headers=headers,
+ )
+ assert res.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = ["8000-8100"]
+ mock_settings.scanner_http_probe_enabled = True
+ mock_settings.scanner_http_verify_tls = False
+ res = await client.get("/api/v1/scan/config", headers=headers)
+ assert res.status_code == 200
+ data = res.json()
+ assert data["http_ranges"] == ["8000-8100"]
+ assert data["http_probe_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
+ saved = {}
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ mock_settings.save_overrides = lambda: saved.update(
+ http_ranges=mock_settings.scanner_http_ranges,
+ probe=mock_settings.scanner_http_probe_enabled,
+ )
+ res = await client.post(
+ "/api/v1/scan/config",
+ json={
+ "ranges": ["192.168.1.0/24"],
+ "http_ranges": ["9000-9100"],
+ "http_probe_enabled": True,
+ "verify_tls": True,
+ },
+ headers=headers,
+ )
+ assert res.status_code == 200
+ assert saved == {"http_ranges": ["9000-9100"], "probe": True}
+
+
+# --- Z-Wave approve: active design targeting + wireless props (regression) ---
+
+@pytest.mark.asyncio
+async def test_bulk_approve_targets_requested_design(client, headers, db_session):
+ """bulk-approve must place nodes on the design_id sent by the UI, not the
+ first design — otherwise approved devices land on the wrong canvas."""
+ first = await _add_design(db_session, "Default") # first design (fallback)
+ active = await _add_design(db_session, "zwave") # the design the user is on
+ dev = PendingDevice(
+ id=str(uuid.uuid4()),
+ ieee_address="zwave-H-2",
+ friendly_name="Living Room Plug",
+ suggested_type="zwave_router",
+ device_subtype="Router",
+ vendor="Aeotec",
+ model="ZW096",
+ status="pending",
+ discovery_source="zwave",
+ )
+ db_session.add(dev)
+ await db_session.commit()
+
+ res = await client.post(
+ "/api/v1/scan/pending/bulk-approve",
+ json={"device_ids": [dev.id], "design_id": active},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ assert res.json()["approved"] == 1
+
+ node = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
+ ).scalar_one()
+ assert node.design_id == active
+ assert node.design_id != first
+ # Z-Wave device → online + Z-Wave property rows, no ICMP check.
+ assert node.status == "online"
+ assert node.check_method == "none"
+ assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
+
+
+@pytest.mark.asyncio
+async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
+ active = await _add_design(db_session, "zwave")
+ dev = PendingDevice(
+ id=str(uuid.uuid4()),
+ ieee_address="zwave-H-9",
+ friendly_name="Door Sensor",
+ suggested_type="zwave_enddevice",
+ vendor="Aeotec",
+ model="ZW120",
+ status="pending",
+ discovery_source="zwave",
+ )
+ db_session.add(dev)
+ await db_session.commit()
+
+ res = await client.post(
+ f"/api/v1/scan/pending/{dev.id}/approve",
+ json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
+ headers=headers,
+ )
+ assert res.status_code == 200
+
+ node = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
+ ).scalar_one()
+ assert node.design_id == active
+ assert node.status == "online"
+ assert node.check_method == "none"
+ assert any(p["key"] == "Z-Wave ID" for p in node.properties)
diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py
index e29e2fc..2b02326 100644
--- a/backend/tests/test_scanner.py
+++ b/backend/tests/test_scanner.py
@@ -359,7 +359,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception():
call_count = 0
- def _flaky_scan(host_dict):
+ def _flaky_scan(host_dict, port_spec=None):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
@@ -457,8 +457,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
@pytest.mark.asyncio
-async def test_run_scan_skips_canvas_nodes(mem_db):
- """Hosts already approved onto the canvas must be skipped."""
+async def test_run_scan_keeps_canvas_nodes(mem_db):
+ """Hosts already on a canvas are NOT suppressed — they stay in the inventory
+ (badged "In N canvas" via correlation), so a re-scan still records them."""
from app.services.scanner import run_scan
run_id = _make_run_id()
@@ -481,7 +482,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db):
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
- assert result.scalar_one_or_none() is None
+ device = result.scalar_one_or_none()
+ assert device is not None
+ assert device.status == "pending"
@pytest.mark.asyncio
@@ -533,3 +536,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
+
+
+# ---------------------------------------------------------------------------
+# Deep scan: port-range plumbing + HTTP probe
+# ---------------------------------------------------------------------------
+
+def test_valid_port_range():
+ from app.services.scanner import _valid_port_range
+
+ assert _valid_port_range("8080")
+ assert _valid_port_range("8000-8100")
+ assert not _valid_port_range("8100-8000") # reversed
+ assert not _valid_port_range("0") # below 1
+ assert not _valid_port_range("70000") # above 65535
+ assert not _valid_port_range("abc")
+ assert not _valid_port_range("80,443") # not a single range
+
+
+def test_build_port_spec_default_when_empty():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ assert _build_port_spec([]) == _EXTRA_PORTS
+ assert _build_port_spec(None) == _EXTRA_PORTS
+
+
+def test_build_port_spec_appends_valid_ranges():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ spec = _build_port_spec(["8000-8100", "9000"])
+ assert spec == _EXTRA_PORTS + ",8000-8100,9000"
+
+
+def test_build_port_spec_drops_invalid_ranges():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ # invalid entries silently dropped; only valid kept
+ assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
+ assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
+
+
+@pytest.mark.asyncio
+async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
+ from app.services.scanner import DeepScanOptions, run_scan
+
+ run_id = _make_run_id()
+ async with mem_db() as session:
+ session.add(_make_scan_run(run_id))
+ await session.commit()
+
+ captured = {}
+
+ async def fake_nmap(target, port_spec):
+ captured["port_spec"] = port_spec
+ return []
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
+ patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
+ await run_scan(
+ ["192.168.1.0/24"], session, run_id,
+ deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
+ )
+
+ assert "8000-8100" in captured["port_spec"]
+
+
+@pytest.mark.asyncio
+async def test_run_scan_probe_enriches_services(mem_db):
+ """With probe enabled, a custom-port service is identified via HTTP signals."""
+ from app.services.scanner import DeepScanOptions, run_scan
+
+ run_id = _make_run_id()
+ async with mem_db() as session:
+ session.add(_make_scan_run(run_id))
+ await session.commit()
+
+ nmap_hosts = [{
+ "ip": "192.168.1.50", "hostname": None, "mac": None, "os": None,
+ "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
+ }]
+ jellyfin_sig = [{
+ "port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
+ "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
+ }]
+
+ async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
+ return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
+ patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
+ patch("app.services.scanner.probe_open_ports", new=fake_probe), \
+ patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
+ await run_scan(
+ ["192.168.1.0/24"], session, run_id,
+ deep_scan=DeepScanOptions(http_probe_enabled=True),
+ )
+
+ async with mem_db() as session:
+ result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
+ device = result.scalar_one_or_none()
+
+ assert device is not None
+ assert any(s["service_name"] == "Jellyfin" for s in device.services)
+
+
+@pytest.mark.asyncio
+async def test_run_scan_no_probe_when_disabled(mem_db):
+ """Probe must not be called on a standard (non-deep) scan."""
+ from app.services.scanner import run_scan
+
+ run_id = _make_run_id()
+ async with mem_db() as session:
+ session.add(_make_scan_run(run_id))
+ await session.commit()
+
+ nmap_hosts = [{
+ "ip": "192.168.1.51", "hostname": None, "mac": None, "os": None,
+ "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
+ }]
+ probe = AsyncMock()
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
+ patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
+ patch("app.services.scanner.probe_open_ports", new=probe), \
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
+ await run_scan(["192.168.1.0/24"], session, run_id)
+
+ probe.assert_not_called()
diff --git a/backend/tests/test_signatures_data.py b/backend/tests/test_signatures_data.py
new file mode 100644
index 0000000..4d34221
--- /dev/null
+++ b/backend/tests/test_signatures_data.py
@@ -0,0 +1,63 @@
+"""Integrity + matching tests against the real service_signatures.json."""
+import re
+
+import pytest
+
+from app.services.fingerprint import _load, match_service
+
+_NODE_TYPES = {
+ "isp", "router", "switch", "server", "proxmox", "vm", "lxc",
+ "nas", "iot", "ap", "camera", "generic",
+}
+
+
+@pytest.fixture
+def signatures():
+ return _load()
+
+
+def test_all_entries_well_formed(signatures):
+ for sig in signatures:
+ # port is an int or explicitly null (port-agnostic)
+ assert sig.get("port") is None or isinstance(sig["port"], int)
+ assert isinstance(sig["service_name"], str) and sig["service_name"]
+ assert sig["suggested_node_type"] in _NODE_TYPES
+ if sig.get("banner_regex"):
+ re.compile(sig["banner_regex"])
+ if sig.get("http_regex"):
+ re.compile(sig["http_regex"])
+
+
+def test_port_agnostic_entries_require_http_regex(signatures):
+ for sig in signatures:
+ if sig.get("port") is None:
+ assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
+
+
+def test_popular_apps_have_port_agnostic_signatures(signatures):
+ names = {s["service_name"] for s in signatures if s.get("port") is None}
+ for expected in {
+ "Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
+ "AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
+ }:
+ assert expected in names, f"missing port-agnostic signature for {expected}"
+
+
+@pytest.mark.parametrize(("title", "expected"), [
+ ("Jellyfin", "Jellyfin"),
+ ("Home Assistant", "Home Assistant"),
+ ("Portainer", "Portainer"),
+ ("Vaultwarden Web Vault", "Vaultwarden"),
+ ("Pi-hole - Dashboard", "Pi-hole"),
+ ("Audiobookshelf", "Audiobookshelf"),
+])
+def test_custom_port_identified_via_http_title(title, expected):
+ # A service on a non-standard port, recognised purely by its HTML title.
+ sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
+ assert sig is not None
+ assert sig["service_name"] == expected
+
+
+def test_custom_port_without_probe_is_unknown():
+ # Same custom port, deep scan off → no signal → no port-agnostic match.
+ assert match_service(58000, "tcp", banner=None, http_signals=None) is None
diff --git a/backend/tests/test_zwave_router.py b/backend/tests/test_zwave_router.py
new file mode 100644
index 0000000..6974738
--- /dev/null
+++ b/backend/tests/test_zwave_router.py
@@ -0,0 +1,447 @@
+"""API endpoint tests for /api/v1/zwave/*."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@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/zwave/test-connection
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
+ with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
+ mock_conn.return_value = True
+ res = await client.post(
+ "/api/v1/zwave/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.zwave.test_zwave_connection") as mock_conn:
+ mock_conn.side_effect = ConnectionError("Connection refused")
+ res = await client.post(
+ "/api/v1/zwave/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/zwave/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/zwave/test-connection",
+ json={"mqtt_host": "localhost", "mqtt_port": 99999},
+ headers=headers,
+ )
+ assert res.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# /api/v1/zwave/import
+# ---------------------------------------------------------------------------
+
+_SAMPLE_NODES = [
+ {
+ "id": "zwave-0xh-1",
+ "label": "Controller",
+ "type": "zwave_coordinator",
+ "ieee_address": "zwave-0xh-1",
+ "friendly_name": "Controller",
+ "device_type": "Controller",
+ "model": None,
+ "vendor": None,
+ "lqi": None,
+ "parent_id": None,
+ },
+ {
+ "id": "zwave-0xh-2",
+ "label": "Wall Plug",
+ "type": "zwave_router",
+ "ieee_address": "zwave-0xh-2",
+ "friendly_name": "Wall Plug",
+ "device_type": "Router",
+ "model": "ZW100",
+ "vendor": "Aeotec",
+ "lqi": None,
+ "parent_id": "zwave-0xh-1",
+ },
+]
+
+_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
+
+
+@pytest.mark.asyncio
+async def test_import_success(client: AsyncClient, headers: dict) -> None:
+ with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
+ res = await client.post(
+ "/api/v1/zwave/import",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ data = res.json()
+ assert data["device_count"] == 2
+ assert len(data["edges"]) == 1
+ coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
+ assert coordinator["ieee_address"] == "zwave-0xh-1"
+
+
+@pytest.mark.asyncio
+async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
+ with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.return_value = ([], [])
+ res = await client.post(
+ "/api/v1/zwave/import",
+ json={
+ "mqtt_host": "localhost",
+ "mqtt_port": 1883,
+ "prefix": "myzwave",
+ "gateway_name": "gw1",
+ "mqtt_username": "admin",
+ "mqtt_password": "secret",
+ },
+ headers=headers,
+ )
+ assert res.status_code == 200
+ mock_fetch.assert_called_once_with(
+ mqtt_host="localhost",
+ mqtt_port=1883,
+ prefix="myzwave",
+ gateway_name="gw1",
+ 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.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.side_effect = ConnectionError("broker unreachable")
+ res = await client.post(
+ "/api/v1/zwave/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.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.side_effect = TimeoutError("timed out")
+ res = await client.post(
+ "/api/v1/zwave/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.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.side_effect = ValueError("malformed response")
+ res = await client.post(
+ "/api/v1/zwave/import",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ headers=headers,
+ )
+ assert res.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
+ with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
+ mock_fetch.side_effect = RuntimeError("boom")
+ res = await client.post(
+ "/api/v1/zwave/import",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ headers=headers,
+ )
+ assert res.status_code == 500
+
+
+@pytest.mark.asyncio
+async def test_import_requires_auth(client: AsyncClient) -> None:
+ res = await client.post(
+ "/api/v1/zwave/import",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ )
+ assert res.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
+ res = await client.post(
+ "/api/v1/zwave/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/zwave/import-pending
+# ---------------------------------------------------------------------------
+
+_PENDING_NODES = [
+ {
+ "id": "zwave-0xh-1",
+ "label": "Controller",
+ "type": "zwave_coordinator",
+ "ieee_address": "zwave-0xh-1",
+ "friendly_name": "Controller",
+ "device_type": "Controller",
+ "model": None,
+ "vendor": None,
+ "lqi": None,
+ "parent_id": None,
+ },
+ {
+ "id": "zwave-0xh-2",
+ "label": "Wall Plug",
+ "type": "zwave_router",
+ "ieee_address": "zwave-0xh-2",
+ "friendly_name": "Wall Plug",
+ "device_type": "Router",
+ "model": "ZW100",
+ "vendor": "Aeotec",
+ "lqi": None,
+ "parent_id": "zwave-0xh-1",
+ },
+ {
+ "id": "zwave-0xh-3",
+ "label": "Door Sensor",
+ "type": "zwave_enddevice",
+ "ieee_address": "zwave-0xh-3",
+ "friendly_name": "Door Sensor",
+ "device_type": "EndDevice",
+ "model": "ZW120",
+ "vendor": "Aeotec",
+ "lqi": None,
+ "parent_id": "zwave-0xh-2",
+ },
+]
+
+_PENDING_EDGES = [
+ {"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
+ {"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
+]
+
+
+@pytest.mark.asyncio
+async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
+ from unittest.mock import AsyncMock
+
+ with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
+ res = await client.post(
+ "/api/v1/zwave/import-pending",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ run = res.json()
+ assert run["kind"] == "zwave"
+ assert run["status"] == "running"
+ assert run["ranges"] == ["localhost:1883"]
+
+
+@pytest.mark.asyncio
+async def test_import_pending_requires_auth(client: AsyncClient) -> None:
+ res = await client.post(
+ "/api/v1/zwave/import-pending",
+ json={"mqtt_host": "localhost", "mqtt_port": 1883},
+ )
+ assert res.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_persist_creates_coordinator_and_pending(db_session) -> None:
+ from app.api.routes.zwave 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 == "zwave-0xh-1"
+ assert result.coordinator_already_existed is False
+ assert result.links_recorded == 2
+
+
+@pytest.mark.asyncio
+async def test_persist_idempotent_updates_existing(db_session) -> None:
+ from app.api.routes.zwave import _persist_pending_import
+
+ await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+ bumped = [dict(n) for n in _PENDING_NODES]
+ bumped[1]["model"] = "ZW111"
+ 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
+
+
+@pytest.mark.asyncio
+async def test_persist_replaces_links(db_session) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zwave import _persist_pending_import
+ from app.db.models import PendingDeviceLink
+
+ await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+ new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
+ 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) == ("zwave-0xh-1", "zwave-0xh-2")
+
+
+@pytest.mark.asyncio
+async def test_persist_sets_coordinator_properties(db_session) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zwave import _persist_pending_import
+ from app.db.models import Node
+
+ nodes = [dict(n) for n in _PENDING_NODES]
+ nodes[0]["vendor"] = "Aeotec"
+ nodes[0]["model"] = "ZW090"
+ await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
+ coord = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in coord.properties}
+ assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
+ assert all(p["visible"] is False for p in coord.properties)
+
+
+@pytest.mark.asyncio
+async def test_persist_skips_pending_for_approved_node(db_session) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zwave import _persist_pending_import
+ from app.db.models import Node, PendingDevice
+
+ approved = Node(
+ label="Wall Plug",
+ type="zwave_router",
+ status="online",
+ check_method="none",
+ ieee_address="zwave-0xh-2",
+ services=[],
+ properties=[],
+ )
+ db_session.add(approved)
+ await db_session.commit()
+
+ await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+
+ pendings = (
+ await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
+ )
+ ).scalars().all()
+ assert pendings == []
+ refreshed = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in refreshed.properties}
+ assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
+
+
+@pytest.mark.asyncio
+async def test_persist_revives_orphaned_approved_device(db_session) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zwave import _persist_pending_import
+ from app.db.models import PendingDevice
+
+ orphan = PendingDevice(
+ ieee_address="zwave-0xh-2",
+ friendly_name="Wall Plug",
+ suggested_type="zwave_router",
+ device_subtype="Router",
+ status="approved",
+ discovery_source="zwave",
+ )
+ db_session.add(orphan)
+ await db_session.commit()
+
+ result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+ revived = (
+ await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
+ )
+ ).scalar_one()
+ assert revived.status == "pending"
+ assert result.pending_created == 1
+ assert result.pending_updated == 1
+
+
+@pytest.mark.asyncio
+async def test_persist_keeps_hidden_hidden(db_session) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zwave import _persist_pending_import
+ from app.db.models import PendingDevice
+
+ hidden = PendingDevice(
+ ieee_address="zwave-0xh-2",
+ friendly_name="Wall Plug",
+ suggested_type="zwave_router",
+ device_subtype="Router",
+ status="hidden",
+ discovery_source="zwave",
+ )
+ db_session.add(hidden)
+ await db_session.commit()
+
+ await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+ still_hidden = (
+ await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
+ )
+ ).scalar_one()
+ assert still_hidden.status == "hidden"
diff --git a/backend/tests/test_zwave_service.py b/backend/tests/test_zwave_service.py
new file mode 100644
index 0000000..4974e1d
--- /dev/null
+++ b/backend/tests/test_zwave_service.py
@@ -0,0 +1,269 @@
+"""Unit tests for zwave_service: parser, role mapping, hierarchy builder."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from app.services.zwave_service import (
+ build_zwave_properties,
+ fetch_zwave_network,
+ parse_zwave_nodes,
+)
+
+# ---------------------------------------------------------------------------
+# Helpers — real zwavejs2mqtt getNodes shape
+# ---------------------------------------------------------------------------
+
+def _node(
+ node_id: int,
+ *,
+ controller: bool = False,
+ routing: bool = False,
+ name: str | None = None,
+ neighbors: list[int] | None = None,
+ manufacturer: str | None = None,
+ product_label: str | None = None,
+ home_id: str = "0xabcd1234",
+) -> dict[str, Any]:
+ return {
+ "id": node_id,
+ "homeId": home_id,
+ "isControllerNode": controller,
+ "isRouting": routing,
+ "name": name,
+ "neighbors": neighbors or [],
+ "manufacturer": manufacturer,
+ "productLabel": product_label,
+ }
+
+
+def _wrap(nodes: list[dict[str, Any]], success: bool = True) -> dict[str, Any]:
+ return {"success": success, "result": nodes}
+
+
+HOME = "0xabcd1234"
+
+
+def _ieee(node_id: int) -> str:
+ return f"zwave-{HOME}-{node_id}"
+
+
+# ---------------------------------------------------------------------------
+# Role mapping
+# ---------------------------------------------------------------------------
+
+class TestRoleMapping:
+ def test_controller_is_coordinator(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True)]))
+ assert nodes[0]["type"] == "zwave_coordinator"
+ assert nodes[0]["device_type"] == "Controller"
+
+ def test_routing_is_router(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(2, routing=True)]))
+ assert nodes[0]["type"] == "zwave_router"
+ assert nodes[0]["device_type"] == "Router"
+
+ def test_default_is_enddevice(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(3)]))
+ assert nodes[0]["type"] == "zwave_enddevice"
+ assert nodes[0]["device_type"] == "EndDevice"
+
+ def test_controller_wins_over_routing(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True, routing=True)]))
+ assert nodes[0]["type"] == "zwave_coordinator"
+
+
+# ---------------------------------------------------------------------------
+# parse_zwave_nodes
+# ---------------------------------------------------------------------------
+
+class TestParse:
+ def test_empty_payload(self) -> None:
+ nodes, edges = parse_zwave_nodes({})
+ assert nodes == []
+ assert edges == []
+
+ def test_empty_result(self) -> None:
+ nodes, edges = parse_zwave_nodes(_wrap([]))
+ assert nodes == []
+ assert edges == []
+
+ def test_success_false_raises(self) -> None:
+ with pytest.raises(ValueError, match="failure"):
+ parse_zwave_nodes(_wrap([], success=False))
+
+ def test_result_not_list_raises(self) -> None:
+ with pytest.raises(ValueError, match="not a list"):
+ parse_zwave_nodes({"success": True, "result": "oops"})
+
+ def test_missing_id_skipped(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([{"homeId": HOME, "isControllerNode": False}]))
+ assert nodes == []
+
+ def test_ieee_identity_format(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(5, controller=True)]))
+ assert nodes[0]["ieee_address"] == _ieee(5)
+
+ def test_name_fallback(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(7, name="Living Room")]))
+ assert nodes[0]["label"] == "Living Room"
+ assert nodes[0]["friendly_name"] == "Living Room"
+
+ def test_model_and_vendor(self) -> None:
+ nodes, _ = parse_zwave_nodes(
+ _wrap([_node(8, manufacturer="Aeotec", product_label="ZW100")])
+ )
+ assert nodes[0]["vendor"] == "Aeotec"
+ assert nodes[0]["model"] == "ZW100"
+
+ def test_lqi_is_none(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(9)]))
+ assert nodes[0]["lqi"] is None
+
+ def test_no_duplicate_nodes(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(1, routing=True), _node(1, routing=True)]))
+ assert len(nodes) == 1
+
+ def test_helper_keys_stripped(self) -> None:
+ nodes, _ = parse_zwave_nodes(_wrap([_node(1, neighbors=[2])]))
+ assert "neighbors" not in nodes[0]
+ assert "node_id" not in nodes[0]
+
+
+class TestHierarchy:
+ def test_coordinator_router_enddevice_tree(self) -> None:
+ payload = _wrap([
+ _node(1, controller=True, neighbors=[2]),
+ _node(2, routing=True, neighbors=[1, 3]),
+ _node(3, neighbors=[2]),
+ ])
+ nodes, edges = parse_zwave_nodes(payload)
+ by_id = {n["id"]: n for n in nodes}
+ assert by_id[_ieee(2)]["parent_id"] == _ieee(1)
+ assert by_id[_ieee(3)]["parent_id"] == _ieee(2)
+ pairs = {(e["source"], e["target"]) for e in edges}
+ assert pairs == {(_ieee(1), _ieee(2)), (_ieee(2), _ieee(3))}
+
+ def test_enddevice_without_router_falls_back_to_coordinator(self) -> None:
+ payload = _wrap([_node(1, controller=True), _node(3, neighbors=[])])
+ nodes, _ = parse_zwave_nodes(payload)
+ end = next(n for n in nodes if n["id"] == _ieee(3))
+ assert end["parent_id"] == _ieee(1)
+
+ def test_coordinator_has_no_incoming_edge(self) -> None:
+ payload = _wrap([
+ _node(1, controller=True, neighbors=[3]),
+ _node(3, neighbors=[1]),
+ ])
+ _, edges = parse_zwave_nodes(payload)
+ assert all(e["target"] != _ieee(1) for e in edges)
+
+ def test_neighbor_to_unknown_node_dropped(self) -> None:
+ payload = _wrap([_node(1, controller=True, neighbors=[99])])
+ _, edges = parse_zwave_nodes(payload)
+ assert edges == []
+
+ def test_no_coordinator_means_no_edges(self) -> None:
+ payload = _wrap([_node(2, routing=True, neighbors=[3]), _node(3, neighbors=[2])])
+ _, edges = parse_zwave_nodes(payload)
+ assert edges == []
+
+
+# ---------------------------------------------------------------------------
+# build_zwave_properties
+# ---------------------------------------------------------------------------
+
+class TestBuildProperties:
+ def test_all_fields(self) -> None:
+ props = build_zwave_properties("zwave-x-1", "Aeotec", "ZW100")
+ keys = {p["key"]: p["value"] for p in props}
+ assert keys == {"Z-Wave ID": "zwave-x-1", "Vendor": "Aeotec", "Model": "ZW100"}
+
+ def test_omits_empty(self) -> None:
+ props = build_zwave_properties("zwave-x-1", None, None)
+ assert [p["key"] for p in props] == ["Z-Wave ID"]
+
+ def test_defaults_hidden(self) -> None:
+ props = build_zwave_properties("zwave-x-1", "V", "M")
+ assert all(p["visible"] is False for p in props)
+
+ def test_no_lqi_row(self) -> None:
+ props = build_zwave_properties("zwave-x-1", "V", "M")
+ assert all(p["key"] != "LQI" for p in props)
+
+
+# ---------------------------------------------------------------------------
+# fetch_zwave_network (mocked MQTT round-trip via mqtt_common)
+# ---------------------------------------------------------------------------
+
+_RESPONSE_TOPIC = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
+
+_SAMPLE_PAYLOAD = {
+ "success": True,
+ "result": [
+ {"id": 1, "homeId": HOME, "isControllerNode": True, "name": "Controller"},
+ {"id": 2, "homeId": HOME, "isRouting": True, "name": "Wall Plug", "neighbors": [1]},
+ ],
+}
+
+
+@pytest.mark.asyncio
+async def test_fetch_zwave_network_success() -> None:
+ class _FakeMessage:
+ topic = _RESPONSE_TOPIC
+ payload = json.dumps(_SAMPLE_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, _t: str) -> None:
+ pass
+
+ async def publish(self, _t: str, _p: str) -> None:
+ pass
+
+ @property
+ def messages(self):
+ return _FakeMessage()
+
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _FakeClient()
+ mock_aiomqtt.MqttError = Exception
+ nodes, edges = await fetch_zwave_network(mqtt_host="localhost", mqtt_port=1883)
+
+ assert any(n["type"] == "zwave_coordinator" for n in nodes)
+ assert any(n["type"] == "zwave_router" for n in nodes)
+
+
+@pytest.mark.asyncio
+async def test_fetch_zwave_network_connection_error() -> None:
+ class _FakeClient:
+ async def __aenter__(self):
+ raise Exception("Connection refused")
+
+ async def __aexit__(self, *_):
+ pass
+
+ with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
+ mock_aiomqtt.Client.return_value = _FakeClient()
+ mock_aiomqtt.MqttError = Exception
+ with pytest.raises(ConnectionError):
+ await fetch_zwave_network(mqtt_host="bad", mqtt_port=1883)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7a7b97d..20a51d2 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -118,12 +118,12 @@
"license": "MIT"
},
"node_modules/@babel/code-frame": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -132,29 +132,29 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
- "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
- "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -171,13 +171,13 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
- "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -199,13 +199,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
- "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.6",
- "@babel/helper-validator-option": "^7.27.1",
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -236,9 +236,9 @@
}
},
"node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -258,27 +258,27 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -339,52 +339,52 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
- "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0"
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -519,31 +519,31 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
- "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -551,13 +551,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -6638,9 +6638,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -9138,9 +9148,9 @@
}
},
"node_modules/undici": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
- "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1ba82eb..88329cf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
+import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
@@ -39,6 +40,7 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
+import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
@@ -77,6 +79,7 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
+ const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies
@@ -560,6 +563,50 @@ export default function App() {
markUnsaved()
}, [addNode, onConnect, snapshotHistory, markUnsaved])
+ const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
+ snapshotHistory()
+ const COLS = 4
+ const SPACING_X = 170
+ const SPACING_Y = 100
+ zwaveNodes.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 = {
+ id,
+ type: zn.type,
+ position,
+ data: {
+ label: zn.friendly_name,
+ type: zn.type as NodeData['type'],
+ status: 'unknown' as const,
+ services: [],
+ ...(zn.model ? { os: zn.model } : {}),
+ ...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
+ },
+ }
+ addNode(newNode)
+ })
+ // Add IoT edges between Z-Wave devices: parent bottom -> child top
+ zwaveEdges.forEach((ze) => {
+ onConnect({
+ source: ze.source,
+ sourceHandle: 'bottom',
+ target: ze.target,
+ targetHandle: 'top-t',
+ type: 'iot',
+ } as unknown as import('@xyflow/react').Connection)
+ })
+ const importedIds = new Set(zwaveNodes.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) => {
setPendingConnection(connection)
}, [])
@@ -634,6 +681,7 @@ export default function App() {
onAddText={() => setAddTextOpen(true)}
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
+ onZwaveImport={() => setZwaveImportOpen(true)}
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
@@ -752,6 +800,17 @@ export default function App() {
/>
)}
+ {!STANDALONE && (
+ setZwaveImportOpen(false)}
+ onAddToCanvas={handleZwaveAddToCanvas}
+ onPendingImported={() => {
+ toast.success('Z-Wave import started — check Scan History for results')
+ }}
+ />
+ )}
+
{!STANDALONE && (
{
it('scanApi endpoints route correctly', () => {
mod.scanApi.trigger()
- expect(api.post).toHaveBeenCalledWith('/scan/trigger')
+ expect(api.post).toHaveBeenCalledWith('/scan/trigger', {})
mod.scanApi.pending()
expect(api.get).toHaveBeenCalledWith('/scan/pending')
mod.scanApi.hidden()
@@ -186,7 +186,9 @@ describe('api/client', () => {
mod.scanApi.ignore('d1')
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
mod.scanApi.bulkApprove(['a', 'b'])
- expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined })
+ mod.scanApi.bulkApprove(['a'], 'design-9')
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' })
mod.scanApi.bulkHide(['a'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
mod.scanApi.restore('d1')
@@ -217,4 +219,14 @@ describe('api/client', () => {
mod.zigbeeApi.importToPending(cfg)
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
})
+
+ it('zwaveApi.testConnection/importNetwork/importToPending', () => {
+ const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
+ mod.zwaveApi.testConnection(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
+ mod.zwaveApi.importNetwork(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
+ mod.zwaveApi.importToPending(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
+ })
})
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 96a8ebe..a21755e 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -58,8 +58,16 @@ export const liveviewApi = {
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
}
+export interface DeepScanConfig {
+ http_ranges: string[]
+ http_probe_enabled: boolean
+ verify_tls: boolean
+}
+
+export type ScanConfigData = { ranges: string[] } & DeepScanConfig
+
export const scanApi = {
- trigger: () => api.post('/scan/trigger'),
+ trigger: (deepScan?: Partial) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
@@ -73,7 +81,7 @@ export const scanApi = {
}>(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
- bulkApprove: (ids: string[]) =>
+ bulkApprove: (ids: string[], designId?: string | null) =>
api.post<{
approved: number
node_ids: string[]
@@ -81,13 +89,13 @@ export const scanApi = {
edges_created: number
edges: { id: string; source: string; target: string }[]
skipped: number
- }>('/scan/pending/bulk-approve', { device_ids: ids }),
+ }>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
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`),
- getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
- saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
+ getConfig: () => api.get('/scan/config'),
+ saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
}
export interface AppSettings {
@@ -156,3 +164,52 @@ export const zigbeeApi = {
error: string | null
}>('/zigbee/import-pending', data),
}
+
+export const zwaveApi = {
+ 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 }>('/zwave/test-connection', data),
+
+ importNetwork: (data: {
+ mqtt_host: string
+ mqtt_port: number
+ mqtt_username?: string
+ mqtt_password?: string
+ prefix?: string
+ gateway_name?: string
+ mqtt_tls?: boolean
+ mqtt_tls_insecure?: boolean
+ }) =>
+ api.post<{
+ nodes: import('@/components/zwave/types').ZwaveNode[]
+ edges: import('@/components/zwave/types').ZwaveEdge[]
+ device_count: number
+ }>('/zwave/import', data),
+
+ importToPending: (data: {
+ mqtt_host: string
+ mqtt_port: number
+ mqtt_username?: string
+ mqtt_password?: string
+ prefix?: string
+ gateway_name?: 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
+ }>('/zwave/import-pending', data),
+}
diff --git a/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts
new file mode 100644
index 0000000..1e0b237
--- /dev/null
+++ b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts
@@ -0,0 +1,15 @@
+import { describe, it, expect } from 'vitest'
+import { nodeTypes } from '../nodeTypes'
+
+describe('nodeTypes registry', () => {
+ it('registers a component for every wireless mesh node type', () => {
+ // Regression: zwave_* types were missing, so React Flow fell back to the
+ // default (unstyled) node — no icon, no accent. (Zigbee covered too.)
+ for (const t of [
+ 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
+ 'zwave_coordinator', 'zwave_router', 'zwave_enddevice',
+ ]) {
+ expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
+ }
+ })
+})
diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx
index 1af4afd..d7b758a 100644
--- a/frontend/src/components/canvas/nodes/index.tsx
+++ b/frontend/src/components/canvas/nodes/index.tsx
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
- HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
+ HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
@@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) =>
export const ZigbeeEndDeviceNode = (props: N) =>
+// Z-Wave node types
+export const ZwaveCoordinatorNode = (props: N) =>
+export const ZwaveRouterNode = (props: N) =>
+export const ZwaveEndDeviceNode = (props: N) =>
+
// Electrical node types
export const GridNode = (props: N) =>
export const UpsNode = (props: N) =>
diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts
index e278307..98f8b8e 100644
--- a/frontend/src/components/canvas/nodes/nodeTypes.ts
+++ b/frontend/src/components/canvas/nodes/nodeTypes.ts
@@ -3,6 +3,7 @@ import {
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
+ ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
LightNode, MeterNode, TransformerNode, LoadNode,
@@ -39,6 +40,9 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode,
+ zwave_coordinator: ZwaveCoordinatorNode,
+ zwave_router: ZwaveRouterNode,
+ zwave_enddevice: ZwaveEndDeviceNode,
grid: GridNode,
ups: UpsNode,
battery: BatteryNode,
diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx
index b1f6918..c4d962f 100644
--- a/frontend/src/components/modals/CustomStyleModal.tsx
+++ b/frontend/src/components/modals/CustomStyleModal.tsx
@@ -1,9 +1,9 @@
-import { useState, useCallback } from 'react'
+import { Fragment, useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
- Radio, Zap, Lightbulb,
+ Radio, Zap, Lightbulb, RadioTower, Share2,
type LucideIcon,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -17,13 +17,16 @@ import type {
} from '@/types'
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
-// ── Node types exposed for custom style (skip groupRect/group) ───────────────
+// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
-const EDITABLE_NODE_TYPES: NodeType[] = [
- 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
- 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
- 'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
- 'generic',
+const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
+ { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
+ { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
+ { label: 'IoT', types: ['iot', 'camera', 'cpl'] },
+ { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
+ { label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
+ { label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
+ { label: 'Generic', types: ['generic'] },
]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
@@ -34,6 +37,7 @@ const NODE_ICONS: Record = {
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
docker_host: Anchor, docker_container: Package,
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
+ zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb,
generic: Circle,
}
@@ -156,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.width}
- onChange={(e) => set('width', parseInt(e.target.value) || 0)}
+ onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
@@ -167,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.height}
- onChange={(e) => set('height', parseInt(e.target.value) || 0)}
+ onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
@@ -281,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
edges: { ...customStyle.edges },
}))
- const handleOpen = (isOpen: boolean) => {
- if (isOpen) {
- // Reset draft to current saved customStyle on open
+ // Reset the draft to the saved customStyle whenever the modal is (re)opened.
+ // The parent keeps this component mounted and only toggles `open`, so Radix's
+ // onOpenChange never fires for a parent-driven open — we key off the prop edge
+ // instead. Without this, abandoned edits (Cancel) would leak into the next open.
+ useEffect(() => {
+ if (open) {
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
setSelection(null)
- } else {
- onClose()
}
+ // Intentional snapshot-on-open: we don't want live customStyle changes to
+ // clobber an in-progress edit, only a fresh open should reset.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+ const handleOpen = (isOpen: boolean) => {
+ if (!isOpen) onClose()
}
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
@@ -363,34 +375,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
{/* Type list */}
- {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
- const Icon = NODE_ICONS[t] ?? Circle
- const style = draft.nodes[t]
- const isSelected = selection?.kind === 'node' && selection.type === t
- const swatchColor = style
- ? applyOpacity(style.borderColor, style.borderOpacity)
- : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
+ {tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
+
+
+ {group.label}
+
+ {group.types.map((t) => {
+ const Icon = NODE_ICONS[t] ?? Circle
+ const style = draft.nodes[t]
+ const isSelected = selection?.kind === 'node' && selection.type === t
+ const swatchColor = style
+ ? applyOpacity(style.borderColor, style.borderOpacity)
+ : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
- return (
-
- )
- })}
+ return (
+
+ )
+ })}
+
+ ))}
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
const style = draft.edges[t]
diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx
index aa76069..542363b 100644
--- a/frontend/src/components/modals/PendingDeviceModal.tsx
+++ b/frontend/src/components/modals/PendingDeviceModal.tsx
@@ -27,6 +27,8 @@ export interface PendingDevice {
vendor?: string | null
lqi?: number | null
discovered_at: string
+ // How many canvases (designs) this device already appears on. Computed server-side.
+ canvas_count?: number
}
interface PendingDeviceModalProps {
diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx
index de35096..b458fea 100644
--- a/frontend/src/components/modals/PendingDevicesModal.tsx
+++ b/frontend/src/components/modals/PendingDevicesModal.tsx
@@ -1,15 +1,17 @@
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,
+ Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
+import { useDesignStore } from '@/stores/designStore'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
+import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
interface PendingDevicesModalProps {
@@ -67,11 +69,13 @@ const TYPE_ICONS: Record
= {
generic: Circle,
}
-type SourceFilter = 'all' | 'ip' | 'zigbee'
+type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
type StatusFilter = 'pending' | 'hidden'
-function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
- if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
+function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
+ if (d.discovery_source === 'zwave') return 'zwave'
+ if (d.discovery_source === 'zigbee') return 'zigbee'
+ if (d.ieee_address) return 'zigbee'
return 'ip'
}
@@ -119,7 +123,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [sourceFilter, setSourceFilter] = useState('all')
const [typeFilter, setTypeFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState(initialStatus)
+ // Inventory shows on-canvas devices by default; toggle off to hide them.
+ const [showOnCanvas, setShowOnCanvas] = useState(true)
+ // Optionally restrict to devices that have at least one detected service.
+ const [withServicesOnly, setWithServicesOnly] = useState(false)
const { addNode, scanEventTs } = useCanvasStore()
+ const activeDesignId = useDesignStore((s) => s.activeDesignId)
const highlightRef = useRef(null)
const load = useCallback(async () => {
@@ -159,6 +168,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
+ // Inventory-only: optionally hide devices already placed on a canvas.
+ if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
+ if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -168,7 +180,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
- }, [devices, search, sourceFilter, typeFilter])
+ }, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -241,9 +253,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else {
+ // Clears only pending rows server-side; approved/on-canvas devices stay,
+ // so reload rather than blanking the whole inventory.
await scanApi.clearPending()
- setDevices([])
setSelectedIds(new Set())
+ await load()
toast.success('Pending devices cleared')
}
} catch {
@@ -255,17 +269,24 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
try {
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
- const zigbee = isZigbeeType(type)
- const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
+ const zwave = isZwaveType(type)
+ const wireless = isZigbeeType(type) || zwave
+ const properties = zwave
+ ? buildZwaveProperties(device)
+ : isZigbeeType(type)
+ ? buildZigbeeProperties(device)
+ : buildMacProperty(device.mac)
const nodeData = {
label: fallbackLabel,
type,
ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined,
- status: zigbee ? 'online' : 'unknown',
+ status: wireless ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
properties,
+ // Approve onto the design the user is viewing, not the first design.
+ design_id: activeDesignId ?? undefined,
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
@@ -273,7 +294,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
- data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
+ data: { ...nodeData, status: wireless ? ('online' as const) : ('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' : ''})` : ''
@@ -310,7 +331,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const ids = [...selectedIds]
if (ids.length === 0) return
try {
- const res = await scanApi.bulkApprove(ids)
+ const res = await scanApi.bulkApprove(ids, activeDesignId)
const deviceToNode: Record = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
@@ -318,7 +339,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const type = (d.suggested_type ?? 'generic') as NodeType
- const zigbee = isZigbeeType(type)
+ const zwave = isZwaveType(type)
+ const wireless = isZigbeeType(type) || zwave
addNode({
id: nodeId,
type,
@@ -329,9 +351,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined,
- status: zigbee ? ('online' as const) : ('unknown' as const),
+ status: wireless ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
- properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
+ properties: zwave
+ ? buildZwaveProperties(d)
+ : isZigbeeType(type)
+ ? buildZigbeeProperties(d)
+ : buildMacProperty(d.mac),
},
})
})
@@ -398,7 +424,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
- {statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
+ {statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
@@ -464,6 +490,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Zigbee
+
+ {statusFilter === 'pending' && (
+
+ )}
+