diff --git a/README.md b/README.md index ae7b054..7de65b9 100644 --- a/README.md +++ b/README.md @@ -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 lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default. diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py new file mode 100644 index 0000000..77051e0 --- /dev/null +++ b/backend/app/api/routes/zigbee.py @@ -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 + ``/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}") diff --git a/backend/app/main.py b/backend/app/main.py index 7446e89..b954546 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import auth, canvas, 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") diff --git a/backend/app/schemas/zigbee.py b/backend/app/schemas/zigbee.py new file mode 100644 index 0000000..975d85d --- /dev/null +++ b/backend/app/schemas/zigbee.py @@ -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 diff --git a/backend/app/services/zigbee_service.py b/backend/app/services/zigbee_service.py new file mode 100644 index 0000000..b8209bb --- /dev/null +++ b/backend/app/services/zigbee_service.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 3bd01ad..d268548 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,6 +17,7 @@ types-PyYAML==6.0.12.20240917 websockets==13.1 httpx==0.27.2 zeroconf==0.131.0 +aiomqtt==2.3.0 # Dev ruff==0.6.9 diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py new file mode 100644 index 0000000..588a417 --- /dev/null +++ b/backend/tests/test_zigbee_router.py @@ -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 diff --git a/backend/tests/test_zigbee_service.py b/backend/tests/test_zigbee_service.py new file mode 100644 index 0000000..85c7387 --- /dev/null +++ b/backend/tests/test_zigbee_service.py @@ -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) diff --git a/docs/zigbee-import.md b/docs/zigbee-import.md new file mode 100644 index 0000000..ef61b77 --- /dev/null +++ b/docs/zigbee-import.md @@ -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:** `/bridge/request/networkmap` + - **Response 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 +``` + +### 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)_ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 477f2e4..db6253d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' +import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' @@ -30,6 +31,7 @@ import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef } from '@/types' +import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' @@ -55,6 +57,7 @@ export default function App() { const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) + const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { @@ -315,6 +318,48 @@ export default function App() { 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 = { + 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) => { setPendingConnection(connection) }, []) @@ -384,6 +429,7 @@ export default function App() { onAddNode={() => setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} onScan={() => setScanConfigOpen(true)} + onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} onNodeApproved={setEditNodeId} forceView={sidebarForceView} @@ -483,6 +529,14 @@ export default function App() { /> )} + {!STANDALONE && ( + setZigbeeImportOpen(false)} + onAddToCanvas={handleZigbeeAddToCanvas} + /> + )} + setAddGroupRectOpen(false)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b0ccdce..622ca09 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -72,3 +72,26 @@ export const settingsApi = { get: () => api.get<{ interval_seconds: number }>('/settings'), 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), +} diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index cfb6604..6bf6eff 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -1,7 +1,7 @@ import { type NodeProps, type Node } from '@xyflow/react' import { Globe, Router, Network, Server, Layers, Box, Container, - HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna, } from 'lucide-react' import { BaseNode } from './BaseNode' import type { NodeData } from '@/types' @@ -26,3 +26,7 @@ export const CplNode = (props: N) => export const DockerHostNode = (props: N) => export const DockerContainerNode = (props: N) => export const GenericNode = (props: N) => +// Zigbee node types +export const ZigbeeCoordinatorNode = (props: N) => +export const ZigbeeRouterNode = (props: N) => +export const ZigbeeEndDeviceNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 6adba16..c390c4a 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -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 { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' @@ -24,4 +24,7 @@ export const nodeTypes = { generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, + zigbee_coordinator: ZigbeeCoordinatorNode, + zigbee_router: ZigbeeRouterNode, + zigbee_enddevice: ZigbeeEndDeviceNode, } diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 4325938..b2b6452 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,5 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -36,13 +36,14 @@ interface SidebarProps { onAddNode: () => void onAddGroupRect: () => void onScan: () => void + onZigbeeImport: () => void onSave: () => void onNodeApproved: (nodeId: string) => void forceView?: SidebarView 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 [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) @@ -137,6 +138,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro {!STANDALONE && } + {!STANDALONE && } 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(DEFAULT_FORM) + const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle') + const [connectionMsg, setConnectionMsg] = useState('') + const [loading, setLoading] = useState(false) + const [devices, setDevices] = useState([]) + const [edges, setEdges] = useState([]) + const [checked, setChecked] = useState>(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 ( + !v && handleClose()}> + + + + + Zigbee2MQTT Import + + + +
+ {/* Connection Form */} +
+
+
+ + updateField('mqtt_host', e.target.value)} + placeholder="192.168.1.x or mqtt.local" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_port', e.target.value)} + placeholder="1883" + type="number" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('base_topic', e.target.value)} + placeholder="zigbee2mqtt" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_username', e.target.value)} + placeholder="mqtt_user" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_password', e.target.value)} + placeholder="••••••••" + type="password" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + {/* Connection status indicator */} + {connectionStatus !== 'idle' && ( +
+ {connectionStatus === 'testing' && } + {connectionStatus === 'ok' && } + {connectionStatus === 'fail' && } + {connectionStatus === 'testing' ? 'Testing…' : connectionMsg} +
+ )} + +
+ + +
+
+ + {/* Device List */} + {devices.length > 0 && ( +
+
+
+ { 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" + /> + + Devices ({checked.size}/{devices.length} selected) + +
+
+ + {(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 ( +
+
+ + + {DEVICE_TYPE_LABEL[type]} ({group.length}) + +
+ {group.map((device) => ( +
toggleCheck(device.id)} + > + toggleCheck(device.id)} + onClick={(e) => e.stopPropagation()} + className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0" + /> +
+
{device.friendly_name}
+
{device.ieee_address}
+ {(device.model || device.vendor) && ( +
+ {[device.vendor, device.model].filter(Boolean).join(' · ')} +
+ )} +
+ {device.lqi != null && ( + + LQI {device.lqi} + + )} +
+ ))} +
+ ) + })} +
+ )} +
+ + + + {devices.length > 0 && ( + + )} + +
+
+ ) +} diff --git a/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx new file mode 100644 index 0000000..5252687 --- /dev/null +++ b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders the modal with form fields when open', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/zigbee/types.ts b/frontend/src/components/zigbee/types.ts new file mode 100644 index 0000000..c730bc6 --- /dev/null +++ b/frontend/src/components/zigbee/types.ts @@ -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 +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7b326fd..76291a8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -19,6 +19,9 @@ export type NodeType = | 'generic' | 'groupRect' | 'group' + | 'zigbee_coordinator' + | 'zigbee_router' + | 'zigbee_enddevice' export type TextPosition = | 'top-left' @@ -136,6 +139,9 @@ export const NODE_TYPE_LABELS: Record = { generic: 'Generic Device', groupRect: 'Group Rectangle', group: 'Node Group', + zigbee_coordinator: 'Zigbee Coordinator', + zigbee_router: 'Zigbee Router', + zigbee_enddevice: 'Zigbee End Device', } export const STATUS_COLORS: Record = {