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

Status check interval can be configured in the sidebar Settings. diff --git a/frontend/src/components/modals/ScanHistoryModal.tsx b/frontend/src/components/modals/ScanHistoryModal.tsx index b76e919..dcbb348 100644 --- a/frontend/src/components/modals/ScanHistoryModal.tsx +++ b/frontend/src/components/modals/ScanHistoryModal.tsx @@ -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) { )} {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 (

@@ -225,12 +240,10 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { {r.status === 'running' && } - {isZigbee ? : } - {isZigbee ? 'Zigbee' : 'IP'} + + {meta.label} {r.devices_found} found diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index 0ea98a7..13a4d47 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -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() + 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() 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() + 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() + rerender() + + 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') + }) }) diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index 6470420..92baef6 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -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() + 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() + 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() 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() + 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() + 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() + 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() + 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() + 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() + 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() + // 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() + }) }) diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 988be69..8bdb4e8 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -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() + 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() + 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() + 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() + 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, + }) + }) + }) }) diff --git a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx index 764c2d9..229eea7 100644 --- a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx @@ -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( @@ -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() + }) }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 1900eb9..32186c9 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -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 {!STANDALONE && } {!STANDALONE && } + {!STANDALONE && } { it('shows all view nav items', () => { render() 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() + fireEvent.click(screen.getByText('Z-Wave Import')) + expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce() + }) + it('calls onSave when Save Canvas is clicked', () => { render() 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() - fireEvent.click(screen.getByText('Pending Devices')) + fireEvent.click(screen.getByText('Device Inventory')) expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending') }) diff --git a/frontend/src/components/zwave/ZwaveImportModal.tsx b/frontend/src/components/zwave/ZwaveImportModal.tsx new file mode 100644 index 0000000..ad809ca --- /dev/null +++ b/frontend/src/components/zwave/ZwaveImportModal.tsx @@ -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(DEFAULT_FORM) + const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle') + const [connectionMsg, setConnectionMsg] = useState('') + const [loading, setLoading] = useState(false) + const [devices, setDevices] = useState([]) + const [edges, setEdges] = useState([]) + const [checked, setChecked] = useState>(new Set()) + const [importMode, setImportMode] = useState('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 ( + !v && handleClose()}> + + + + + Z-Wave Import + + + +
+ {/* Connection Form */} +
+
+
+ + updateField('mqtt_host', e.target.value)} + placeholder="192.168.1.x or mqtt.local" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_port', e.target.value)} + placeholder="1883" + type="number" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('prefix', e.target.value)} + placeholder="zwave" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('gateway_name', e.target.value)} + placeholder="zwavejs2mqtt" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_username', e.target.value)} + placeholder="mqtt_user" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_password', e.target.value)} + placeholder="••••••••" + type="password" + autoComplete="new-password" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + +
+
+ + {/* Connection status indicator */} + {connectionStatus !== 'idle' && ( +
+ {connectionStatus === 'testing' && } + {connectionStatus === 'ok' && } + {connectionStatus === 'fail' && } + {connectionStatus === 'testing' ? 'Testing…' : connectionMsg} +
+ )} + +
+ Send devices to: + + +
+
+ + +
+

+ Make sure the gateway name matches your Z-Wave JS UI configuration. +

+
+ + {/* Device List */} + {devices.length > 0 && ( +
+
+
+ { 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" + /> + + Devices ({checked.size}/{devices.length} selected) + +
+
+ + {(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 ( +
+
+ + + {DEVICE_TYPE_LABEL[type]} ({group.length}) + +
+ {group.map((device) => ( +
toggleCheck(device.id)} + > + toggleCheck(device.id)} + onClick={(e) => e.stopPropagation()} + className="w-3 h-3 mt-0.5 cursor-pointer shrink-0" + style={{ accentColor: ACCENT }} + /> +
+
{device.friendly_name}
+
{device.ieee_address}
+ {(device.model || device.vendor) && ( +
+ {[device.vendor, device.model].filter(Boolean).join(' · ')} +
+ )} +
+
+ ))} +
+ ) + })} +
+ )} +
+ + + + {devices.length > 0 && ( + + )} + +
+
+ ) +} diff --git a/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx new file mode 100644 index 0000000..7c8f54c --- /dev/null +++ b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders the modal with prefix and gateway fields when open', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/zwave/types.ts b/frontend/src/components/zwave/types.ts new file mode 100644 index 0000000..3ee9a87 --- /dev/null +++ b/frontend/src/components/zwave/types.ts @@ -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 +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 97b6b92..22132b7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 = { 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', diff --git a/frontend/src/utils/__tests__/zwaveProperties.test.ts b/frontend/src/utils/__tests__/zwaveProperties.test.ts new file mode 100644 index 0000000..3e6608d --- /dev/null +++ b/frontend/src/utils/__tests__/zwaveProperties.test.ts @@ -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) + }) +}) diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index e7ea3a5..182d6a5 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -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 = { zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, + zwave_coordinator: RadioTower, + zwave_router: Share2, + zwave_enddevice: Lightbulb, generic: Circle, group: Circle, groupRect: Circle, diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index f2a1d23..892ca46 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -64,6 +64,9 @@ export const THEMES: Record = { 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 = { 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 = { 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 = { 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 = { 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 = { 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' }, diff --git a/frontend/src/utils/zwaveProperties.ts b/frontend/src/utils/zwaveProperties.ts new file mode 100644 index 0000000..6a47804 --- /dev/null +++ b/frontend/src/utils/zwaveProperties.ts @@ -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 +}