From 8faf5c1c79618e376813d0d320756690beb0b068 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 11:19:37 +0200 Subject: [PATCH 1/7] feat: add Z-Wave network scan via MQTT gateway Import a Z-Wave JS UI (zwavejs2mqtt) network over the MQTT gateway API, mirroring the existing Zigbee pipeline: - New Z-Wave Import modal + sidebar entry (broker, prefix, gateway name) - coordinator/router/end-device typing with mesh tree from node neighbors - import to Pending section or straight to canvas - Pending Devices gains a Z-Wave source filter - shared mqtt_common helpers extracted from the zigbee service ha-relevant: yes --- backend/app/api/routes/zwave.py | 284 +++++++++++ backend/app/main.py | 3 +- backend/app/schemas/zwave.py | 85 ++++ backend/app/services/mqtt_common.py | 168 +++++++ backend/app/services/zigbee_service.py | 41 +- backend/app/services/zwave_service.py | 235 +++++++++ backend/tests/test_mqtt_common.py | 183 +++++++ backend/tests/test_zwave_router.py | 447 +++++++++++++++++ backend/tests/test_zwave_service.py | 269 ++++++++++ frontend/src/App.tsx | 59 +++ frontend/src/api/__tests__/client.test.ts | 10 + frontend/src/api/client.ts | 49 ++ .../components/modals/PendingDevicesModal.tsx | 46 +- .../__tests__/PendingDevicesModal.test.tsx | 33 ++ frontend/src/components/panels/Sidebar.tsx | 6 +- .../panels/__tests__/Sidebar.test.tsx | 7 + .../src/components/zwave/ZwaveImportModal.tsx | 461 ++++++++++++++++++ .../zwave/__tests__/ZwaveImportModal.test.tsx | 198 ++++++++ frontend/src/components/zwave/types.ts | 37 ++ frontend/src/types/index.ts | 6 + .../utils/__tests__/zwaveProperties.test.ts | 38 ++ frontend/src/utils/nodeIcons.ts | 5 +- frontend/src/utils/zwaveProperties.ts | 21 + 23 files changed, 2638 insertions(+), 53 deletions(-) create mode 100644 backend/app/api/routes/zwave.py create mode 100644 backend/app/schemas/zwave.py create mode 100644 backend/app/services/mqtt_common.py create mode 100644 backend/app/services/zwave_service.py create mode 100644 backend/tests/test_mqtt_common.py create mode 100644 backend/tests/test_zwave_router.py create mode 100644 backend/tests/test_zwave_service.py create mode 100644 frontend/src/components/zwave/ZwaveImportModal.tsx create mode 100644 frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx create mode 100644 frontend/src/components/zwave/types.ts create mode 100644 frontend/src/utils/__tests__/zwaveProperties.test.ts create mode 100644 frontend/src/utils/zwaveProperties.ts 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/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/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/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/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/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_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/src/App.tsx b/frontend/src/App.tsx index 069ecb6..b502ce4 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 @@ -541,6 +544,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) }, []) @@ -615,6 +662,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)} @@ -733,6 +781,17 @@ export default function App() { /> )} + {!STANDALONE && ( + setZwaveImportOpen(false)} + onAddToCanvas={handleZwaveAddToCanvas} + onPendingImported={() => { + toast.success('Z-Wave import started — check Scan History for results') + }} + /> + )} + {!STANDALONE && ( { 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 22b8e8c..45a5958 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -164,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/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index eaba4c0..933fa60 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -10,6 +10,7 @@ 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 +68,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' } @@ -264,15 +267,20 @@ 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, } @@ -282,7 +290,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' : ''})` : '' @@ -327,7 +335,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, @@ -338,9 +347,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), }, }) }) @@ -473,6 +486,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus > Zigbee + 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/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 +} From 9b8f15bec30fbf058b7970052cdfb53954ed25ce Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 13:10:12 +0200 Subject: [PATCH 2/7] feat: show Z-Wave scan runs in Scan History Scan History now recognises kind=zwave: dedicated Z-Wave filter chip, badge (RadioTower, orange) and import-done toast, instead of falling back to the generic IP type. ha-relevant: yes --- .../components/modals/ScanHistoryModal.tsx | 35 +++++++++++++------ .../__tests__/ScanHistoryModal.test.tsx | 21 +++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) 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__/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() + }) }) From 5b08d5712464ff84d009b8aba35fb62679ce00de Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 13:58:30 +0200 Subject: [PATCH 3/7] fix: add zwave node types to canvas theme maps The 6 per-theme NodeType color maps in themes.ts were missing the three zwave_* keys, breaking the production build (tsc -b) even though the dev typecheck passed. ha-relevant: yes --- frontend/src/utils/themes.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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' }, From ecf3cbdfe4116a122a1050cfef0fd5c28a14b9f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 16:02:23 +0200 Subject: [PATCH 4/7] feat: group node types by category in Custom Style editor The Custom Style modal listed node types as a flat list. It now groups them under category headers (Hardware, Virtualization, IoT, Zigbee, Z-Wave, Personal, Generic) like the Add/Edit Node modal, and exposes the Z-Wave node types for styling. ha-relevant: yes --- .../components/modals/CustomStyleModal.tsx | 81 +++++++++++-------- .../__tests__/CustomStyleModal.test.tsx | 10 +++ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index b1f6918..8504d81 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, 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, } @@ -363,34 +367,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/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index 0ea98a7..d368a87 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' })) From f749b38edcc9b7c8771923a5889cc7ff763c597e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 16:20:30 +0200 Subject: [PATCH 5/7] fix: reset Custom Style draft on reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal is kept mounted by its parent (only `open` toggles), so Radix onOpenChange never fires for a parent-driven open and the draft-reset branch was dead code — abandoned edits leaked into the next open. Reset on the `open` prop edge via effect instead. Also pass radix 10 to parseInt for the size inputs. Adds a reopen-after-cancel regression test. ha-relevant: yes --- .../components/modals/CustomStyleModal.tsx | 24 ++++++++++++------- .../__tests__/CustomStyleModal.test.tsx | 19 +++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index 8504d81..c4d962f 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState, useCallback } from 'react' +import { Fragment, useState, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, @@ -160,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]" />
@@ -171,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]" />
@@ -285,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 => diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index d368a87..13a4d47 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -134,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') + }) }) From d41896fadf55a7e580649998dabe7a0e5fca8cd7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 20:42:52 +0200 Subject: [PATCH 6/7] fix: render styled canvas nodes for zwave types The zwave_* node types were not registered in the React Flow nodeTypes map, so imported Z-Wave devices fell back to the default unstyled node (no icon, no accent). Add ZwaveCoordinator/Router/EndDevice node components and register them. Adds a registry guard test. ha-relevant: yes --- .../canvas/nodes/__tests__/nodeTypes.test.ts | 15 +++++++++++++++ frontend/src/components/canvas/nodes/index.tsx | 7 ++++++- frontend/src/components/canvas/nodes/nodeTypes.ts | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts 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, From 13420bead8ec9e1282d7c9084647d9512b9ccf13 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 21:58:31 +0200 Subject: [PATCH 7/7] fix: approve devices onto the active design + Z-Wave node fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approve (single + bulk) created the canvas Node under the first design instead of the design the user is viewing, so approved devices were invisible on the active canvas and got wiped on the next save — and a re-approve returned "0 approved" because the rows were already approved. - bulk-approve now accepts design_id; the UI sends the active design. - single approve already honoured design_id; the UI now sends it too. - generalize the wireless branch (status=online, mesh props, no ICMP check) to Z-Wave as well as Zigbee, using build_zwave_properties. ha-relevant: yes --- backend/app/api/routes/scan.py | 58 +++++++++++---- backend/tests/test_scan.py | 73 +++++++++++++++++++ frontend/src/api/__tests__/client.test.ts | 4 +- frontend/src/api/client.ts | 4 +- .../components/modals/PendingDevicesModal.tsx | 6 +- .../__tests__/PendingDevicesModal.test.tsx | 4 +- 6 files changed, 127 insertions(+), 22 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 7f0da05..5377cb6 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -16,8 +16,28 @@ from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse 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,6 +70,10 @@ 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]: @@ -248,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( @@ -263,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) @@ -375,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 @@ -385,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) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index dc6b518..b9213c8 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -1357,3 +1357,76 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header ) 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/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index f5a129d..557c56d 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -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') diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 45a5958..a21755e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,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[] @@ -89,7 +89,7 @@ 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 }), diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 933fa60..b458fea 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -6,6 +6,7 @@ import { 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' @@ -127,6 +128,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus // 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 () => { @@ -283,6 +285,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus 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 @@ -327,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)) diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index f988d1d..92baef6 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -213,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 () => { @@ -290,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() })