Merge pull request #224 from Pouzor/feat/zwave-scan
feat: Z-Wave network scan via MQTT
This commit is contained in:
@@ -16,8 +16,28 @@ from app.schemas.nodes import NodeCreate
|
|||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||||
from app.services.zigbee_service import build_zigbee_properties
|
from app.services.zigbee_service import build_zigbee_properties
|
||||||
|
from app.services.zwave_service import build_zwave_properties
|
||||||
|
|
||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
_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]]:
|
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||||
@@ -50,6 +70,10 @@ def merge_mac_property(
|
|||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
device_ids: list[str]
|
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]:
|
def _check_port_ranges(v: list[str]) -> list[str]:
|
||||||
@@ -248,9 +272,11 @@ async def bulk_approve_devices(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# Determine target design (use first design as fallback)
|
# Target the design the user is on; fall back to the first design.
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
default_design_id = payload.design_id
|
||||||
default_design_id = first_design.id if first_design else None
|
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(
|
result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
@@ -263,22 +289,22 @@ async def bulk_approve_devices(
|
|||||||
for device in devices:
|
for device in devices:
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node_type = device.suggested_type or "generic"
|
node_type = device.suggested_type or "generic"
|
||||||
is_zigbee = node_type in _ZIGBEE_TYPES
|
is_wireless = _is_wireless(node_type)
|
||||||
node = Node(
|
node = Node(
|
||||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
type=node_type,
|
type=node_type,
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
mac=device.mac,
|
mac=device.mac,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="online" if is_zigbee else "unknown",
|
status="online" if is_wireless else "unknown",
|
||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=_wireless_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if is_zigbee else build_mac_property(device.mac),
|
) if is_wireless else build_mac_property(device.mac),
|
||||||
# Default to ping so the status checker actually polls the new node.
|
# Default to ping so the status checker actually polls the new node.
|
||||||
# Without this the scheduler skips it (check_method NULL → no check).
|
# 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,
|
design_id=default_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
@@ -375,7 +401,7 @@ async def approve_device(
|
|||||||
if device.status != "pending":
|
if device.status != "pending":
|
||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
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);
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
# fall back to whatever the approve payload carried.
|
# fall back to whatever the approve payload carried.
|
||||||
_mac = device.mac or node_data.mac
|
_mac = device.mac or node_data.mac
|
||||||
@@ -385,14 +411,14 @@ async def approve_device(
|
|||||||
ip=node_data.ip,
|
ip=node_data.ip,
|
||||||
mac=_mac,
|
mac=_mac,
|
||||||
hostname=node_data.hostname,
|
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 [],
|
services=node_data.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=_wireless_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
|
) if wireless 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_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||||
check_target=None if _is_zigbee else node_data.check_target,
|
check_target=None if wireless else node_data.check_target,
|
||||||
design_id=node_design_id,
|
design_id=node_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
|
|||||||
@@ -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")
|
||||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
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(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
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(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"])
|
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import ssl
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -20,42 +21,8 @@ _NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
|||||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
_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)
|
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||||
|
|
||||||
|
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
def build_zigbee_properties(
|
def build_zigbee_properties(
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
||||||
|
|
||||||
|
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
||||||
|
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||||
|
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.mqtt_common import request_response, test_connection
|
||||||
|
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
||||||
|
merge_zwave_properties = merge_zigbee_properties
|
||||||
|
|
||||||
|
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
||||||
|
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
||||||
|
|
||||||
|
|
||||||
|
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
||||||
|
"""Map a Z-Wave node's role flags to a homelable node type.
|
||||||
|
|
||||||
|
Controller → coordinator. Mains-powered / routing nodes → router.
|
||||||
|
Everything else (battery sensors, etc.) → end device.
|
||||||
|
"""
|
||||||
|
if raw.get("isControllerNode"):
|
||||||
|
return "zwave_coordinator"
|
||||||
|
if raw.get("isRouting"):
|
||||||
|
return "zwave_router"
|
||||||
|
return "zwave_enddevice"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_label(node_type: str) -> str:
|
||||||
|
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
||||||
|
return {
|
||||||
|
"zwave_coordinator": "Controller",
|
||||||
|
"zwave_router": "Router",
|
||||||
|
"zwave_enddevice": "EndDevice",
|
||||||
|
}.get(node_type, "EndDevice")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
||||||
|
node_id = raw.get("id")
|
||||||
|
if node_id is None:
|
||||||
|
return None
|
||||||
|
ieee = f"zwave-{home_id}-{node_id}"
|
||||||
|
node_type = _zwave_type_to_homelable(raw)
|
||||||
|
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
||||||
|
model = raw.get("productLabel") or raw.get("productDescription") or None
|
||||||
|
vendor = raw.get("manufacturer") or None
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": name,
|
||||||
|
"type": node_type,
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"friendly_name": name,
|
||||||
|
"device_type": _role_label(node_type),
|
||||||
|
"node_id": node_id,
|
||||||
|
"model": model,
|
||||||
|
"vendor": vendor,
|
||||||
|
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
||||||
|
"parent_id": None,
|
||||||
|
"neighbors": raw.get("neighbors") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
||||||
|
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
||||||
|
controller_home = None
|
||||||
|
for entry in raw_nodes:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
home = entry.get("homeId")
|
||||||
|
if home is None:
|
||||||
|
continue
|
||||||
|
if entry.get("isControllerNode"):
|
||||||
|
return str(home)
|
||||||
|
if controller_home is None:
|
||||||
|
controller_home = str(home)
|
||||||
|
return controller_home or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_zwave_nodes(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
||||||
|
|
||||||
|
Expected shape::
|
||||||
|
|
||||||
|
{"success": true, "result": [ {<node>}, ... ]}
|
||||||
|
|
||||||
|
Edges are a strict coordinator → router → end-device tree, derived from
|
||||||
|
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
||||||
|
"""
|
||||||
|
if payload.get("success") is False:
|
||||||
|
raise ValueError("Z-Wave gateway reported failure")
|
||||||
|
|
||||||
|
result = payload.get("result")
|
||||||
|
if result is None:
|
||||||
|
result = []
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
||||||
|
|
||||||
|
home_id = _resolve_home_id(result)
|
||||||
|
|
||||||
|
nodes_list: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
coordinator_id: str | None = None
|
||||||
|
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
||||||
|
id_by_node_id: dict[Any, str] = {}
|
||||||
|
|
||||||
|
for entry in result:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
node = _node_from_zwave(entry, home_id)
|
||||||
|
if node is None or node["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(node["id"])
|
||||||
|
id_by_node_id[node["node_id"]] = node["id"]
|
||||||
|
nodes_list.append(node)
|
||||||
|
if node["type"] == "zwave_coordinator":
|
||||||
|
coordinator_id = node["id"]
|
||||||
|
|
||||||
|
# Translate neighbor lists into candidate edges (only between known nodes).
|
||||||
|
raw_edges: list[dict[str, Any]] = []
|
||||||
|
for node in nodes_list:
|
||||||
|
src = node["id"]
|
||||||
|
for neighbor in node.get("neighbors") or []:
|
||||||
|
tgt = id_by_node_id.get(neighbor)
|
||||||
|
if tgt and tgt != src:
|
||||||
|
raw_edges.append({"source": src, "target": tgt})
|
||||||
|
|
||||||
|
# Build parent_id hierarchy: coordinator → routers → end devices.
|
||||||
|
if coordinator_id:
|
||||||
|
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["type"] == "zwave_router":
|
||||||
|
node["parent_id"] = coordinator_id
|
||||||
|
elif node["type"] == "zwave_enddevice":
|
||||||
|
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||||
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
|
# Final edges = strict parent → child tree (one edge per non-coordinator).
|
||||||
|
edges_list: list[dict[str, Any]] = [
|
||||||
|
{"source": node["parent_id"], "target": node["id"]}
|
||||||
|
for node in nodes_list
|
||||||
|
if node.get("parent_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Drop transient helper keys before returning.
|
||||||
|
for node in nodes_list:
|
||||||
|
node.pop("neighbors", None)
|
||||||
|
node.pop("node_id", None)
|
||||||
|
|
||||||
|
return nodes_list, edges_list
|
||||||
|
|
||||||
|
|
||||||
|
def build_zwave_properties(
|
||||||
|
ieee: str | None,
|
||||||
|
vendor: str | None,
|
||||||
|
model: str | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
||||||
|
|
||||||
|
Z-Wave has no LQI, so that row is omitted. New props default to
|
||||||
|
``visible=False`` — users opt in from the right panel.
|
||||||
|
"""
|
||||||
|
props: list[dict[str, Any]] = []
|
||||||
|
if ieee:
|
||||||
|
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
|
||||||
|
if vendor:
|
||||||
|
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||||
|
if model:
|
||||||
|
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_zwave_network(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
prefix: str = "zwave",
|
||||||
|
gateway_name: str = "zwavejs2mqtt",
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: if the gateway does not respond in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is malformed.
|
||||||
|
"""
|
||||||
|
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||||
|
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||||
|
|
||||||
|
payload = await request_response(
|
||||||
|
mqtt_host=mqtt_host,
|
||||||
|
mqtt_port=mqtt_port,
|
||||||
|
request_topic=request_topic,
|
||||||
|
response_topic=response_topic,
|
||||||
|
request_payload={"args": []},
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
tls=tls,
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parse_zwave_nodes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zwave_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Quick MQTT reachability check for the Z-Wave broker."""
|
||||||
|
return await test_connection(
|
||||||
|
mqtt_host=mqtt_host,
|
||||||
|
mqtt_port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
tls=tls,
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -1357,3 +1357,76 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header
|
|||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""API endpoint tests for /api/v1/zwave/*."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/test-connection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is True
|
||||||
|
assert "success" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
||||||
|
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is False
|
||||||
|
assert "refused" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_NODES = [
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-1",
|
||||||
|
"label": "Controller",
|
||||||
|
"type": "zwave_coordinator",
|
||||||
|
"ieee_address": "zwave-0xh-1",
|
||||||
|
"friendly_name": "Controller",
|
||||||
|
"device_type": "Controller",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-2",
|
||||||
|
"label": "Wall Plug",
|
||||||
|
"type": "zwave_router",
|
||||||
|
"ieee_address": "zwave-0xh-2",
|
||||||
|
"friendly_name": "Wall Plug",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "ZW100",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 2
|
||||||
|
assert len(data["edges"]) == 1
|
||||||
|
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
|
||||||
|
assert coordinator["ieee_address"] == "zwave-0xh-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"prefix": "myzwave",
|
||||||
|
"gateway_name": "gw1",
|
||||||
|
"mqtt_username": "admin",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
mock_fetch.assert_called_once_with(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
prefix="myzwave",
|
||||||
|
gateway_name="gw1",
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
tls=False,
|
||||||
|
tls_insecure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 502
|
||||||
|
assert "broker unreachable" in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = TimeoutError("timed out")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ValueError("malformed response")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = RuntimeError("boom")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_tls": False,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/import-pending
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PENDING_NODES = [
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-1",
|
||||||
|
"label": "Controller",
|
||||||
|
"type": "zwave_coordinator",
|
||||||
|
"ieee_address": "zwave-0xh-1",
|
||||||
|
"friendly_name": "Controller",
|
||||||
|
"device_type": "Controller",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-2",
|
||||||
|
"label": "Wall Plug",
|
||||||
|
"type": "zwave_router",
|
||||||
|
"ieee_address": "zwave-0xh-2",
|
||||||
|
"friendly_name": "Wall Plug",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "ZW100",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-3",
|
||||||
|
"label": "Door Sensor",
|
||||||
|
"type": "zwave_enddevice",
|
||||||
|
"ieee_address": "zwave-0xh-3",
|
||||||
|
"friendly_name": "Door Sensor",
|
||||||
|
"device_type": "EndDevice",
|
||||||
|
"model": "ZW120",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PENDING_EDGES = [
|
||||||
|
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
|
||||||
|
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
run = res.json()
|
||||||
|
assert run["kind"] == "zwave"
|
||||||
|
assert run["status"] == "running"
|
||||||
|
assert run["ranges"] == ["localhost:1883"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
assert result.device_count == 3
|
||||||
|
assert result.pending_created == 2
|
||||||
|
assert result.pending_updated == 0
|
||||||
|
assert result.coordinator is not None
|
||||||
|
assert result.coordinator.ieee_address == "zwave-0xh-1"
|
||||||
|
assert result.coordinator_already_existed is False
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["model"] = "ZW111"
|
||||||
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
assert result.pending_created == 0
|
||||||
|
assert result.pending_updated == 2
|
||||||
|
assert result.coordinator_already_existed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_replaces_links(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import PendingDeviceLink
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
||||||
|
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_sets_coordinator_properties(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
nodes = [dict(n) for n in _PENDING_NODES]
|
||||||
|
nodes[0]["vendor"] = "Aeotec"
|
||||||
|
nodes[0]["model"] = "ZW090"
|
||||||
|
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
|
||||||
|
assert all(p["visible"] is False for p in coord.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
|
approved = Node(
|
||||||
|
label="Wall Plug",
|
||||||
|
type="zwave_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
services=[],
|
||||||
|
properties=[],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
pendings = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert pendings == []
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
orphan = PendingDevice(
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
friendly_name="Wall Plug",
|
||||||
|
suggested_type="zwave_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="approved",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(orphan)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
revived = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert revived.status == "pending"
|
||||||
|
assert result.pending_created == 1
|
||||||
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
hidden = PendingDevice(
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
friendly_name="Wall Plug",
|
||||||
|
suggested_type="zwave_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="hidden",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(hidden)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
still_hidden = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert still_hidden.status == "hidden"
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Unit tests for zwave_service: parser, role mapping, hierarchy builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.zwave_service import (
|
||||||
|
build_zwave_properties,
|
||||||
|
fetch_zwave_network,
|
||||||
|
parse_zwave_nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — real zwavejs2mqtt getNodes shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _node(
|
||||||
|
node_id: int,
|
||||||
|
*,
|
||||||
|
controller: bool = False,
|
||||||
|
routing: bool = False,
|
||||||
|
name: str | None = None,
|
||||||
|
neighbors: list[int] | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
product_label: str | None = None,
|
||||||
|
home_id: str = "0xabcd1234",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": node_id,
|
||||||
|
"homeId": home_id,
|
||||||
|
"isControllerNode": controller,
|
||||||
|
"isRouting": routing,
|
||||||
|
"name": name,
|
||||||
|
"neighbors": neighbors or [],
|
||||||
|
"manufacturer": manufacturer,
|
||||||
|
"productLabel": product_label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(nodes: list[dict[str, Any]], success: bool = True) -> dict[str, Any]:
|
||||||
|
return {"success": success, "result": nodes}
|
||||||
|
|
||||||
|
|
||||||
|
HOME = "0xabcd1234"
|
||||||
|
|
||||||
|
|
||||||
|
def _ieee(node_id: int) -> str:
|
||||||
|
return f"zwave-{HOME}-{node_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Role mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoleMapping:
|
||||||
|
def test_controller_is_coordinator(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_coordinator"
|
||||||
|
assert nodes[0]["device_type"] == "Controller"
|
||||||
|
|
||||||
|
def test_routing_is_router(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(2, routing=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_router"
|
||||||
|
assert nodes[0]["device_type"] == "Router"
|
||||||
|
|
||||||
|
def test_default_is_enddevice(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(3)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_enddevice"
|
||||||
|
assert nodes[0]["device_type"] == "EndDevice"
|
||||||
|
|
||||||
|
def test_controller_wins_over_routing(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True, routing=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_coordinator"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_zwave_nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParse:
|
||||||
|
def test_empty_payload(self) -> None:
|
||||||
|
nodes, edges = parse_zwave_nodes({})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_empty_result(self) -> None:
|
||||||
|
nodes, edges = parse_zwave_nodes(_wrap([]))
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_success_false_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="failure"):
|
||||||
|
parse_zwave_nodes(_wrap([], success=False))
|
||||||
|
|
||||||
|
def test_result_not_list_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a list"):
|
||||||
|
parse_zwave_nodes({"success": True, "result": "oops"})
|
||||||
|
|
||||||
|
def test_missing_id_skipped(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([{"homeId": HOME, "isControllerNode": False}]))
|
||||||
|
assert nodes == []
|
||||||
|
|
||||||
|
def test_ieee_identity_format(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(5, controller=True)]))
|
||||||
|
assert nodes[0]["ieee_address"] == _ieee(5)
|
||||||
|
|
||||||
|
def test_name_fallback(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(7, name="Living Room")]))
|
||||||
|
assert nodes[0]["label"] == "Living Room"
|
||||||
|
assert nodes[0]["friendly_name"] == "Living Room"
|
||||||
|
|
||||||
|
def test_model_and_vendor(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(
|
||||||
|
_wrap([_node(8, manufacturer="Aeotec", product_label="ZW100")])
|
||||||
|
)
|
||||||
|
assert nodes[0]["vendor"] == "Aeotec"
|
||||||
|
assert nodes[0]["model"] == "ZW100"
|
||||||
|
|
||||||
|
def test_lqi_is_none(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(9)]))
|
||||||
|
assert nodes[0]["lqi"] is None
|
||||||
|
|
||||||
|
def test_no_duplicate_nodes(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, routing=True), _node(1, routing=True)]))
|
||||||
|
assert len(nodes) == 1
|
||||||
|
|
||||||
|
def test_helper_keys_stripped(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, neighbors=[2])]))
|
||||||
|
assert "neighbors" not in nodes[0]
|
||||||
|
assert "node_id" not in nodes[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHierarchy:
|
||||||
|
def test_coordinator_router_enddevice_tree(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_node(1, controller=True, neighbors=[2]),
|
||||||
|
_node(2, routing=True, neighbors=[1, 3]),
|
||||||
|
_node(3, neighbors=[2]),
|
||||||
|
])
|
||||||
|
nodes, edges = parse_zwave_nodes(payload)
|
||||||
|
by_id = {n["id"]: n for n in nodes}
|
||||||
|
assert by_id[_ieee(2)]["parent_id"] == _ieee(1)
|
||||||
|
assert by_id[_ieee(3)]["parent_id"] == _ieee(2)
|
||||||
|
pairs = {(e["source"], e["target"]) for e in edges}
|
||||||
|
assert pairs == {(_ieee(1), _ieee(2)), (_ieee(2), _ieee(3))}
|
||||||
|
|
||||||
|
def test_enddevice_without_router_falls_back_to_coordinator(self) -> None:
|
||||||
|
payload = _wrap([_node(1, controller=True), _node(3, neighbors=[])])
|
||||||
|
nodes, _ = parse_zwave_nodes(payload)
|
||||||
|
end = next(n for n in nodes if n["id"] == _ieee(3))
|
||||||
|
assert end["parent_id"] == _ieee(1)
|
||||||
|
|
||||||
|
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_node(1, controller=True, neighbors=[3]),
|
||||||
|
_node(3, neighbors=[1]),
|
||||||
|
])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert all(e["target"] != _ieee(1) for e in edges)
|
||||||
|
|
||||||
|
def test_neighbor_to_unknown_node_dropped(self) -> None:
|
||||||
|
payload = _wrap([_node(1, controller=True, neighbors=[99])])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_no_coordinator_means_no_edges(self) -> None:
|
||||||
|
payload = _wrap([_node(2, routing=True, neighbors=[3]), _node(3, neighbors=[2])])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_zwave_properties
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBuildProperties:
|
||||||
|
def test_all_fields(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "Aeotec", "ZW100")
|
||||||
|
keys = {p["key"]: p["value"] for p in props}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-x-1", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||||
|
|
||||||
|
def test_omits_empty(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", None, None)
|
||||||
|
assert [p["key"] for p in props] == ["Z-Wave ID"]
|
||||||
|
|
||||||
|
def test_defaults_hidden(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||||
|
assert all(p["visible"] is False for p in props)
|
||||||
|
|
||||||
|
def test_no_lqi_row(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||||
|
assert all(p["key"] != "LQI" for p in props)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_zwave_network (mocked MQTT round-trip via mqtt_common)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_RESPONSE_TOPIC = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
||||||
|
|
||||||
|
_SAMPLE_PAYLOAD = {
|
||||||
|
"success": True,
|
||||||
|
"result": [
|
||||||
|
{"id": 1, "homeId": HOME, "isControllerNode": True, "name": "Controller"},
|
||||||
|
{"id": 2, "homeId": HOME, "isRouting": True, "name": "Wall Plug", "neighbors": [1]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_zwave_network_success() -> None:
|
||||||
|
class _FakeMessage:
|
||||||
|
topic = _RESPONSE_TOPIC
|
||||||
|
payload = json.dumps(_SAMPLE_PAYLOAD).encode()
|
||||||
|
_yielded = False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._yielded:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._yielded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, _t: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish(self, _t: str, _p: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self):
|
||||||
|
return _FakeMessage()
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
nodes, edges = await fetch_zwave_network(mqtt_host="localhost", mqtt_port=1883)
|
||||||
|
|
||||||
|
assert any(n["type"] == "zwave_coordinator" for n in nodes)
|
||||||
|
assert any(n["type"] == "zwave_router" for n in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_zwave_network_connection_error() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await fetch_zwave_network(mqtt_host="bad", mqtt_port=1883)
|
||||||
@@ -22,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
|
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
@@ -39,6 +40,7 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
|
|||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/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 = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -77,6 +79,7 @@ export default function App() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
// Returns true on success, false on failure — the design-switch effect relies
|
// Returns true on success, false on failure — the design-switch effect relies
|
||||||
@@ -541,6 +544,50 @@ export default function App() {
|
|||||||
markUnsaved()
|
markUnsaved()
|
||||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
|
const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 170
|
||||||
|
const SPACING_Y = 100
|
||||||
|
zwaveNodes.forEach((zn, i) => {
|
||||||
|
const id = zn.id
|
||||||
|
const col = i % COLS
|
||||||
|
const row = Math.floor(i / COLS)
|
||||||
|
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
||||||
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: zn.type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: zn.friendly_name,
|
||||||
|
type: zn.type as NodeData['type'],
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: [],
|
||||||
|
...(zn.model ? { os: zn.model } : {}),
|
||||||
|
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Add IoT edges between Z-Wave devices: parent bottom -> child top
|
||||||
|
zwaveEdges.forEach((ze) => {
|
||||||
|
onConnect({
|
||||||
|
source: ze.source,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: ze.target,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
const importedIds = new Set(zwaveNodes.map((zn) => zn.id))
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||||
|
selectedNodeIds: Array.from(importedIds),
|
||||||
|
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||||
|
}))
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -615,6 +662,7 @@ export default function App() {
|
|||||||
onAddText={() => setAddTextOpen(true)}
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
|
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||||
@@ -733,6 +781,17 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZwaveImportModal
|
||||||
|
open={zwaveImportOpen}
|
||||||
|
onClose={() => setZwaveImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZwaveAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
toast.success('Z-Wave import started — check Scan History for results')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<ScanHistoryModal
|
<ScanHistoryModal
|
||||||
open={scanHistoryOpen}
|
open={scanHistoryOpen}
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ describe('api/client', () => {
|
|||||||
mod.scanApi.ignore('d1')
|
mod.scanApi.ignore('d1')
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
||||||
mod.scanApi.bulkApprove(['a', 'b'])
|
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'])
|
mod.scanApi.bulkHide(['a'])
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
||||||
mod.scanApi.restore('d1')
|
mod.scanApi.restore('d1')
|
||||||
@@ -217,4 +219,14 @@ describe('api/client', () => {
|
|||||||
mod.zigbeeApi.importToPending(cfg)
|
mod.zigbeeApi.importToPending(cfg)
|
||||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const scanApi = {
|
|||||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
bulkApprove: (ids: string[]) =>
|
bulkApprove: (ids: string[], designId?: string | null) =>
|
||||||
api.post<{
|
api.post<{
|
||||||
approved: number
|
approved: number
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
@@ -89,7 +89,7 @@ export const scanApi = {
|
|||||||
edges_created: number
|
edges_created: number
|
||||||
edges: { id: string; source: string; target: string }[]
|
edges: { id: string; source: string; target: string }[]
|
||||||
skipped: number
|
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 }),
|
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`),
|
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 }),
|
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
|
error: string | null
|
||||||
}>('/zigbee/import-pending', data),
|
}>('/zigbee/import-pending', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const zwaveApi = {
|
||||||
|
testConnection: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/zwave/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
prefix?: string
|
||||||
|
gateway_name?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
nodes: import('@/components/zwave/types').ZwaveNode[]
|
||||||
|
edges: import('@/components/zwave/types').ZwaveEdge[]
|
||||||
|
device_count: number
|
||||||
|
}>('/zwave/import', data),
|
||||||
|
|
||||||
|
importToPending: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
prefix?: string
|
||||||
|
gateway_name?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/zwave/import-pending', data),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { nodeTypes } from '../nodeTypes'
|
||||||
|
|
||||||
|
describe('nodeTypes registry', () => {
|
||||||
|
it('registers a component for every wireless mesh node type', () => {
|
||||||
|
// Regression: zwave_* types were missing, so React Flow fell back to the
|
||||||
|
// default (unstyled) node — no icon, no accent. (Zigbee covered too.)
|
||||||
|
for (const t of [
|
||||||
|
'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||||
|
'zwave_coordinator', 'zwave_router', 'zwave_enddevice',
|
||||||
|
]) {
|
||||||
|
expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
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,
|
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Ne
|
|||||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|
||||||
|
// Z-Wave node types
|
||||||
|
export const ZwaveCoordinatorNode = (props: N) => <BaseNode {...props} icon={RadioTower} />
|
||||||
|
export const ZwaveRouterNode = (props: N) => <BaseNode {...props} icon={Share2} />
|
||||||
|
export const ZwaveEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|
||||||
// Electrical node types
|
// Electrical node types
|
||||||
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
||||||
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||||
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||||
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||||
|
ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
|
||||||
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
||||||
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
||||||
LightNode, MeterNode, TransformerNode, LoadNode,
|
LightNode, MeterNode, TransformerNode, LoadNode,
|
||||||
@@ -39,6 +40,9 @@ export const nodeTypes = {
|
|||||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
zigbee_router: ZigbeeRouterNode,
|
zigbee_router: ZigbeeRouterNode,
|
||||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
zwave_coordinator: ZwaveCoordinatorNode,
|
||||||
|
zwave_router: ZwaveRouterNode,
|
||||||
|
zwave_enddevice: ZwaveEndDeviceNode,
|
||||||
grid: GridNode,
|
grid: GridNode,
|
||||||
ups: UpsNode,
|
ups: UpsNode,
|
||||||
battery: BatteryNode,
|
battery: BatteryNode,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { Fragment, useState, useEffect, useCallback } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
Radio, Zap, Lightbulb,
|
Radio, Zap, Lightbulb, RadioTower, Share2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
@@ -17,13 +17,16 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } 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[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
'generic',
|
{ 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']
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
|
||||||
@@ -34,6 +37,7 @@ const NODE_ICONS: Record<string, LucideIcon> = {
|
|||||||
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
|
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
|
||||||
docker_host: Anchor, docker_container: Package,
|
docker_host: Anchor, docker_container: Package,
|
||||||
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||||
|
zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb,
|
||||||
generic: Circle,
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
min={0}
|
min={0}
|
||||||
step={10}
|
step={10}
|
||||||
value={style.width}
|
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]"
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
min={0}
|
min={0}
|
||||||
step={10}
|
step={10}
|
||||||
value={style.height}
|
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]"
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
edges: { ...customStyle.edges },
|
edges: { ...customStyle.edges },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const handleOpen = (isOpen: boolean) => {
|
// Reset the draft to the saved customStyle whenever the modal is (re)opened.
|
||||||
if (isOpen) {
|
// The parent keeps this component mounted and only toggles `open`, so Radix's
|
||||||
// Reset draft to current saved customStyle on open
|
// 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 } })
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
setSelection(null)
|
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 =>
|
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
||||||
@@ -363,34 +375,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
|
|
||||||
{/* Type list */}
|
{/* Type list */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
|
{tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
|
||||||
const Icon = NODE_ICONS[t] ?? Circle
|
<Fragment key={group.label}>
|
||||||
const style = draft.nodes[t]
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#8b949e]/60">
|
||||||
const isSelected = selection?.kind === 'node' && selection.type === t
|
{group.label}
|
||||||
const swatchColor = style
|
</div>
|
||||||
? applyOpacity(style.borderColor, style.borderOpacity)
|
{group.types.map((t) => {
|
||||||
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
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 (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelection({ kind: 'node', type: t })}
|
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#21262d' : 'transparent',
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<Icon size={13} />
|
||||||
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ background: swatchColor }}
|
style={{ background: swatchColor }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||||
const style = draft.edges[t]
|
const style = draft.edges[t]
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
import type { NodeType, ServiceInfo } from '@/types'
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||||
|
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||||
import { buildMacProperty } from '@/utils/macProperty'
|
import { buildMacProperty } from '@/utils/macProperty'
|
||||||
|
|
||||||
interface PendingDevicesModalProps {
|
interface PendingDevicesModalProps {
|
||||||
@@ -67,11 +69,13 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
|
|||||||
generic: Circle,
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||||
type StatusFilter = 'pending' | 'hidden'
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
|
||||||
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
if (d.discovery_source === 'zwave') return 'zwave'
|
||||||
|
if (d.discovery_source === 'zigbee') return 'zigbee'
|
||||||
|
if (d.ieee_address) return 'zigbee'
|
||||||
return 'ip'
|
return 'ip'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
// Optionally restrict to devices that have at least one detected service.
|
// Optionally restrict to devices that have at least one detected service.
|
||||||
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const activeDesignId = useDesignStore((s) => s.activeDesignId)
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -264,17 +269,24 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
try {
|
try {
|
||||||
const fallbackLabel = deviceLabel(device)
|
const fallbackLabel = deviceLabel(device)
|
||||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
const zigbee = isZigbeeType(type)
|
const zwave = isZwaveType(type)
|
||||||
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
|
const wireless = isZigbeeType(type) || zwave
|
||||||
|
const properties = zwave
|
||||||
|
? buildZwaveProperties(device)
|
||||||
|
: isZigbeeType(type)
|
||||||
|
? buildZigbeeProperties(device)
|
||||||
|
: buildMacProperty(device.mac)
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
label: fallbackLabel,
|
label: fallbackLabel,
|
||||||
type,
|
type,
|
||||||
ip: device.ip ?? undefined,
|
ip: device.ip ?? undefined,
|
||||||
mac: device.mac ?? undefined,
|
mac: device.mac ?? undefined,
|
||||||
hostname: device.hostname ?? undefined,
|
hostname: device.hostname ?? undefined,
|
||||||
status: zigbee ? 'online' : 'unknown',
|
status: wireless ? 'online' : 'unknown',
|
||||||
services: (device.services ?? []) as ServiceInfo[],
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
properties,
|
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 res = await scanApi.approve(device.id, nodeData)
|
||||||
const nodeId = res.data.node_id
|
const nodeId = res.data.node_id
|
||||||
@@ -282,7 +294,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: nodeData.type,
|
type: nodeData.type,
|
||||||
position: { x: 400, y: 300 },
|
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)
|
injectAutoEdges(res.data.edges)
|
||||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
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]
|
const ids = [...selectedIds]
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
try {
|
try {
|
||||||
const res = await scanApi.bulkApprove(ids)
|
const res = await scanApi.bulkApprove(ids, activeDesignId)
|
||||||
const deviceToNode: Record<string, string> = {}
|
const deviceToNode: Record<string, string> = {}
|
||||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
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]
|
const nodeId = deviceToNode[d.id]
|
||||||
if (!nodeId) return
|
if (!nodeId) return
|
||||||
const type = (d.suggested_type ?? 'generic') as NodeType
|
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||||
const zigbee = isZigbeeType(type)
|
const zwave = isZwaveType(type)
|
||||||
|
const wireless = isZigbeeType(type) || zwave
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type,
|
type,
|
||||||
@@ -338,9 +351,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
ip: d.ip ?? undefined,
|
ip: d.ip ?? undefined,
|
||||||
mac: d.mac ?? undefined,
|
mac: d.mac ?? undefined,
|
||||||
hostname: d.hostname ?? 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[],
|
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
|
Zigbee
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('zwave')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zwave' ? 'bg-[#ff6e00]/20 text-[#ff6e00]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Z-Wave
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
@@ -631,8 +654,11 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
const source = inferSource(device)
|
const source = inferSource(device)
|
||||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
const label = deviceLabel(device)
|
const label = deviceLabel(device)
|
||||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
const sourceLabel =
|
||||||
|
source === 'zigbee' ? 'ZIGBEE'
|
||||||
|
: source === 'zwave' ? 'Z-WAVE'
|
||||||
|
: (device.discovery_source ?? 'IP').toUpperCase()
|
||||||
const services = device.services ?? []
|
const services = device.services ?? []
|
||||||
const visibleServices = services.slice(0, 4)
|
const visibleServices = services.slice(0, 4)
|
||||||
const moreServices = services.length - visibleServices.length
|
const moreServices = services.length - visibleServices.length
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
|
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
@@ -22,7 +22,18 @@ interface ScanHistoryModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type KindFilter = 'all' | 'ip' | 'zigbee'
|
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||||
|
|
||||||
|
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
||||||
|
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
|
||||||
|
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_META = {
|
||||||
|
ip: { label: 'IP', color: '#a855f7' },
|
||||||
|
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
||||||
|
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
||||||
|
} as const
|
||||||
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
|
||||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
@@ -37,6 +48,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
|||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
{ key: 'ip', label: 'IP' },
|
{ key: 'ip', label: 'IP' },
|
||||||
{ key: 'zigbee', label: 'Zigbee' },
|
{ key: 'zigbee', label: 'Zigbee' },
|
||||||
|
{ key: 'zwave', label: 'Z-Wave' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function statusColor(s: string): string {
|
function statusColor(s: string): string {
|
||||||
@@ -90,8 +102,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
}
|
}
|
||||||
if (prev?.status === 'running' && run.status === 'done') {
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
if (run.kind === 'zigbee') {
|
if (run.kind === 'zigbee' || run.kind === 'zwave') {
|
||||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
|
||||||
|
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
}
|
}
|
||||||
useCanvasStore.getState().notifyScanDeviceFound()
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
}
|
}
|
||||||
@@ -143,7 +156,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = runs.filter((r) => {
|
const filtered = runs.filter((r) => {
|
||||||
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
|
const k = runKind(r.kind)
|
||||||
if (kindFilter !== 'all' && k !== kindFilter) return false
|
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
return true
|
return true
|
||||||
@@ -216,7 +229,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const isZigbee = r.kind === 'zigbee'
|
const kind = runKind(r.kind)
|
||||||
|
const meta = KIND_META[kind]
|
||||||
|
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -225,12 +240,10 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
style={isZigbee
|
style={{ background: `${meta.color}22`, color: meta.color }}
|
||||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
|
||||||
: { background: '#a855f722', color: '#a855f7' }}
|
|
||||||
>
|
>
|
||||||
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
|
<KindIcon size={10} />
|
||||||
{isZigbee ? 'Zigbee' : 'IP'}
|
{meta.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||||
{r.devices_found} found
|
{r.devices_found} found
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('Zigbee')).toBeDefined()
|
||||||
|
expect(screen.getByText('Z-Wave')).toBeDefined()
|
||||||
|
// A Z-Wave node type is selectable from its category.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
|
||||||
|
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting a node type opens the node editor', () => {
|
it('selecting a node type opens the node editor', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
@@ -124,4 +134,23 @@ describe('CustomStyleModal', () => {
|
|||||||
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
||||||
|
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||||
|
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||||
|
const { rerender } = render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const widthInput = screen.getAllByRole('spinbutton')[0]
|
||||||
|
fireEvent.change(widthInput, { target: { value: '250' } })
|
||||||
|
expect((widthInput as HTMLInputElement).value).toBe('250')
|
||||||
|
|
||||||
|
// Cancel = parent flips open → false, then later → true again.
|
||||||
|
rerender(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||||
|
rerender(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const reopenedWidth = screen.getAllByRole('spinbutton')[0]
|
||||||
|
// Draft was reset to saved style (default width 0) — edit did not leak.
|
||||||
|
expect((reopenedWidth as HTMLInputElement).value).toBe('0')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ const DEVICE_ZIGBEE = {
|
|||||||
discovered_at: '2026-01-02T00:00:00Z',
|
discovered_at: '2026-01-02T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_ZWAVE = {
|
||||||
|
id: 'dev-c',
|
||||||
|
ip: null,
|
||||||
|
hostname: null,
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'zwave_router',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'zwave',
|
||||||
|
ieee_address: 'zwave-0xh-2',
|
||||||
|
friendly_name: 'wall-plug',
|
||||||
|
vendor: 'Aeotec',
|
||||||
|
model: 'ZW100',
|
||||||
|
discovered_at: '2026-01-03T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
@@ -129,6 +146,22 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows source chip Z-WAVE for zwave device', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (zwave only)', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('filters by suggested type', async () => {
|
it('filters by suggested type', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -180,7 +213,7 @@ describe('PendingDevicesModal', () => {
|
|||||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||||
@@ -257,7 +290,7 @@ describe('PendingDevicesModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
fireEvent.keyDown(window, { key: 'Enter' })
|
fireEvent.keyDown(window, { key: 'Enter' })
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
|
||||||
expect(mockBulkRestore).not.toHaveBeenCalled()
|
expect(mockBulkRestore).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ const ZIGBEE_RUN = {
|
|||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZWAVE_RUN = {
|
||||||
|
id: 'run-5',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'zwave',
|
||||||
|
ranges: [],
|
||||||
|
devices_found: 5,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
function renderModal() {
|
function renderModal() {
|
||||||
return render(
|
return render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -155,4 +166,14 @@ describe('ScanHistoryModal', () => {
|
|||||||
expect(screen.getByText('7 found')).toBeDefined()
|
expect(screen.getByText('7 found')).toBeDefined()
|
||||||
expect(screen.queryByText('3 found')).toBeNull()
|
expect(screen.queryByText('3 found')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters by zwave kind', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZWAVE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||||
|
// Only the zwave run (5 found) remains
|
||||||
|
expect(screen.getByText('5 found')).toBeDefined()
|
||||||
|
expect(screen.queryByText('3 found')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -25,13 +25,14 @@ interface SidebarProps {
|
|||||||
onAddText: () => void
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
|
onZwaveImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onOpenHistory: () => void
|
onOpenHistory: () => void
|
||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||||
@@ -217,6 +218,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
|
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const defaultProps = {
|
|||||||
onAddText: vi.fn(),
|
onAddText: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
onZigbeeImport: vi.fn(),
|
onZigbeeImport: vi.fn(),
|
||||||
|
onZwaveImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onOpenSettings: vi.fn(),
|
onOpenSettings: vi.fn(),
|
||||||
onOpenHistory: vi.fn(),
|
onOpenHistory: vi.fn(),
|
||||||
@@ -184,6 +185,12 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls onZwaveImport when Z-Wave Import is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Z-Wave Import'))
|
||||||
|
expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onSave when Save Canvas is clicked', () => {
|
it('calls onSave when Save Canvas is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Save Canvas'))
|
fireEvent.click(screen.getByText('Save Canvas'))
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { RadioTower, Share2, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { zwaveApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ZwaveNode, ZwaveEdge } from './types'
|
||||||
|
|
||||||
|
interface ZwaveImportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
|
||||||
|
onPendingImported?: (
|
||||||
|
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportMode = 'pending' | 'canvas'
|
||||||
|
|
||||||
|
const ACCENT = '#ff6e00'
|
||||||
|
|
||||||
|
interface ConnectionForm {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: string
|
||||||
|
mqtt_username: string
|
||||||
|
mqtt_password: string
|
||||||
|
prefix: string
|
||||||
|
gateway_name: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
mqtt_tls_insecure: boolean
|
||||||
|
port_user_edited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ConnectionForm = {
|
||||||
|
mqtt_host: '',
|
||||||
|
mqtt_port: '1883',
|
||||||
|
mqtt_username: '',
|
||||||
|
mqtt_password: '',
|
||||||
|
prefix: 'zwave',
|
||||||
|
gateway_name: 'zwavejs2mqtt',
|
||||||
|
mqtt_tls: false,
|
||||||
|
mqtt_tls_insecure: false,
|
||||||
|
port_user_edited: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_TYPE_ICON = {
|
||||||
|
zwave_coordinator: RadioTower,
|
||||||
|
zwave_router: Share2,
|
||||||
|
zwave_enddevice: Cpu,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_LABEL = {
|
||||||
|
zwave_coordinator: 'Controller',
|
||||||
|
zwave_router: 'Router',
|
||||||
|
zwave_enddevice: 'End Device',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR = {
|
||||||
|
zwave_coordinator: '#ff6e00',
|
||||||
|
zwave_router: '#39d353',
|
||||||
|
zwave_enddevice: '#e3b341',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
|
||||||
|
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||||
|
const [connectionMsg, setConnectionMsg] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [devices, setDevices] = useState<ZwaveNode[]>([])
|
||||||
|
const [edges, setEdges] = useState<ZwaveEdge[]>([])
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||||
|
|
||||||
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[field]: value,
|
||||||
|
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleTls = (next: boolean) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const port = f.port_user_edited
|
||||||
|
? f.mqtt_port
|
||||||
|
: next
|
||||||
|
? '8883'
|
||||||
|
: '1883'
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
mqtt_tls: next,
|
||||||
|
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||||
|
mqtt_port: port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
prefix: form.prefix.trim() || 'zwave',
|
||||||
|
gateway_name: form.gateway_name.trim() || 'zwavejs2mqtt',
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
try {
|
||||||
|
const res = await zwaveApi.testConnection({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
if (res.data.connected) {
|
||||||
|
setConnectionStatus('ok')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg('Request failed — check broker address')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractError = (err: unknown): string | undefined => {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchDevices = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (importMode === 'pending') {
|
||||||
|
await zwaveApi.importToPending(buildPayload())
|
||||||
|
toast.success('Z-Wave import started — track progress in Scan History')
|
||||||
|
onPendingImported?.(null)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const res = await zwaveApi.importNetwork(buildPayload())
|
||||||
|
setDevices(res.data.nodes)
|
||||||
|
setEdges(res.data.edges)
|
||||||
|
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||||
|
if (res.data.device_count === 0) {
|
||||||
|
toast.info('No Z-Wave devices found')
|
||||||
|
} else {
|
||||||
|
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(extractError(err) ?? 'Failed to fetch Z-Wave devices')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCanvas = () => {
|
||||||
|
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||||
|
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||||
|
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
onAddToCanvas(selectedDevices, selectedEdges)
|
||||||
|
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDevices([])
|
||||||
|
setEdges([])
|
||||||
|
setChecked(new Set())
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
setConnectionMsg('')
|
||||||
|
setImportMode('pending')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDevices = {
|
||||||
|
zwave_coordinator: devices.filter((d) => d.type === 'zwave_coordinator'),
|
||||||
|
zwave_router: devices.filter((d) => d.type === 'zwave_router'),
|
||||||
|
zwave_enddevice: devices.filter((d) => d.type === 'zwave_enddevice'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<RadioTower size={16} style={{ color: ACCENT }} />
|
||||||
|
Z-Wave Import
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||||
|
{/* Connection Form */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_host}
|
||||||
|
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||||
|
placeholder="192.168.1.x or mqtt.local"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_port}
|
||||||
|
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||||
|
placeholder="1883"
|
||||||
|
type="number"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">MQTT Prefix</Label>
|
||||||
|
<Input
|
||||||
|
value={form.prefix}
|
||||||
|
onChange={(e) => updateField('prefix', e.target.value)}
|
||||||
|
placeholder="zwave"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Gateway Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.gateway_name}
|
||||||
|
onChange={(e) => updateField('gateway_name', e.target.value)}
|
||||||
|
placeholder="zwavejs2mqtt"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_username}
|
||||||
|
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||||
|
placeholder="mqtt_user"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_password}
|
||||||
|
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls}
|
||||||
|
onChange={(e) => toggleTls(e.target.checked)}
|
||||||
|
className="w-3 h-3 cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Use TLS (port 8883)
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||||
|
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls_insecure}
|
||||||
|
disabled={!form.mqtt_tls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
Skip cert verify (self-signed only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
{connectionStatus !== 'idle' && (
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||||
|
connectionStatus === 'ok'
|
||||||
|
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||||
|
: connectionStatus === 'fail'
|
||||||
|
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||||
|
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||||
|
}`}>
|
||||||
|
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||||
|
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||||
|
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-muted-foreground">Send devices to:</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zwave-import-mode"
|
||||||
|
checked={importMode === 'pending'}
|
||||||
|
onChange={() => setImportMode('pending')}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Pending section
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zwave-import-mode"
|
||||||
|
checked={importMode === 'canvas'}
|
||||||
|
onChange={() => setImportMode('canvas')}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Canvas directly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={connectionStatus === 'testing' || loading}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'testing'
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <CheckCircle2 size={13} />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
style={{ background: ACCENT, color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleFetchDevices}
|
||||||
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <RadioTower size={13} />}
|
||||||
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
Make sure the gateway name matches your Z-Wave JS UI configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device List */}
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.size === devices.length}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="w-3 h-3 cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
title="Select all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Devices ({checked.size}/{devices.length} selected)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][])
|
||||||
|
.filter(([, group]) => group.length > 0)
|
||||||
|
.map(([type, group]) => {
|
||||||
|
const Icon = DEVICE_TYPE_ICON[type]
|
||||||
|
const color = DEVICE_TYPE_COLOR[type]
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Icon size={11} style={{ color }} />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||||
|
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||||
|
checked.has(device.id)
|
||||||
|
? 'bg-[#21262d] border-[#ff6e00]/40'
|
||||||
|
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCheck(device.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.has(device.id)}
|
||||||
|
onChange={() => toggleCheck(device.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||||
|
{(device.model || device.vendor) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddToCanvas}
|
||||||
|
disabled={checked.size === 0}
|
||||||
|
style={{ background: ACCENT, color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add {checked.size} to Canvas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ZwaveImportModal } from '../ZwaveImportModal'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
zwaveApi: {
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
importNetwork: vi.fn(),
|
||||||
|
importToPending: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
|
||||||
|
import { zwaveApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddToCanvas: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleNodes = [
|
||||||
|
{
|
||||||
|
id: 'zwave-0xh-1',
|
||||||
|
label: 'Controller',
|
||||||
|
type: 'zwave_coordinator' as const,
|
||||||
|
ieee_address: 'zwave-0xh-1',
|
||||||
|
friendly_name: 'Controller',
|
||||||
|
device_type: 'Controller',
|
||||||
|
model: null,
|
||||||
|
vendor: null,
|
||||||
|
lqi: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zwave-0xh-2',
|
||||||
|
label: 'Wall Plug',
|
||||||
|
type: 'zwave_router' as const,
|
||||||
|
ieee_address: 'zwave-0xh-2',
|
||||||
|
friendly_name: 'Wall Plug',
|
||||||
|
device_type: 'Router',
|
||||||
|
model: 'ZW100',
|
||||||
|
vendor: 'Aeotec',
|
||||||
|
lqi: null,
|
||||||
|
parent_id: 'zwave-0xh-1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZwaveImportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(zwaveApi.testConnection).mockReset()
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockReset()
|
||||||
|
vi.mocked(zwaveApi.importToPending).mockReset()
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
vi.mocked(toast.info).mockReset()
|
||||||
|
defaultProps.onClose.mockReset()
|
||||||
|
defaultProps.onAddToCanvas.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ZwaveImportModal {...defaultProps} open={false} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the modal with prefix and gateway fields when open', () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Z-Wave Import')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zwave')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when testing connection without a host', async () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||||
|
})
|
||||||
|
expect(zwaveApi.testConnection).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success status when connection test passes', async () => {
|
||||||
|
vi.mocked(zwaveApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: true, message: 'Connection successful' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectCanvasMode = () => {
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetches devices and renders them grouped by type', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Controller')).toBeDefined()
|
||||||
|
expect(screen.getByText('Wall Plug')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes prefix and gateway_name to importNetwork', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: [], edges: [], device_count: 0 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '10.0.0.5' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled())
|
||||||
|
const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0]
|
||||||
|
expect(payload.prefix).toBe('myzw')
|
||||||
|
expect(payload.gateway_name).toBe('gw1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports to pending by default and notifies parent', async () => {
|
||||||
|
vi.mocked(zwaveApi.importToPending).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'zwave',
|
||||||
|
ranges: ['192.168.1.100:1883'],
|
||||||
|
devices_found: 0,
|
||||||
|
started_at: '2026-01-01T00:00:00Z',
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
const onPendingImported = vi.fn()
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(zwaveApi.importToPending).toHaveBeenCalled()
|
||||||
|
expect(onPendingImported).toHaveBeenCalled()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Controller'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Shared Z-Wave type definitions for the frontend. */
|
||||||
|
|
||||||
|
export interface ZwaveNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice'
|
||||||
|
ieee_address: string
|
||||||
|
friendly_name: string
|
||||||
|
device_type: string
|
||||||
|
model?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
lqi?: number | null
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveImportResponse {
|
||||||
|
nodes: ZwaveNode[]
|
||||||
|
edges: ZwaveEdge[]
|
||||||
|
device_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveTestConnectionRequest {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveTestConnectionResponse {
|
||||||
|
connected: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ export type NodeType =
|
|||||||
| 'zigbee_coordinator'
|
| 'zigbee_coordinator'
|
||||||
| 'zigbee_router'
|
| 'zigbee_router'
|
||||||
| 'zigbee_enddevice'
|
| 'zigbee_enddevice'
|
||||||
|
| 'zwave_coordinator'
|
||||||
|
| 'zwave_router'
|
||||||
|
| 'zwave_enddevice'
|
||||||
| 'grid'
|
| 'grid'
|
||||||
| 'ups'
|
| 'ups'
|
||||||
| 'battery'
|
| 'battery'
|
||||||
@@ -187,6 +190,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
zigbee_coordinator: 'Zigbee Coordinator',
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
zigbee_router: 'Zigbee Router',
|
zigbee_router: 'Zigbee Router',
|
||||||
zigbee_enddevice: 'Zigbee End Device',
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
|
zwave_coordinator: 'Z-Wave Controller',
|
||||||
|
zwave_router: 'Z-Wave Router',
|
||||||
|
zwave_enddevice: 'Z-Wave End Device',
|
||||||
grid: 'Grid Connection',
|
grid: 'Grid Connection',
|
||||||
ups: 'UPS',
|
ups: 'UPS',
|
||||||
battery: 'Battery',
|
battery: 'Battery',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { isZwaveType, buildZwaveProperties } from '../zwaveProperties'
|
||||||
|
|
||||||
|
describe('isZwaveType', () => {
|
||||||
|
it('returns true for zwave types', () => {
|
||||||
|
expect(isZwaveType('zwave_coordinator')).toBe(true)
|
||||||
|
expect(isZwaveType('zwave_router')).toBe(true)
|
||||||
|
expect(isZwaveType('zwave_enddevice')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for non-zwave types', () => {
|
||||||
|
expect(isZwaveType('zigbee_router')).toBe(false)
|
||||||
|
expect(isZwaveType('server')).toBe(false)
|
||||||
|
expect(isZwaveType(undefined)).toBe(false)
|
||||||
|
expect(isZwaveType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildZwaveProperties', () => {
|
||||||
|
it('builds Z-Wave ID / Vendor / Model rows, all hidden', () => {
|
||||||
|
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: 'Aeotec', model: 'ZW100' })
|
||||||
|
expect(props).toEqual([
|
||||||
|
{ key: 'Z-Wave ID', value: 'zwave-0xh-2', icon: null, visible: false },
|
||||||
|
{ key: 'Vendor', value: 'Aeotec', icon: null, visible: false },
|
||||||
|
{ key: 'Model', value: 'ZW100', icon: null, visible: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits empty fields', () => {
|
||||||
|
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: null, model: undefined })
|
||||||
|
expect(props.map((p) => p.key)).toEqual(['Z-Wave ID'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never adds an LQI row', () => {
|
||||||
|
const props = buildZwaveProperties({ ieee_address: 'x', vendor: 'v', model: 'm' })
|
||||||
|
expect(props.some((p) => p.key === 'LQI')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
// Security & Auth
|
// Security & Auth
|
||||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||||
// Automation & IoT
|
// 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
|
// Smart Home / Sensors
|
||||||
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
|
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
|
||||||
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
|
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
|
||||||
@@ -178,6 +178,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
|||||||
zigbee_coordinator: Radio,
|
zigbee_coordinator: Radio,
|
||||||
zigbee_router: Zap,
|
zigbee_router: Zap,
|
||||||
zigbee_enddevice: Lightbulb,
|
zigbee_enddevice: Lightbulb,
|
||||||
|
zwave_coordinator: RadioTower,
|
||||||
|
zwave_router: Share2,
|
||||||
|
zwave_enddevice: Lightbulb,
|
||||||
generic: Circle,
|
generic: Circle,
|
||||||
group: Circle,
|
group: Circle,
|
||||||
groupRect: Circle,
|
groupRect: Circle,
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||||
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||||
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
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' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
@@ -143,6 +146,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
|
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
|
||||||
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
|
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
|
||||||
zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' },
|
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' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
@@ -222,6 +228,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
|
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
|
||||||
zigbee_router: { border: '#b45309', icon: '#b45309' },
|
zigbee_router: { border: '#b45309', icon: '#b45309' },
|
||||||
zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
|
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' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||||
group: { border: '#0284c7', icon: '#0284c7' },
|
group: { border: '#0284c7', icon: '#0284c7' },
|
||||||
@@ -301,6 +310,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
|
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
|
||||||
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
|
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
|
||||||
zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
|
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' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||||
group: { border: '#00ffff', icon: '#00ffff' },
|
group: { border: '#00ffff', icon: '#00ffff' },
|
||||||
@@ -380,6 +392,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
|
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
|
||||||
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
|
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
|
||||||
zigbee_enddevice: { border: '#008822', icon: '#008822' },
|
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' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||||
group: { border: '#00ff41', icon: '#00ff41' },
|
group: { border: '#00ff41', icon: '#00ff41' },
|
||||||
@@ -459,6 +474,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
|
||||||
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
|
||||||
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
|
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' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NodeProperty, NodeType } from '@/types'
|
||||||
|
|
||||||
|
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
|
||||||
|
|
||||||
|
export function isZwaveType(type: NodeType | string | undefined | null): boolean {
|
||||||
|
return !!type && (ZWAVE_TYPES as string[]).includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel.
|
||||||
|
* Matches backend `build_zwave_properties` (no LQI — Z-Wave has none). */
|
||||||
|
export function buildZwaveProperties(input: {
|
||||||
|
ieee_address?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
model?: string | null
|
||||||
|
}): NodeProperty[] {
|
||||||
|
const props: NodeProperty[] = []
|
||||||
|
if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false })
|
||||||
|
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
|
||||||
|
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
|
||||||
|
return props
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user