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:
@@ -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.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.core.config import settings
|
||||
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(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||
|
||||
|
||||
@app.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
|
||||
Reference in New Issue
Block a user