feat: add Zigbee2MQTT network map importer
- Backend: async MQTT service (aiomqtt) to fetch Z2M networkmap via bridge API - Backend: FastAPI router at /api/v1/zigbee with /import and /test-connection - Backend: Pydantic v2 schemas for request/response validation - Backend: coordinator → router → end-device parent_id hierarchy builder - Frontend: ZigbeeImportModal with MQTT config form, Test Connection, Fetch Devices - Frontend: device list grouped by type (coordinator/router/enddevice) with checkboxes - Frontend: ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode canvas nodes - Frontend: Zigbee Import button in sidebar alongside Scan Network - Frontend: handleZigbeeAddToCanvas wires selected devices + edges onto canvas - Tests: full unit test suite for parser, hierarchy builder, MQTT mocks - Tests: API endpoint tests for /zigbee/import and /zigbee/test-connection - Tests: Vitest component tests for ZigbeeImportModal - Docs: docs/zigbee-import.md with full usage, MQTT config, troubleshooting guide - Docs: README.md Zigbee2MQTT Import section Co-authored-by: CyberKeys <noreply@openclaw.ai>
This commit is contained in:
@@ -74,6 +74,38 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Zigbee2MQTT Import
|
||||||
|
|
||||||
|
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||||
|
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
||||||
|
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
||||||
|
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
||||||
|
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
||||||
|
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
|
||||||
|
| Type | Z2M Device | Icon |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `zigbee_coordinator` | Coordinator | Network hub |
|
||||||
|
| `zigbee_router` | Router (mains-powered) | Radio |
|
||||||
|
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
||||||
|
|
||||||
|
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
||||||
|
LQI (Link Quality Indicator) is stored as a node property.
|
||||||
|
|
||||||
|
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Live View (read-only public canvas)
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""FastAPI router for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.schemas.zigbee import (
|
||||||
|
ZigbeeEdgeOut,
|
||||||
|
ZigbeeImportRequest,
|
||||||
|
ZigbeeImportResponse,
|
||||||
|
ZigbeeNodeOut,
|
||||||
|
ZigbeeTestConnectionRequest,
|
||||||
|
ZigbeeTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||||
|
async def import_zigbee_network(
|
||||||
|
payload: ZigbeeImportRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeImportResponse:
|
||||||
|
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||||
|
|
||||||
|
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||||
|
``<base_topic>/bridge/request/networkmap``, and waits up to 10 s for the
|
||||||
|
response. The devices are returned as typed homelable nodes with a
|
||||||
|
coordinator → router → end-device hierarchy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nodes_raw, edges_raw = await fetch_networkmap(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
base_topic=payload.base_topic,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
)
|
||||||
|
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 Zigbee import")
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||||
|
|
||||||
|
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||||
|
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||||
|
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
||||||
|
async def test_zigbee_connection(
|
||||||
|
payload: ZigbeeTestConnectionRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeTestConnectionResponse:
|
||||||
|
"""Quick MQTT ping to validate broker connection before importing."""
|
||||||
|
try:
|
||||||
|
await test_mqtt_connection(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
)
|
||||||
|
return ZigbeeTestConnectionResponse(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 ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during connection test")
|
||||||
|
return ZigbeeTestConnectionResponse(connected=False, message=f"Unexpected error: {exc}")
|
||||||
+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, edges, liveview, nodes, scan, status
|
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
|
||||||
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
|
||||||
@@ -55,6 +55,7 @@ app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
|||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
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.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportRequest(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)")
|
||||||
|
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeTestConnectionRequest(BaseModel):
|
||||||
|
mqtt_host: str
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||||
|
mqtt_username: str | None = None
|
||||||
|
mqtt_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeDeviceData(BaseModel):
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str # Coordinator, Router, EndDevice
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
last_seen: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeNodeOut(BaseModel):
|
||||||
|
"""A homelable-ready node representation of a Zigbee device."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str # zigbee_coordinator | zigbee_router | zigbee_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 ZigbeeEdgeOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportResponse(BaseModel):
|
||||||
|
nodes: list[ZigbeeNodeOut]
|
||||||
|
edges: list[ZigbeeEdgeOut]
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeTestConnectionResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||||
|
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||||
|
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||||
|
_NETWORKMAP_TIMEOUT = 10.0 # seconds to wait for the networkmap response
|
||||||
|
|
||||||
|
|
||||||
|
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||||
|
"""Map a Z2M device type string to a homelable node type."""
|
||||||
|
mapping = {
|
||||||
|
"Coordinator": "zigbee_coordinator",
|
||||||
|
"Router": "zigbee_router",
|
||||||
|
"EndDevice": "zigbee_enddevice",
|
||||||
|
}
|
||||||
|
return mapping.get(device_type, "zigbee_enddevice")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Parse a Z2M networkmap response payload into node + edge lists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(nodes, edges) where each node/edge is a plain dict with the fields
|
||||||
|
expected by ZigbeeNodeOut / ZigbeeEdgeOut.
|
||||||
|
"""
|
||||||
|
data = payload.get("data", {})
|
||||||
|
routes = data.get("routes", [])
|
||||||
|
|
||||||
|
nodes_list: list[dict] = []
|
||||||
|
edges_list: list[dict] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
# Coordinator is always present; find it first so we can wire the hierarchy
|
||||||
|
coordinator_id: str | None = None
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
source = route.get("source", {})
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ieee = source.get("ieeeAddr") or source.get("ieee_address") or ""
|
||||||
|
if not ieee:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_type: str = source.get("type", "EndDevice")
|
||||||
|
friendly_name: str = source.get("friendlyName") or source.get("friendly_name") or ieee
|
||||||
|
model: str | None = source.get("modelID") or source.get("model")
|
||||||
|
vendor: str | None = source.get("vendor")
|
||||||
|
description: str | None = source.get("description")
|
||||||
|
|
||||||
|
if ieee not in seen_ids:
|
||||||
|
seen_ids.add(ieee)
|
||||||
|
node_type = _z2m_type_to_homelable(device_type)
|
||||||
|
node: dict = {
|
||||||
|
"id": ieee,
|
||||||
|
"label": friendly_name,
|
||||||
|
"type": node_type,
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"friendly_name": friendly_name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"model": model,
|
||||||
|
"vendor": vendor,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
}
|
||||||
|
nodes_list.append(node)
|
||||||
|
if device_type == "Coordinator":
|
||||||
|
coordinator_id = ieee
|
||||||
|
|
||||||
|
# Walk the route targets to build edges and collect additional nodes
|
||||||
|
targets = route.get("routes", [])
|
||||||
|
for target_entry in targets:
|
||||||
|
target_ieee = target_entry.get("target", {}).get("ieeeAddr") or target_entry.get("target", {}).get("ieee_address") or ""
|
||||||
|
lqi: int | None = target_entry.get("lqi")
|
||||||
|
|
||||||
|
if not target_ieee:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_ieee not in seen_ids:
|
||||||
|
seen_ids.add(target_ieee)
|
||||||
|
t_source = target_entry.get("target", {})
|
||||||
|
t_type: str = t_source.get("type", "EndDevice")
|
||||||
|
t_fn: str = t_source.get("friendlyName") or t_source.get("friendly_name") or target_ieee
|
||||||
|
t_model: str | None = t_source.get("modelID") or t_source.get("model")
|
||||||
|
t_vendor: str | None = t_source.get("vendor")
|
||||||
|
t_node: dict = {
|
||||||
|
"id": target_ieee,
|
||||||
|
"label": t_fn,
|
||||||
|
"type": _z2m_type_to_homelable(t_type),
|
||||||
|
"ieee_address": target_ieee,
|
||||||
|
"friendly_name": t_fn,
|
||||||
|
"device_type": t_type,
|
||||||
|
"model": t_model,
|
||||||
|
"vendor": t_vendor,
|
||||||
|
"lqi": lqi,
|
||||||
|
"parent_id": None,
|
||||||
|
}
|
||||||
|
nodes_list.append(t_node)
|
||||||
|
|
||||||
|
edges_list.append({"source": ieee, "target": target_ieee})
|
||||||
|
|
||||||
|
# Build parent_id hierarchy: coordinator → routers → end devices
|
||||||
|
if coordinator_id:
|
||||||
|
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["device_type"] == "Router":
|
||||||
|
node["parent_id"] = coordinator_id
|
||||||
|
elif node["device_type"] == "EndDevice":
|
||||||
|
# Try to find the nearest router from the edge list
|
||||||
|
parent = _find_parent_router(node["id"], router_ids, edges_list)
|
||||||
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
|
return nodes_list, edges_list
|
||||||
|
|
||||||
|
|
||||||
|
def _find_parent_router(
|
||||||
|
device_id: str,
|
||||||
|
router_ids: set[str],
|
||||||
|
edges: list[dict],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the first router that has a direct edge to device_id."""
|
||||||
|
for edge in edges:
|
||||||
|
if edge["target"] == device_id and edge["source"] in router_ids:
|
||||||
|
return edge["source"]
|
||||||
|
if edge["source"] == device_id and edge["target"] in router_ids:
|
||||||
|
return edge["target"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_networkmap(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
base_topic: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: if the broker does not respond in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is malformed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import aiomqtt # type: ignore[import]
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"aiomqtt is required for Zigbee import. "
|
||||||
|
"Install it with: pip install aiomqtt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||||
|
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||||
|
|
||||||
|
result_event: asyncio.Event = asyncio.Event()
|
||||||
|
response_payload: dict = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
) as client:
|
||||||
|
await client.subscribe(response_topic)
|
||||||
|
await client.publish(
|
||||||
|
request_topic,
|
||||||
|
json.dumps({"type": "raw", "routes": False}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait_for_response() -> None:
|
||||||
|
async for message in client.messages:
|
||||||
|
if str(message.topic) == response_topic:
|
||||||
|
try:
|
||||||
|
response_payload.update(json.loads(message.payload))
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
raise ValueError(f"Malformed networkmap response: {exc}") from exc
|
||||||
|
result_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||||
|
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(f"MQTT connection failed: {exc}") from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timed out waiting for networkmap response from {mqtt_host}:{mqtt_port}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not response_payload:
|
||||||
|
raise ValueError("Empty networkmap response received")
|
||||||
|
|
||||||
|
return parse_networkmap(response_payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mqtt_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||||
|
|
||||||
|
Returns True on success, raises ConnectionError on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import aiomqtt # type: ignore[import]
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise ImportError("aiomqtt is required") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(f"MQTT connection failed: {exc}") from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError(f"Connection to {mqtt_host}:{mqtt_port} timed out") from exc
|
||||||
@@ -17,6 +17,7 @@ types-PyYAML==6.0.12.20240917
|
|||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.131.0
|
zeroconf==0.131.0
|
||||||
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""API endpoint tests for /api/v1/zigbee/*."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@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/zigbee/test-connection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/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.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/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/zigbee/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/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422 # pydantic validation error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_NODES = [
|
||||||
|
{
|
||||||
|
"id": "0x00000000",
|
||||||
|
"label": "Coordinator",
|
||||||
|
"type": "zigbee_coordinator",
|
||||||
|
"ieee_address": "0x00000000",
|
||||||
|
"friendly_name": "Coordinator",
|
||||||
|
"device_type": "Coordinator",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0x00000001",
|
||||||
|
"label": "router_1",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ieee_address": "0x00000001",
|
||||||
|
"friendly_name": "router_1",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "CC2530",
|
||||||
|
"vendor": "Texas Instruments",
|
||||||
|
"lqi": 230,
|
||||||
|
"parent_id": "0x00000000",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SAMPLE_EDGES = [
|
||||||
|
{"source": "0x00000000", "target": "0x00000001"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"base_topic": "zigbee2mqtt",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 2
|
||||||
|
assert len(data["nodes"]) == 2
|
||||||
|
assert len(data["edges"]) == 1
|
||||||
|
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
|
||||||
|
assert coordinator["ieee_address"] == "0x00000000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_username": "admin",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
"base_topic": "z2m",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
mock_fetch.assert_called_once_with(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="z2m",
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/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.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = TimeoutError("timed out")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/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.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ValueError("malformed response")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
||||||
|
"""An empty Zigbee network (coordinator only) is a valid response."""
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 0
|
||||||
|
assert data["nodes"] == []
|
||||||
|
assert data["edges"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"""Unit tests for zigbee_service: parser and hierarchy builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
_find_parent_router,
|
||||||
|
_z2m_type_to_homelable,
|
||||||
|
fetch_networkmap,
|
||||||
|
parse_networkmap,
|
||||||
|
test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_route(
|
||||||
|
ieee: str,
|
||||||
|
device_type: str = "EndDevice",
|
||||||
|
friendly_name: str | None = None,
|
||||||
|
targets: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a minimal Z2M route entry for testing."""
|
||||||
|
return {
|
||||||
|
"source": {
|
||||||
|
"ieeeAddr": ieee,
|
||||||
|
"type": device_type,
|
||||||
|
"friendlyName": friendly_name or ieee,
|
||||||
|
},
|
||||||
|
"routes": targets or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_target(
|
||||||
|
ieee: str,
|
||||||
|
device_type: str = "EndDevice",
|
||||||
|
lqi: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"target": {"ieeeAddr": ieee, "type": device_type, "friendlyName": ieee},
|
||||||
|
"lqi": lqi,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _z2m_type_to_homelable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestZ2mTypeToHomelable:
|
||||||
|
def test_coordinator(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
|
||||||
|
|
||||||
|
def test_router(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Router") == "zigbee_router"
|
||||||
|
|
||||||
|
def test_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
def test_unknown_defaults_to_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_networkmap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseNetworkmap:
|
||||||
|
def test_empty_payload(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap({})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_empty_routes(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap({"data": {"routes": []}})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_only(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route("0x0000000000000000", "Coordinator", "Coordinator"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||||
|
assert nodes[0]["ieee_address"] == "0x0000000000000000"
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_router_enddevice(self) -> None:
|
||||||
|
coord_ieee = "0x0000000000000000"
|
||||||
|
router_ieee = "0x0000000000000001"
|
||||||
|
end_ieee = "0x0000000000000002"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route(
|
||||||
|
coord_ieee,
|
||||||
|
"Coordinator",
|
||||||
|
"Coordinator",
|
||||||
|
targets=[_make_target(router_ieee, "Router")],
|
||||||
|
),
|
||||||
|
_make_route(
|
||||||
|
router_ieee,
|
||||||
|
"Router",
|
||||||
|
"my_router",
|
||||||
|
targets=[_make_target(end_ieee, "EndDevice")],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes}
|
||||||
|
|
||||||
|
assert coord_ieee in node_by_id
|
||||||
|
assert router_ieee in node_by_id
|
||||||
|
assert end_ieee in node_by_id
|
||||||
|
|
||||||
|
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
|
||||||
|
assert node_by_id[router_ieee]["type"] == "zigbee_router"
|
||||||
|
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
|
||||||
|
|
||||||
|
# Parent hierarchy
|
||||||
|
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
|
||||||
|
assert node_by_id[end_ieee]["parent_id"] == router_ieee
|
||||||
|
|
||||||
|
def test_no_duplicate_nodes(self) -> None:
|
||||||
|
ieee = "0x0000000000000001"
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route(ieee, "Router"),
|
||||||
|
_make_route(ieee, "Router"), # duplicate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
|
||||||
|
def test_edges_built_correctly(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
router = "0x0001"
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route(
|
||||||
|
coord,
|
||||||
|
"Coordinator",
|
||||||
|
targets=[_make_target(router, "Router")],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert len(edges) == 1
|
||||||
|
assert edges[0]["source"] == coord
|
||||||
|
assert edges[0]["target"] == router
|
||||||
|
|
||||||
|
def test_friendly_name_used_as_label(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route("0xABCD", "EndDevice", "Living Room Sensor")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert nodes[0]["label"] == "Living Room Sensor"
|
||||||
|
|
||||||
|
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0003"
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
_make_route(coord, "Coordinator"),
|
||||||
|
_make_route(end, "EndDevice"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
end_node = next(n for n in nodes if n["id"] == end)
|
||||||
|
assert end_node["parent_id"] == coord
|
||||||
|
|
||||||
|
def test_missing_ieee_skipped(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
{"source": {}, "routes": []}, # no ieeeAddr
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _find_parent_router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFindParentRouter:
|
||||||
|
def test_finds_router_as_source(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "r1", "target": "e1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_finds_router_as_target(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "e1", "target": "r1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_router(self) -> None:
|
||||||
|
router_ids: set[str] = set()
|
||||||
|
edges = [{"source": "e1", "target": "e2"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) is None
|
||||||
|
|
||||||
|
def test_returns_none_empty_edges(self) -> None:
|
||||||
|
assert _find_parent_router("e1", {"r1"}, []) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_networkmap (integration-style with mocked aiomqtt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SAMPLE_RESPONSE_PAYLOAD = {
|
||||||
|
"data": {
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"ieeeAddr": "0x00000000",
|
||||||
|
"type": "Coordinator",
|
||||||
|
"friendlyName": "Coordinator",
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"target": {
|
||||||
|
"ieeeAddr": "0x00000001",
|
||||||
|
"type": "Router",
|
||||||
|
"friendlyName": "router_1",
|
||||||
|
},
|
||||||
|
"lqi": 230,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_success() -> None:
|
||||||
|
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
|
||||||
|
|
||||||
|
class _FakeMessage:
|
||||||
|
topic = "zigbee2mqtt/bridge/response/networkmap"
|
||||||
|
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
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()
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
nodes, edges = await fetch_networkmap(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
|
||||||
|
assert any(n["type"] == "zigbee_router" for n in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_connection_error() -> None:
|
||||||
|
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await fetch_networkmap(
|
||||||
|
mqtt_host="bad-host",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_success() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
result = await test_mqtt_connection("localhost", 1883)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_failure() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await test_mqtt_connection("bad-host", 1883)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Zigbee2MQTT Network Map Importer
|
||||||
|
|
||||||
|
This feature lets you connect Homelable to your MQTT broker, fetch the Zigbee2MQTT network topology, and drop all Zigbee devices onto the canvas as typed nodes with proper hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
- **Automatic device discovery** — Requests the Z2M networkmap via the MQTT bridge API and parses the full device list
|
||||||
|
- **Typed nodes** — Devices are mapped to three homelable node types:
|
||||||
|
- `zigbee_coordinator` — The Zigbee coordinator (hub)
|
||||||
|
- `zigbee_router` — Mains-powered router devices
|
||||||
|
- `zigbee_enddevice` — Battery-powered end devices (sensors, bulbs, etc.)
|
||||||
|
- **Hierarchy** — `parent_id` is set automatically: coordinator → routers → end devices
|
||||||
|
- **LQI display** — Link Quality Indicator is stored as a node property
|
||||||
|
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
|
||||||
|
2. **Zigbee2MQTT** connected to the broker and running
|
||||||
|
3. Z2M must respond to networkmap requests on:
|
||||||
|
- **Request topic:** `<base_topic>/bridge/request/networkmap`
|
||||||
|
- **Response topic:** `<base_topic>/bridge/response/networkmap`
|
||||||
|
- The default base topic is `zigbee2mqtt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step Usage
|
||||||
|
|
||||||
|
### 1. Open the Zigbee Import dialog
|
||||||
|
|
||||||
|
Click **Zigbee Import** in the left sidebar (below "Scan Network").
|
||||||
|
|
||||||
|
### 2. Configure the MQTT connection
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Broker Host | — | IP or hostname of your MQTT broker |
|
||||||
|
| Port | 1883 | MQTT broker port |
|
||||||
|
| Base Topic | `zigbee2mqtt` | Zigbee2MQTT base topic |
|
||||||
|
| Username | _(optional)_ | MQTT username if authentication is enabled |
|
||||||
|
| Password | _(optional)_ | MQTT password |
|
||||||
|
|
||||||
|
### 3. Test the connection (optional)
|
||||||
|
|
||||||
|
Click **Test Connection** to verify broker reachability before fetching devices.
|
||||||
|
A green indicator confirms success; red shows the error message from the broker.
|
||||||
|
|
||||||
|
### 4. Fetch devices
|
||||||
|
|
||||||
|
Click **Fetch Devices**. Homelable will:
|
||||||
|
1. Connect to the broker
|
||||||
|
2. Subscribe to the response topic
|
||||||
|
3. Publish `{"type": "raw", "routes": false}` to the request topic
|
||||||
|
4. Wait up to 10 seconds for the network map response
|
||||||
|
5. Parse and group devices by type
|
||||||
|
|
||||||
|
### 5. Select and add to canvas
|
||||||
|
|
||||||
|
Devices are grouped by type (Coordinator / Router / End Device).
|
||||||
|
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
|
||||||
|
|
||||||
|
> **Tip:** All devices are selected by default. Uncheck any you don't want.
|
||||||
|
|
||||||
|
### 6. Arrange on the canvas
|
||||||
|
|
||||||
|
Devices are placed in a grid at the top-right of the canvas.
|
||||||
|
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MQTT Configuration Tips
|
||||||
|
|
||||||
|
### Mosquitto without authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mosquitto with password file
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
password_file /etc/mosquitto/passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a user:
|
||||||
|
```bash
|
||||||
|
mosquitto_passwd -c /etc/mosquitto/passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zigbee2MQTT `configuration.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
base_topic: zigbee2mqtt
|
||||||
|
server: mqtt://localhost:1883
|
||||||
|
# user: mqtt_user
|
||||||
|
# password: mqtt_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Z2M Versions
|
||||||
|
|
||||||
|
The networkmap bridge API is available in **Zigbee2MQTT 1.x and 2.x**.
|
||||||
|
Tested against Z2M 1.35+ and 2.x.
|
||||||
|
|
||||||
|
The importer uses the `raw` topology format (`routes: false`) which is the most widely supported mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
|
||||||
|
| "Timed out waiting for networkmap" | Z2M not running or wrong base_topic | Verify Z2M is connected, check base_topic setting |
|
||||||
|
| 0 devices returned | Z2M has no devices paired | Pair at least one device first |
|
||||||
|
| "Malformed networkmap response" | Z2M returned unexpected format | Check Z2M version; open an issue |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_(Screenshots will be added in a future release)_
|
||||||
@@ -19,6 +19,7 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
@@ -30,6 +31,7 @@ import { canvasApi } from '@/api/client'
|
|||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
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'
|
||||||
|
|
||||||
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'
|
||||||
@@ -55,6 +57,7 @@ export default function App() {
|
|||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
const [zigbeeImportOpen, setZigbeeImportOpen] = 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
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -315,6 +318,48 @@ export default function App() {
|
|||||||
setExportModalOpen(true)
|
setExportModalOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
// Place nodes in a grid starting at x=500, y=100
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 170
|
||||||
|
const SPACING_Y = 100
|
||||||
|
zigbeeNodes.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.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
|
||||||
|
...(zn.model ? { os: zn.model } : {}),
|
||||||
|
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Add IoT edges between Zigbee devices
|
||||||
|
zigbeeEdges.forEach((ze) => {
|
||||||
|
const sourceId = ze.source
|
||||||
|
const targetId = ze.target
|
||||||
|
onConnect({
|
||||||
|
source: sourceId,
|
||||||
|
sourceHandle: 'top',
|
||||||
|
target: targetId,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -384,6 +429,7 @@ export default function App() {
|
|||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
onNodeApproved={setEditNodeId}
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
@@ -483,6 +529,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZigbeeImportModal
|
||||||
|
open={zigbeeImportOpen}
|
||||||
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
|
|||||||
@@ -72,3 +72,26 @@ export const settingsApi = {
|
|||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const zigbeeApi = {
|
||||||
|
testConnection: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
base_topic?: string
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||||
|
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||||
|
device_count: number
|
||||||
|
}>('/zigbee/import', data),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, PlugZap, Anchor, Package, Flame,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -26,3 +26,7 @@ export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
|||||||
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
// Zigbee node types
|
||||||
|
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
|
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||||
|
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
@@ -24,4 +24,7 @@ export const nodeTypes = {
|
|||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
|
zigbee_router: ZigbeeRouterNode,
|
||||||
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut, Network } 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'
|
||||||
@@ -36,13 +36,14 @@ interface SidebarProps {
|
|||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
onNodeApproved: (nodeId: string) => void
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
highlightPendingId?: string
|
highlightPendingId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -137,6 +138,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
{!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} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={hideIp ? EyeOff : Eye}
|
icon={hideIp ? EyeOff : Eye}
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Network, Router, 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 { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||||
|
|
||||||
|
interface ZigbeeImportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionForm {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: string
|
||||||
|
mqtt_username: string
|
||||||
|
mqtt_password: string
|
||||||
|
base_topic: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ConnectionForm = {
|
||||||
|
mqtt_host: '',
|
||||||
|
mqtt_port: '1883',
|
||||||
|
mqtt_username: '',
|
||||||
|
mqtt_password: '',
|
||||||
|
base_topic: 'zigbee2mqtt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_TYPE_ICON = {
|
||||||
|
zigbee_coordinator: Network,
|
||||||
|
zigbee_router: Router,
|
||||||
|
zigbee_enddevice: Cpu,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_LABEL = {
|
||||||
|
zigbee_coordinator: 'Coordinator',
|
||||||
|
zigbee_router: 'Router',
|
||||||
|
zigbee_enddevice: 'End Device',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR = {
|
||||||
|
zigbee_coordinator: '#00d4ff',
|
||||||
|
zigbee_router: '#39d353',
|
||||||
|
zigbee_enddevice: '#e3b341',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) {
|
||||||
|
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<ZigbeeNode[]>([])
|
||||||
|
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
|
setForm((f) => ({ ...f, [field]: value }))
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || 1883,
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
try {
|
||||||
|
const res = await zigbeeApi.testConnection({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || 1883,
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
})
|
||||||
|
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 handleFetchDevices = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await zigbeeApi.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 Zigbee devices found in the network map')
|
||||||
|
} else {
|
||||||
|
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err && typeof err === 'object' && 'response' in err
|
||||||
|
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
: undefined
|
||||||
|
toast.error(msg ?? 'Failed to fetch Zigbee 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('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDevices = {
|
||||||
|
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
|
||||||
|
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
|
||||||
|
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_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">
|
||||||
|
<Network size={16} className="text-[#00d4ff]" />
|
||||||
|
Zigbee2MQTT 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">Base Topic</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_topic}
|
||||||
|
onChange={(e) => updateField('base_topic', e.target.value)}
|
||||||
|
placeholder="zigbee2mqtt"
|
||||||
|
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"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</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 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: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleFetchDevices}
|
||||||
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||||
|
Fetch Devices
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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 accent-[#00d4ff] cursor-pointer"
|
||||||
|
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, ZigbeeNode[]][])
|
||||||
|
.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-[#00d4ff]/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 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
||||||
|
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
||||||
|
>
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add {checked.size} to Canvas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
zigbeeApi: {
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
importNetwork: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
|
||||||
|
import { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddToCanvas: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleNodes = [
|
||||||
|
{
|
||||||
|
id: '0x0000',
|
||||||
|
label: 'Coordinator',
|
||||||
|
type: 'zigbee_coordinator' as const,
|
||||||
|
ieee_address: '0x0000',
|
||||||
|
friendly_name: 'Coordinator',
|
||||||
|
device_type: 'Coordinator',
|
||||||
|
model: null,
|
||||||
|
vendor: null,
|
||||||
|
lqi: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0x0001',
|
||||||
|
label: 'router_1',
|
||||||
|
type: 'zigbee_router' as const,
|
||||||
|
ieee_address: '0x0001',
|
||||||
|
friendly_name: 'router_1',
|
||||||
|
device_type: 'Router',
|
||||||
|
model: 'CC2530',
|
||||||
|
vendor: 'TI',
|
||||||
|
lqi: 200,
|
||||||
|
parent_id: '0x0000',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZigbeeImportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).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(<ZigbeeImportModal {...defaultProps} open={false} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the modal with form fields when open', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('1883')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when testing connection without a host', async () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||||
|
})
|
||||||
|
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success status when connection test passes', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: true, message: 'Connection successful' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failure status when connection test fails', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: false, message: 'Connection refused' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection refused')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches devices and renders them grouped by type', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||||
|
expect(screen.getByText('router_1')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows info toast when no devices found', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: [], edges: [], device_count: 0 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Coordinator'))
|
||||||
|
|
||||||
|
// Click "Add N to Canvas" button
|
||||||
|
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Shared Zigbee type definitions for the frontend. */
|
||||||
|
|
||||||
|
export interface ZigbeeNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_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 ZigbeeEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeImportResponse {
|
||||||
|
nodes: ZigbeeNode[]
|
||||||
|
edges: ZigbeeEdge[]
|
||||||
|
device_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionRequest {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionResponse {
|
||||||
|
connected: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export type NodeType =
|
|||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'zigbee_coordinator'
|
||||||
|
| 'zigbee_router'
|
||||||
|
| 'zigbee_enddevice'
|
||||||
|
|
||||||
export type TextPosition =
|
export type TextPosition =
|
||||||
| 'top-left'
|
| 'top-left'
|
||||||
@@ -136,6 +139,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
group: 'Node Group',
|
group: 'Node Group',
|
||||||
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
|
zigbee_router: 'Zigbee Router',
|
||||||
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user