Merge branch 'main' into fix/markdown-copy
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+172
-26
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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"}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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 <title> 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 <title> — avoids large downloads.
|
||||
_MAX_BODY_BYTES = 64 * 1024
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", 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))
|
||||
@@ -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
|
||||
+129
-43
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<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": [ {<node>}, ... ]}
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<html><title>Jellyfin</title></html>") == "Jellyfin"
|
||||
|
||||
|
||||
def test_extract_title_collapses_whitespace():
|
||||
assert _extract_title("<title>\n My App\n</title>") == "My App"
|
||||
|
||||
|
||||
def test_extract_title_missing():
|
||||
assert _extract_title("<html><body>no title</body></html>") is None
|
||||
|
||||
|
||||
def test_extract_title_case_insensitive():
|
||||
assert _extract_title("<TITLE>Portainer</TITLE>") == "Portainer"
|
||||
|
||||
|
||||
# ── probe_port ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_title():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
||||
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("<title>HTTP App</title>")
|
||||
|
||||
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("<title>X</title>"))
|
||||
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("<title>Jellyfin</title>")
|
||||
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
|
||||
@@ -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)
|
||||
+321
-13
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Generated
+97
-87
@@ -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": {
|
||||
|
||||
@@ -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<NodeData> = {
|
||||
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 && (
|
||||
<ZwaveImportModal
|
||||
open={zwaveImportOpen}
|
||||
onClose={() => setZwaveImportOpen(false)}
|
||||
onAddToCanvas={handleZwaveAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
toast.success('Z-Wave import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('api/client', () => {
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<DeepScanConfig>) => 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<ScanConfigData>('/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),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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) => <BaseNode {...props} icon={Ne
|
||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||
|
||||
// Z-Wave node types
|
||||
export const ZwaveCoordinatorNode = (props: N) => <BaseNode {...props} icon={RadioTower} />
|
||||
export const ZwaveRouterNode = (props: N) => <BaseNode {...props} icon={Share2} />
|
||||
export const ZwaveEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||
|
||||
// Electrical node types
|
||||
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
||||
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
@@ -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]"
|
||||
/>
|
||||
</div>
|
||||
@@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{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) => (
|
||||
<Fragment key={group.label}>
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#8b949e]/60">
|
||||
{group.label}
|
||||
</div>
|
||||
{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 (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||
style={{
|
||||
background: isSelected ? '#21262d' : 'transparent',
|
||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: swatchColor }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||
style={{
|
||||
background: isSelected ? '#21262d' : 'transparent',
|
||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: swatchColor }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||
const style = draft.edges[t]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, React.ElementType> = {
|
||||
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<SourceFilter>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(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<HTMLButtonElement>(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<string, string> = {}
|
||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||
@@ -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
|
||||
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||
</span>
|
||||
@@ -464,6 +490,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
Zigbee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('zwave')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zwave' ? 'bg-[#ff6e00]/20 text-[#ff6e00]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Z-Wave
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -479,7 +511,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Pending
|
||||
Inventory
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('hidden')}
|
||||
@@ -488,6 +520,26 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
Hidden
|
||||
</button>
|
||||
</div>
|
||||
{statusFilter === 'pending' && (
|
||||
<button
|
||||
onClick={() => setShowOnCanvas((v) => !v)}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${showOnCanvas ? 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground' : 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50'}`}
|
||||
title="Show or hide devices already on a canvas"
|
||||
aria-pressed={!showOnCanvas}
|
||||
>
|
||||
<Layers size={12} />
|
||||
{showOnCanvas ? 'Hide on-canvas' : 'Show on-canvas'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setWithServicesOnly((v) => !v)}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${withServicesOnly ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||
title="Only show devices with at least one detected service"
|
||||
aria-pressed={withServicesOnly}
|
||||
>
|
||||
<ServerCog size={12} />
|
||||
With services
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||
@@ -602,8 +654,11 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
const source = inferSource(device)
|
||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const label = deviceLabel(device)
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||
const sourceLabel =
|
||||
source === 'zigbee' ? 'ZIGBEE'
|
||||
: source === 'zwave' ? 'Z-WAVE'
|
||||
: (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const services = device.services ?? []
|
||||
const visibleServices = services.slice(0, 4)
|
||||
const moreServices = services.length - visibleServices.length
|
||||
@@ -624,12 +679,24 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
{selectMode && selected && (
|
||||
<CheckCircle2
|
||||
size={18}
|
||||
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117] z-10"
|
||||
/>
|
||||
)}
|
||||
{!selectMode && device.status === 'hidden' && (
|
||||
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||
)}
|
||||
{/* Canvas-presence corner: how many canvases this device already sits on.
|
||||
Hidden while a select-mode checkmark occupies the same corner. */}
|
||||
{device.status !== 'hidden' && (device.canvas_count ?? 0) > 0 && !(selectMode && selected) && (
|
||||
<div
|
||||
className="absolute top-0 right-0 flex items-center gap-1 rounded-bl-lg rounded-tr-lg bg-[#00d4ff] text-[#0d1117] text-xs font-bold px-2 py-1 shadow-md"
|
||||
title={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
|
||||
aria-label={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
|
||||
>
|
||||
<Layers size={12} strokeWidth={2.5} />
|
||||
{device.canvas_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Settings } from 'lucide-react'
|
||||
import { Plus, Trash2, Settings, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { scanApi, type DeepScanConfig } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ScanConfigModalProps {
|
||||
@@ -13,24 +13,56 @@ interface ScanConfigModalProps {
|
||||
onScanNow: () => void
|
||||
}
|
||||
|
||||
const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false }
|
||||
|
||||
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
||||
const [ranges, setRanges] = useState<string[]>([''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Deep-scan section. Pre-filled from persisted defaults; edits here are a
|
||||
// per-scan override passed to trigger() — they do NOT change the saved defaults.
|
||||
const [deepOpen, setDeepOpen] = useState(false)
|
||||
const [deepDefaults, setDeepDefaults] = useState<DeepScanConfig>(DEEP_DEFAULTS)
|
||||
const [httpProbe, setHttpProbe] = useState(false)
|
||||
const [verifyTls, setVerifyTls] = useState(false)
|
||||
const [httpRangesText, setHttpRangesText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scanApi.getConfig()
|
||||
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
|
||||
.then((res) => {
|
||||
const d = res.data
|
||||
setRanges(d.ranges.length > 0 ? d.ranges : [''])
|
||||
const deep: DeepScanConfig = {
|
||||
http_ranges: d.http_ranges ?? [],
|
||||
http_probe_enabled: d.http_probe_enabled ?? false,
|
||||
verify_tls: d.verify_tls ?? false,
|
||||
}
|
||||
setDeepDefaults(deep)
|
||||
setHttpProbe(deep.http_probe_enabled)
|
||||
setVerifyTls(deep.verify_tls)
|
||||
setHttpRangesText(deep.http_ranges.join(', '))
|
||||
setDeepOpen(deep.http_probe_enabled || deep.http_ranges.length > 0)
|
||||
})
|
||||
.catch(() => {/* use defaults */})
|
||||
}, [open])
|
||||
|
||||
const parseHttpRanges = () =>
|
||||
httpRangesText.split(',').map((r) => r.trim()).filter(Boolean)
|
||||
|
||||
const handleScanNow = async () => {
|
||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
await scanApi.saveConfig({ ranges: cleaned })
|
||||
await scanApi.trigger()
|
||||
// Persist IP ranges; leave deep-scan defaults as configured in Options.
|
||||
await scanApi.saveConfig({ ranges: cleaned, ...deepDefaults })
|
||||
// Per-scan deep-scan override from this dialog.
|
||||
await scanApi.trigger({
|
||||
http_ranges: parseHttpRanges(),
|
||||
http_probe_enabled: httpProbe,
|
||||
verify_tls: verifyTls,
|
||||
})
|
||||
onScanNow()
|
||||
onClose()
|
||||
} catch {
|
||||
@@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Deep Scan (opt-in) */}
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeepOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{deepOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
Deep Scan
|
||||
</button>
|
||||
|
||||
{deepOpen && (
|
||||
<div className="space-y-3 pl-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scan extra ports and probe HTTP services to identify apps on custom ports.
|
||||
Overrides the saved defaults for this scan only.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Extra port ranges</Label>
|
||||
<Input
|
||||
value={httpRangesText}
|
||||
onChange={(e) => setHttpRangesText(e.target.value)}
|
||||
placeholder="8000-8100, 9000-9100"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={httpProbe}
|
||||
onChange={(e) => setHttpProbe(e.target.checked)}
|
||||
className="accent-[#00d4ff]"
|
||||
/>
|
||||
Enable HTTP probe
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={verifyTls}
|
||||
onChange={(e) => setVerifyTls(e.target.checked)}
|
||||
className="accent-[#00d4ff]"
|
||||
/>
|
||||
Verify TLS certificates
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Settings size={11} />
|
||||
Status check interval can be configured in the sidebar Settings.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
|
||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { scanApi } from '@/api/client'
|
||||
@@ -22,7 +22,18 @@ interface ScanHistoryModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee'
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||
|
||||
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
||||
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
|
||||
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
|
||||
}
|
||||
|
||||
const KIND_META = {
|
||||
ip: { label: 'IP', color: '#a855f7' },
|
||||
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
||||
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
||||
} as const
|
||||
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
@@ -37,6 +48,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'ip', label: 'IP' },
|
||||
{ key: 'zigbee', label: 'Zigbee' },
|
||||
{ key: 'zwave', label: 'Z-Wave' },
|
||||
]
|
||||
|
||||
function statusColor(s: string): string {
|
||||
@@ -90,8 +102,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (prev?.status === 'running' && run.status === 'done') {
|
||||
if (run.kind === 'zigbee') {
|
||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
if (run.kind === 'zigbee' || run.kind === 'zwave') {
|
||||
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
|
||||
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
}
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
}
|
||||
@@ -143,7 +156,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
}
|
||||
|
||||
const filtered = runs.filter((r) => {
|
||||
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
|
||||
const k = runKind(r.kind)
|
||||
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||
return true
|
||||
@@ -216,7 +229,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((r) => {
|
||||
const isZigbee = r.kind === 'zigbee'
|
||||
const kind = runKind(r.kind)
|
||||
const meta = KIND_META[kind]
|
||||
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
|
||||
return (
|
||||
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -225,12 +240,10 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={isZigbee
|
||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||
: { background: '#a855f722', color: '#a855f7' }}
|
||||
style={{ background: `${meta.color}22`, color: meta.color }}
|
||||
>
|
||||
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
|
||||
{isZigbee ? 'Zigbee' : 'IP'}
|
||||
<KindIcon size={10} />
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||
{r.devices_found} found
|
||||
|
||||
@@ -37,6 +37,16 @@ describe('CustomStyleModal', () => {
|
||||
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('Zigbee')).toBeDefined()
|
||||
expect(screen.getByText('Z-Wave')).toBeDefined()
|
||||
// A Z-Wave node type is selectable from its category.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
|
||||
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('selecting a node type opens the node editor', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
@@ -124,4 +134,23 @@ describe('CustomStyleModal', () => {
|
||||
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||
})
|
||||
|
||||
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
||||
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||
const { rerender } = render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
const widthInput = screen.getAllByRole('spinbutton')[0]
|
||||
fireEvent.change(widthInput, { target: { value: '250' } })
|
||||
expect((widthInput as HTMLInputElement).value).toBe('250')
|
||||
|
||||
// Cancel = parent flips open → false, then later → true again.
|
||||
rerender(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||
rerender(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
const reopenedWidth = screen.getAllByRole('spinbutton')[0]
|
||||
// Draft was reset to saved style (default width 0) — edit did not leak.
|
||||
expect((reopenedWidth as HTMLInputElement).value).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +67,23 @@ const DEVICE_ZIGBEE = {
|
||||
discovered_at: '2026-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_ZWAVE = {
|
||||
id: 'dev-c',
|
||||
ip: null,
|
||||
hostname: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'zwave_router',
|
||||
status: 'pending',
|
||||
discovery_source: 'zwave',
|
||||
ieee_address: 'zwave-0xh-2',
|
||||
friendly_name: 'wall-plug',
|
||||
vendor: 'Aeotec',
|
||||
model: 'ZW100',
|
||||
discovered_at: '2026-01-03T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
@@ -129,6 +146,22 @@ describe('PendingDevicesModal', () => {
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows source chip Z-WAVE for zwave device', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
|
||||
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by source (zwave only)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
@@ -180,7 +213,7 @@ describe('PendingDevicesModal', () => {
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
|
||||
})
|
||||
|
||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||
@@ -257,7 +290,7 @@ describe('PendingDevicesModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
|
||||
expect(mockBulkRestore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -271,4 +304,64 @@ describe('PendingDevicesModal', () => {
|
||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||
expect(mockBulkApprove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
|
||||
|
||||
it('titles the pending view "Device Inventory"', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Hidden Devices" title in hidden mode', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a corner canvas-count when canvas_count > 0', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
const corner = screen.getByLabelText('On 2 canvases')
|
||||
expect(corner).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('uses singular "canvas" for a single canvas', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('does not render the canvas-count corner when canvas_count is 0', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters to devices with detected services when "With services" is on', async () => {
|
||||
// dev-a has an http service; dev-b (zigbee) has none.
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /With services/ }))
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows on-canvas devices by default and hides them when toggled off', async () => {
|
||||
mockPending.mockResolvedValue({
|
||||
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
// Default: both visible (on-canvas shown).
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
// Toggle off → the on-canvas device (dev-b) disappears.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hide on-canvas/ }))
|
||||
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,7 +82,12 @@ describe('ScanConfigModal', () => {
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({
|
||||
ranges: ['192.168.1.0/24'],
|
||||
http_ranges: [],
|
||||
http_probe_enabled: false,
|
||||
verify_tls: false,
|
||||
})
|
||||
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||
expect(onScanNow).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
@@ -108,4 +113,59 @@ describe('ScanConfigModal', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Deep scan ---
|
||||
|
||||
it('reveals deep-scan fields when the section is toggled', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
expect(screen.queryByText('Enable HTTP probe')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Deep Scan'))
|
||||
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes deep-scan overrides to trigger() as a per-scan override', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByText('Deep Scan'))
|
||||
fireEvent.change(screen.getByPlaceholderText('8000-8100, 9000-9100'), {
|
||||
target: { value: '8000-8100, 9000' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Enable HTTP probe'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.trigger).toHaveBeenCalledWith({
|
||||
http_ranges: ['8000-8100', '9000'],
|
||||
http_probe_enabled: true,
|
||||
verify_tls: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-opens deep-scan section when a default probe is enabled', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({
|
||||
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: false },
|
||||
} as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
|
||||
expect(screen.getByDisplayValue('7000-7100')).toBeDefined()
|
||||
})
|
||||
|
||||
it('saving keeps deep-scan defaults untouched (modal only overrides per-scan)', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({
|
||||
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: true },
|
||||
} as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({
|
||||
ranges: ['192.168.1.0/24'],
|
||||
http_ranges: ['7000-7100'],
|
||||
http_probe_enabled: true,
|
||||
verify_tls: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,17 @@ const ZIGBEE_RUN = {
|
||||
error: null,
|
||||
}
|
||||
|
||||
const ZWAVE_RUN = {
|
||||
id: 'run-5',
|
||||
status: 'done',
|
||||
kind: 'zwave',
|
||||
ranges: [],
|
||||
devices_found: 5,
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
@@ -155,4 +166,14 @@ describe('ScanHistoryModal', () => {
|
||||
expect(screen.getByText('7 found')).toBeDefined()
|
||||
expect(screen.queryByText('3 found')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by zwave kind', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZWAVE_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||
// Only the zwave run (5 found) remains
|
||||
expect(screen.getByText('5 found')).toBeDefined()
|
||||
expect(screen.queryByText('3 found')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -15,7 +15,7 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||
{ kind: 'pending', icon: ScanLine, label: 'Device Inventory' },
|
||||
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||
]
|
||||
|
||||
@@ -25,13 +25,14 @@ interface SidebarProps {
|
||||
onAddText: () => void
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onZwaveImport: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
@@ -217,6 +218,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
|
||||
@@ -70,6 +70,7 @@ const defaultProps = {
|
||||
onAddText: vi.fn(),
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onZwaveImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenHistory: vi.fn(),
|
||||
@@ -98,7 +99,7 @@ describe('Sidebar', () => {
|
||||
it('shows all view nav items', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
expect(screen.getByText('Canvas')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
|
||||
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
||||
expect(screen.getByText('Scan History')).toBeInTheDocument()
|
||||
})
|
||||
@@ -184,6 +185,12 @@ describe('Sidebar', () => {
|
||||
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onZwaveImport when Z-Wave Import is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Z-Wave Import'))
|
||||
expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSave when Save Canvas is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Save Canvas'))
|
||||
@@ -233,9 +240,9 @@ describe('Sidebar', () => {
|
||||
|
||||
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||
|
||||
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Pending Devices'))
|
||||
fireEvent.click(screen.getByText('Device Inventory'))
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState } from 'react'
|
||||
import { RadioTower, Share2, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { zwaveApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { ZwaveNode, ZwaveEdge } from './types'
|
||||
|
||||
interface ZwaveImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
|
||||
onPendingImported?: (
|
||||
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
type ImportMode = 'pending' | 'canvas'
|
||||
|
||||
const ACCENT = '#ff6e00'
|
||||
|
||||
interface ConnectionForm {
|
||||
mqtt_host: string
|
||||
mqtt_port: string
|
||||
mqtt_username: string
|
||||
mqtt_password: string
|
||||
prefix: string
|
||||
gateway_name: string
|
||||
mqtt_tls: boolean
|
||||
mqtt_tls_insecure: boolean
|
||||
port_user_edited: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ConnectionForm = {
|
||||
mqtt_host: '',
|
||||
mqtt_port: '1883',
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
prefix: 'zwave',
|
||||
gateway_name: 'zwavejs2mqtt',
|
||||
mqtt_tls: false,
|
||||
mqtt_tls_insecure: false,
|
||||
port_user_edited: false,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_ICON = {
|
||||
zwave_coordinator: RadioTower,
|
||||
zwave_router: Share2,
|
||||
zwave_enddevice: Cpu,
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_LABEL = {
|
||||
zwave_coordinator: 'Controller',
|
||||
zwave_router: 'Router',
|
||||
zwave_enddevice: 'End Device',
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_COLOR = {
|
||||
zwave_coordinator: '#ff6e00',
|
||||
zwave_router: '#39d353',
|
||||
zwave_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<ZwaveNode[]>([])
|
||||
const [edges, setEdges] = useState<ZwaveEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[field]: value,
|
||||
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||
}))
|
||||
|
||||
const toggleTls = (next: boolean) =>
|
||||
setForm((f) => {
|
||||
const port = f.port_user_edited
|
||||
? f.mqtt_port
|
||||
: next
|
||||
? '8883'
|
||||
: '1883'
|
||||
return {
|
||||
...f,
|
||||
mqtt_tls: next,
|
||||
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||
mqtt_port: port,
|
||||
}
|
||||
})
|
||||
|
||||
const buildPayload = () => ({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
prefix: form.prefix.trim() || 'zwave',
|
||||
gateway_name: form.gateway_name.trim() || 'zwavejs2mqtt',
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const res = await zwaveApi.testConnection({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
if (res.data.connected) {
|
||||
setConnectionStatus('ok')
|
||||
setConnectionMsg(res.data.message)
|
||||
} else {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg(res.data.message)
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg('Request failed — check broker address')
|
||||
}
|
||||
}
|
||||
|
||||
const extractError = (err: unknown): string | undefined => {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const handleFetchDevices = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
if (importMode === 'pending') {
|
||||
await zwaveApi.importToPending(buildPayload())
|
||||
toast.success('Z-Wave import started — track progress in Scan History')
|
||||
onPendingImported?.(null)
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await zwaveApi.importNetwork(buildPayload())
|
||||
setDevices(res.data.nodes)
|
||||
setEdges(res.data.edges)
|
||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||
if (res.data.device_count === 0) {
|
||||
toast.info('No Z-Wave devices found')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Z-Wave devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCheck = (id: string) =>
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleAll = () => {
|
||||
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleAddToCanvas = () => {
|
||||
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||
onAddToCanvas(selectedDevices, selectedEdges)
|
||||
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setDevices([])
|
||||
setEdges([])
|
||||
setChecked(new Set())
|
||||
setConnectionStatus('idle')
|
||||
setConnectionMsg('')
|
||||
setImportMode('pending')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const groupedDevices = {
|
||||
zwave_coordinator: devices.filter((d) => d.type === 'zwave_coordinator'),
|
||||
zwave_router: devices.filter((d) => d.type === 'zwave_router'),
|
||||
zwave_enddevice: devices.filter((d) => d.type === 'zwave_enddevice'),
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<RadioTower size={16} style={{ color: ACCENT }} />
|
||||
Z-Wave Import
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||
{/* Connection Form */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||
<Input
|
||||
value={form.mqtt_host}
|
||||
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||
placeholder="192.168.1.x or mqtt.local"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
value={form.mqtt_port}
|
||||
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||
placeholder="1883"
|
||||
type="number"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">MQTT Prefix</Label>
|
||||
<Input
|
||||
value={form.prefix}
|
||||
onChange={(e) => updateField('prefix', e.target.value)}
|
||||
placeholder="zwave"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Gateway Name</Label>
|
||||
<Input
|
||||
value={form.gateway_name}
|
||||
onChange={(e) => updateField('gateway_name', e.target.value)}
|
||||
placeholder="zwavejs2mqtt"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_username}
|
||||
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||
placeholder="mqtt_user"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_password}
|
||||
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls}
|
||||
onChange={(e) => toggleTls(e.target.checked)}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Use TLS (port 8883)
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls_insecure}
|
||||
disabled={!form.mqtt_tls}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||
}
|
||||
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
Skip cert verify (self-signed only)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
{connectionStatus !== 'idle' && (
|
||||
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||
connectionStatus === 'ok'
|
||||
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||
: connectionStatus === 'fail'
|
||||
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||
}`}>
|
||||
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Send devices to:</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zwave-import-mode"
|
||||
checked={importMode === 'pending'}
|
||||
onChange={() => setImportMode('pending')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Pending section
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zwave-import-mode"
|
||||
checked={importMode === 'canvas'}
|
||||
onChange={() => setImportMode('canvas')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Canvas directly
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionStatus === 'testing' || loading}
|
||||
>
|
||||
{connectionStatus === 'testing'
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <CheckCircle2 size={13} />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
onClick={handleFetchDevices}
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RadioTower size={13} />}
|
||||
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
Make sure the gateway name matches your Z-Wave JS UI configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device List */}
|
||||
{devices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.size === devices.length}
|
||||
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||
onChange={toggleAll}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
title="Select all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Devices ({checked.size}/{devices.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][])
|
||||
.filter(([, group]) => group.length > 0)
|
||||
.map(([type, group]) => {
|
||||
const Icon = DEVICE_TYPE_ICON[type]
|
||||
const color = DEVICE_TYPE_COLOR[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon size={11} style={{ color }} />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||
</span>
|
||||
</div>
|
||||
{group.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||
checked.has(device.id)
|
||||
? 'bg-[#21262d] border-[#ff6e00]/40'
|
||||
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||
}`}
|
||||
onClick={() => toggleCheck(device.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(device.id)}
|
||||
onChange={() => toggleCheck(device.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||
{(device.model || device.vendor) && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||
{devices.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAddToCanvas}
|
||||
disabled={checked.size === 0}
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {checked.size} to Canvas
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ZwaveImportModal } from '../ZwaveImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
zwaveApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { zwaveApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
{
|
||||
id: 'zwave-0xh-1',
|
||||
label: 'Controller',
|
||||
type: 'zwave_coordinator' as const,
|
||||
ieee_address: 'zwave-0xh-1',
|
||||
friendly_name: 'Controller',
|
||||
device_type: 'Controller',
|
||||
model: null,
|
||||
vendor: null,
|
||||
lqi: null,
|
||||
parent_id: null,
|
||||
},
|
||||
{
|
||||
id: 'zwave-0xh-2',
|
||||
label: 'Wall Plug',
|
||||
type: 'zwave_router' as const,
|
||||
ieee_address: 'zwave-0xh-2',
|
||||
friendly_name: 'Wall Plug',
|
||||
device_type: 'Router',
|
||||
model: 'ZW100',
|
||||
vendor: 'Aeotec',
|
||||
lqi: null,
|
||||
parent_id: 'zwave-0xh-1',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ZwaveImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zwaveApi.testConnection).mockReset()
|
||||
vi.mocked(zwaveApi.importNetwork).mockReset()
|
||||
vi.mocked(zwaveApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.info).mockReset()
|
||||
defaultProps.onClose.mockReset()
|
||||
defaultProps.onAddToCanvas.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ZwaveImportModal {...defaultProps} open={false} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the modal with prefix and gateway fields when open', () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
expect(screen.getByText('Z-Wave Import')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zwave')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error toast when testing connection without a host', async () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||
})
|
||||
expect(zwaveApi.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success status when connection test passes', async () => {
|
||||
vi.mocked(zwaveApi.testConnection).mockResolvedValue({
|
||||
data: { connected: true, message: 'Connection successful' },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
const selectCanvasMode = () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Controller')).toBeDefined()
|
||||
expect(screen.getByText('Wall Plug')).toBeDefined()
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||
})
|
||||
|
||||
it('passes prefix and gateway_name to importNetwork', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '10.0.0.5' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled())
|
||||
const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0]
|
||||
expect(payload.prefix).toBe('myzw')
|
||||
expect(payload.gateway_name).toBe('gw1')
|
||||
})
|
||||
|
||||
it('imports to pending by default and notifies parent', async () => {
|
||||
vi.mocked(zwaveApi.importToPending).mockResolvedValue({
|
||||
data: {
|
||||
id: 'run-1',
|
||||
status: 'running',
|
||||
kind: 'zwave',
|
||||
ranges: ['192.168.1.100:1883'],
|
||||
devices_found: 0,
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
},
|
||||
} as never)
|
||||
const onPendingImported = vi.fn()
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(zwaveApi.importToPending).toHaveBeenCalled()
|
||||
expect(onPendingImported).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => screen.getByText('Controller'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Shared Z-Wave type definitions for the frontend. */
|
||||
|
||||
export interface ZwaveNode {
|
||||
id: string
|
||||
label: string
|
||||
type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice'
|
||||
ieee_address: string
|
||||
friendly_name: string
|
||||
device_type: string
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface ZwaveEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface ZwaveImportResponse {
|
||||
nodes: ZwaveNode[]
|
||||
edges: ZwaveEdge[]
|
||||
device_count: number
|
||||
}
|
||||
|
||||
export interface ZwaveTestConnectionRequest {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
}
|
||||
|
||||
export interface ZwaveTestConnectionResponse {
|
||||
connected: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export type NodeType =
|
||||
| 'zigbee_coordinator'
|
||||
| 'zigbee_router'
|
||||
| 'zigbee_enddevice'
|
||||
| 'zwave_coordinator'
|
||||
| 'zwave_router'
|
||||
| 'zwave_enddevice'
|
||||
| 'grid'
|
||||
| 'ups'
|
||||
| 'battery'
|
||||
@@ -187,6 +190,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
zigbee_coordinator: 'Zigbee Coordinator',
|
||||
zigbee_router: 'Zigbee Router',
|
||||
zigbee_enddevice: 'Zigbee End Device',
|
||||
zwave_coordinator: 'Z-Wave Controller',
|
||||
zwave_router: 'Z-Wave Router',
|
||||
zwave_enddevice: 'Z-Wave End Device',
|
||||
grid: 'Grid Connection',
|
||||
ups: 'UPS',
|
||||
battery: 'Battery',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isZwaveType, buildZwaveProperties } from '../zwaveProperties'
|
||||
|
||||
describe('isZwaveType', () => {
|
||||
it('returns true for zwave types', () => {
|
||||
expect(isZwaveType('zwave_coordinator')).toBe(true)
|
||||
expect(isZwaveType('zwave_router')).toBe(true)
|
||||
expect(isZwaveType('zwave_enddevice')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-zwave types', () => {
|
||||
expect(isZwaveType('zigbee_router')).toBe(false)
|
||||
expect(isZwaveType('server')).toBe(false)
|
||||
expect(isZwaveType(undefined)).toBe(false)
|
||||
expect(isZwaveType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildZwaveProperties', () => {
|
||||
it('builds Z-Wave ID / Vendor / Model rows, all hidden', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: 'Aeotec', model: 'ZW100' })
|
||||
expect(props).toEqual([
|
||||
{ key: 'Z-Wave ID', value: 'zwave-0xh-2', icon: null, visible: false },
|
||||
{ key: 'Vendor', value: 'Aeotec', icon: null, visible: false },
|
||||
{ key: 'Model', value: 'ZW100', icon: null, visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits empty fields', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: null, model: undefined })
|
||||
expect(props.map((p) => p.key)).toEqual(['Z-Wave ID'])
|
||||
})
|
||||
|
||||
it('never adds an LQI row', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'x', vendor: 'v', model: 'm' })
|
||||
expect(props.some((p) => p.key === 'LQI')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
// Security & Auth
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||
// Automation & IoT
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, RadioTower, Share2, BotMessageSquare, Webhook,
|
||||
// Smart Home / Sensors
|
||||
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
|
||||
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
|
||||
@@ -178,6 +178,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
zigbee_coordinator: Radio,
|
||||
zigbee_router: Zap,
|
||||
zigbee_enddevice: Lightbulb,
|
||||
zwave_coordinator: RadioTower,
|
||||
zwave_router: Share2,
|
||||
zwave_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
|
||||
@@ -64,6 +64,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||
zwave_coordinator: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
zwave_router: { border: '#e3b341', icon: '#e3b341' },
|
||||
zwave_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
@@ -143,6 +146,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
|
||||
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' },
|
||||
zwave_coordinator: { border: '#fb923c', icon: '#fb923c' },
|
||||
zwave_router: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
zwave_enddevice: { border: '#c084fc', icon: '#c084fc' },
|
||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
@@ -222,6 +228,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
|
||||
zigbee_router: { border: '#b45309', icon: '#b45309' },
|
||||
zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
zwave_coordinator: { border: '#ea580c', icon: '#ea580c' },
|
||||
zwave_router: { border: '#b45309', icon: '#b45309' },
|
||||
zwave_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
@@ -301,6 +310,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
|
||||
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
|
||||
zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
zwave_coordinator: { border: '#ff8800', icon: '#ff8800' },
|
||||
zwave_router: { border: '#ffff00', icon: '#ffff00' },
|
||||
zwave_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
@@ -380,6 +392,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
|
||||
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
|
||||
zigbee_enddevice: { border: '#008822', icon: '#008822' },
|
||||
zwave_coordinator: { border: '#33ff66', icon: '#33ff66' },
|
||||
zwave_router: { border: '#66ff33', icon: '#66ff33' },
|
||||
zwave_enddevice: { border: '#008822', icon: '#008822' },
|
||||
generic: { border: '#006600', icon: '#006600' },
|
||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', icon: '#00ff41' },
|
||||
@@ -459,6 +474,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||
zwave_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||
zwave_router: { border: '#e3b341', icon: '#e3b341' },
|
||||
zwave_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeProperty, NodeType } from '@/types'
|
||||
|
||||
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
|
||||
|
||||
export function isZwaveType(type: NodeType | string | undefined | null): boolean {
|
||||
return !!type && (ZWAVE_TYPES as string[]).includes(type)
|
||||
}
|
||||
|
||||
/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel.
|
||||
* Matches backend `build_zwave_properties` (no LQI — Z-Wave has none). */
|
||||
export function buildZwaveProperties(input: {
|
||||
ieee_address?: string | null
|
||||
vendor?: string | null
|
||||
model?: string | null
|
||||
}): NodeProperty[] {
|
||||
const props: NodeProperty[] = []
|
||||
if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false })
|
||||
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
|
||||
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
|
||||
return props
|
||||
}
|
||||
Reference in New Issue
Block a user