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/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_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/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.scanApi.ignore('d1') expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore') mod.scanApi.bulkApprove(['a', 'b']) - expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] }) + expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined }) + mod.scanApi.bulkApprove(['a'], 'design-9') + expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' }) mod.scanApi.bulkHide(['a']) expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] }) mod.scanApi.restore('d1') @@ -217,4 +219,14 @@ describe('api/client', () => { mod.zigbeeApi.importToPending(cfg) expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg) }) + + it('zwaveApi.testConnection/importNetwork/importToPending', () => { + const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' } + mod.zwaveApi.testConnection(cfg) + expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg) + mod.zwaveApi.importNetwork(cfg) + expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg) + mod.zwaveApi.importToPending(cfg) + expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg) + }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 22b8e8c..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 }), @@ -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/canvas/nodes/__tests__/nodeTypes.test.ts b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts new file mode 100644 index 0000000..1e0b237 --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { nodeTypes } from '../nodeTypes' + +describe('nodeTypes registry', () => { + it('registers a component for every wireless mesh node type', () => { + // Regression: zwave_* types were missing, so React Flow fell back to the + // default (unstyled) node — no icon, no accent. (Zigbee covered too.) + for (const t of [ + 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', + 'zwave_coordinator', 'zwave_router', 'zwave_enddevice', + ]) { + expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined() + } + }) +}) diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index 1af4afd..d7b758a 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -1,7 +1,7 @@ import { type NodeProps, type Node } from '@xyflow/react' import { Globe, Router, Network, Server, Layers, Box, Container, - HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2, Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap, } from 'lucide-react' import { BaseNode } from './BaseNode' @@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) => export const ZigbeeEndDeviceNode = (props: N) => +// Z-Wave node types +export const ZwaveCoordinatorNode = (props: N) => +export const ZwaveRouterNode = (props: N) => +export const ZwaveEndDeviceNode = (props: N) => + // Electrical node types export const GridNode = (props: N) => export const UpsNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index e278307..98f8b8e 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -3,6 +3,7 @@ import { NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode, + ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode, GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode, CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode, LightNode, MeterNode, TransformerNode, LoadNode, @@ -39,6 +40,9 @@ export const nodeTypes = { zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_router: ZigbeeRouterNode, zigbee_enddevice: ZigbeeEndDeviceNode, + zwave_coordinator: ZwaveCoordinatorNode, + zwave_router: ZwaveRouterNode, + zwave_enddevice: ZwaveEndDeviceNode, grid: GridNode, ups: UpsNode, battery: BatteryNode, diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index b1f6918..c4d962f 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -1,9 +1,9 @@ -import { useState, useCallback } from 'react' +import { Fragment, useState, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame, - Radio, Zap, Lightbulb, + Radio, Zap, Lightbulb, RadioTower, Share2, type LucideIcon, } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -17,13 +17,16 @@ import type { } from '@/types' import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' -// ── Node types exposed for custom style (skip groupRect/group) ─────────────── +// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ── -const EDITABLE_NODE_TYPES: NodeType[] = [ - 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', - 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', - 'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', - 'generic', +const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ + { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, + { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, + { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, + { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, + { label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] }, + { label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, + { label: 'Generic', types: ['generic'] }, ] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical'] @@ -34,6 +37,7 @@ const NODE_ICONS: Record = { camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap, docker_host: Anchor, docker_container: Package, zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, + zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb, generic: Circle, } @@ -156,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor min={0} step={10} value={style.width} - onChange={(e) => set('width', parseInt(e.target.value) || 0)} + onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" /> @@ -167,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor min={0} step={10} value={style.height} - onChange={(e) => set('height', parseInt(e.target.value) || 0)} + onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" /> @@ -281,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { edges: { ...customStyle.edges }, })) - const handleOpen = (isOpen: boolean) => { - if (isOpen) { - // Reset draft to current saved customStyle on open + // Reset the draft to the saved customStyle whenever the modal is (re)opened. + // The parent keeps this component mounted and only toggles `open`, so Radix's + // onOpenChange never fires for a parent-driven open — we key off the prop edge + // instead. Without this, abandoned edits (Cancel) would leak into the next open. + useEffect(() => { + if (open) { setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) setSelection(null) - } else { - onClose() } + // Intentional snapshot-on-open: we don't want live customStyle changes to + // clobber an in-progress edit, only a fresh open should reset. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleOpen = (isOpen: boolean) => { + if (!isOpen) onClose() } const getNodeStyle = (t: NodeType): NodeTypeStyle => @@ -363,34 +375,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { {/* Type list */}
- {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { - const Icon = NODE_ICONS[t] ?? Circle - const style = draft.nodes[t] - const isSelected = selection?.kind === 'node' && selection.type === t - const swatchColor = style - ? applyOpacity(style.borderColor, style.borderOpacity) - : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + {tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => ( + +
+ {group.label} +
+ {group.types.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' - return ( - - ) - })} + return ( + + ) + })} +
+ ))} {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { const style = draft.edges[t] diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index eaba4c0..b458fea 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -6,10 +6,12 @@ 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' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' +import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties' import { buildMacProperty } from '@/utils/macProperty' interface PendingDevicesModalProps { @@ -67,11 +69,13 @@ const TYPE_ICONS: Record = { generic: Circle, } -type SourceFilter = 'all' | 'ip' | 'zigbee' +type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave' type StatusFilter = 'pending' | 'hidden' -function inferSource(d: PendingDevice): 'zigbee' | 'ip' { - if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee' +function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' { + if (d.discovery_source === 'zwave') return 'zwave' + if (d.discovery_source === 'zigbee') return 'zigbee' + if (d.ieee_address) return 'zigbee' return 'ip' } @@ -124,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 () => { @@ -264,17 +269,24 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus try { const fallbackLabel = deviceLabel(device) const type = (device.suggested_type ?? 'generic') as NodeType - const zigbee = isZigbeeType(type) - const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac) + const zwave = isZwaveType(type) + const wireless = isZigbeeType(type) || zwave + const properties = zwave + ? buildZwaveProperties(device) + : isZigbeeType(type) + ? buildZigbeeProperties(device) + : buildMacProperty(device.mac) const nodeData = { label: fallbackLabel, type, ip: device.ip ?? undefined, mac: device.mac ?? undefined, hostname: device.hostname ?? undefined, - status: zigbee ? 'online' : 'unknown', + status: wireless ? 'online' : 'unknown', services: (device.services ?? []) as ServiceInfo[], properties, + // Approve onto the design the user is viewing, not the first design. + design_id: activeDesignId ?? undefined, } const res = await scanApi.approve(device.id, nodeData) const nodeId = res.data.node_id @@ -282,7 +294,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus id: nodeId, type: nodeData.type, position: { x: 400, y: 300 }, - data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) }, + data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) }, }) injectAutoEdges(res.data.edges) const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' @@ -319,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)) @@ -327,7 +339,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const nodeId = deviceToNode[d.id] if (!nodeId) return const type = (d.suggested_type ?? 'generic') as NodeType - const zigbee = isZigbeeType(type) + const zwave = isZwaveType(type) + const wireless = isZigbeeType(type) || zwave addNode({ id: nodeId, type, @@ -338,9 +351,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus ip: d.ip ?? undefined, mac: d.mac ?? undefined, hostname: d.hostname ?? undefined, - status: zigbee ? ('online' as const) : ('unknown' as const), + status: wireless ? ('online' as const) : ('unknown' as const), services: (d.services ?? []) as ServiceInfo[], - properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac), + properties: zwave + ? buildZwaveProperties(d) + : isZigbeeType(type) + ? buildZigbeeProperties(d) + : buildMacProperty(d.mac), }, }) }) @@ -473,6 +490,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/themes.ts b/frontend/src/utils/themes.ts index f2a1d23..892ca46 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -64,6 +64,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, zigbee_router: { border: '#e3b341', icon: '#e3b341' }, zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' }, + zwave_coordinator: { border: '#ff6e00', icon: '#ff6e00' }, + zwave_router: { border: '#e3b341', icon: '#e3b341' }, + zwave_enddevice: { border: '#a855f7', icon: '#a855f7' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' }, @@ -143,6 +146,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' }, zigbee_router: { border: '#fbbf24', icon: '#fbbf24' }, zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' }, + zwave_coordinator: { border: '#fb923c', icon: '#fb923c' }, + zwave_router: { border: '#fbbf24', icon: '#fbbf24' }, + zwave_enddevice: { border: '#c084fc', icon: '#c084fc' }, generic: { border: '#94a3b8', icon: '#94a3b8' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' }, @@ -222,6 +228,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' }, zigbee_router: { border: '#b45309', icon: '#b45309' }, zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' }, + zwave_coordinator: { border: '#ea580c', icon: '#ea580c' }, + zwave_router: { border: '#b45309', icon: '#b45309' }, + zwave_enddevice: { border: '#7c3aed', icon: '#7c3aed' }, generic: { border: '#6b7280', icon: '#6b7280' }, groupRect: { border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' }, @@ -301,6 +310,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' }, zigbee_router: { border: '#ffff00', icon: '#ffff00' }, zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' }, + zwave_coordinator: { border: '#ff8800', icon: '#ff8800' }, + zwave_router: { border: '#ffff00', icon: '#ffff00' }, + zwave_enddevice: { border: '#ff00ff', icon: '#ff00ff' }, generic: { border: '#8888ff', icon: '#8888ff' }, groupRect: { border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' }, @@ -380,6 +392,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' }, zigbee_router: { border: '#66ff33', icon: '#66ff33' }, zigbee_enddevice: { border: '#008822', icon: '#008822' }, + zwave_coordinator: { border: '#33ff66', icon: '#33ff66' }, + zwave_router: { border: '#66ff33', icon: '#66ff33' }, + zwave_enddevice: { border: '#008822', icon: '#008822' }, generic: { border: '#006600', icon: '#006600' }, groupRect: { border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' }, @@ -459,6 +474,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, zigbee_router: { border: '#e3b341', icon: '#e3b341' }, zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' }, + zwave_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, + zwave_router: { border: '#e3b341', icon: '#e3b341' }, + zwave_enddevice: { border: '#a855f7', icon: '#a855f7' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' }, diff --git a/frontend/src/utils/zwaveProperties.ts b/frontend/src/utils/zwaveProperties.ts new file mode 100644 index 0000000..6a47804 --- /dev/null +++ b/frontend/src/utils/zwaveProperties.ts @@ -0,0 +1,21 @@ +import type { NodeProperty, NodeType } from '@/types' + +const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] + +export function isZwaveType(type: NodeType | string | undefined | null): boolean { + return !!type && (ZWAVE_TYPES as string[]).includes(type) +} + +/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel. + * Matches backend `build_zwave_properties` (no LQI — Z-Wave has none). */ +export function buildZwaveProperties(input: { + ieee_address?: string | null + vendor?: string | null + model?: string | null +}): NodeProperty[] { + const props: NodeProperty[] = [] + if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false }) + if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false }) + if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false }) + return props +}