feat: add Z-Wave network scan via MQTT gateway

Import a Z-Wave JS UI (zwavejs2mqtt) network over the MQTT gateway API,
mirroring the existing Zigbee pipeline:

- New Z-Wave Import modal + sidebar entry (broker, prefix, gateway name)
- coordinator/router/end-device typing with mesh tree from node neighbors
- import to Pending section or straight to canvas
- Pending Devices gains a Z-Wave source filter
- shared mqtt_common helpers extracted from the zigbee service

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-26 11:19:37 +02:00
parent c6076d133a
commit 8faf5c1c79
23 changed files with 2638 additions and 53 deletions
+183
View File
@@ -0,0 +1,183 @@
"""Unit tests for the shared MQTT helpers in mqtt_common."""
from __future__ import annotations
import json
import ssl
from unittest.mock import patch
import pytest
from app.services.mqtt_common import (
_build_tls_context,
_sanitize_mqtt_error,
request_response,
)
from app.services.mqtt_common import test_connection as _test_connection
# ---------------------------------------------------------------------------
# _sanitize_mqtt_error — never leak credentials
# ---------------------------------------------------------------------------
def test_sanitize_auth_error() -> None:
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
assert msg == "Authentication failed"
assert "secret" not in msg
def test_sanitize_refused() -> None:
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
def test_sanitize_dns() -> None:
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
assert msg == "Broker hostname could not be resolved"
assert "broker.lan" not in msg
def test_sanitize_tls() -> None:
assert _sanitize_mqtt_error(
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
) == "TLS handshake failed"
def test_sanitize_timeout() -> None:
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
def test_sanitize_unknown_falls_back() -> None:
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
assert msg == "MQTT connection failed"
assert "hunter2" not in msg
# ---------------------------------------------------------------------------
# _build_tls_context
# ---------------------------------------------------------------------------
def test_tls_secure_verifies() -> None:
ctx = _build_tls_context(insecure=False)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_tls_insecure_disables_verification() -> None:
ctx = _build_tls_context(insecure=True)
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
# ---------------------------------------------------------------------------
# request_response (mocked aiomqtt)
# ---------------------------------------------------------------------------
_SAMPLE = {"success": True, "result": []}
def _fake_client_factory(topic: str, payload: dict):
class _FakeMessage:
_yielded = False
def __init__(self) -> None:
self.topic = topic
self.payload = json.dumps(payload).encode()
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _topic: str) -> None:
pass
async def publish(self, _topic: str, _payload: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
return _FakeClient
@pytest.mark.asyncio
async def test_request_response_success() -> None:
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
out = await request_response(
"localhost", 1883, "req/topic", topic, {"args": []}
)
assert out == _SAMPLE
@pytest.mark.asyncio
async def test_request_response_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await request_response("bad", 1883, "req", "resp", {})
@pytest.mark.asyncio
async def test_request_response_passes_tls_context() -> None:
topic = "resp"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
assert ctx.verify_mode == ssl.CERT_NONE
@pytest.mark.asyncio
async def test_test_connection_success() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
assert await _test_connection("localhost", 1883) is True
@pytest.mark.asyncio
async def test_test_connection_failure() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await _test_connection("bad", 1883)
+447
View File
@@ -0,0 +1,447 @@
"""API endpoint tests for /api/v1/zwave/*."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# /api/v1/zwave/test-connection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is True
assert "success" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
mock_conn.side_effect = ConnectionError("Connection refused")
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is False
assert "refused" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 99999},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zwave/import
# ---------------------------------------------------------------------------
_SAMPLE_NODES = [
{
"id": "zwave-0xh-1",
"label": "Controller",
"type": "zwave_coordinator",
"ieee_address": "zwave-0xh-1",
"friendly_name": "Controller",
"device_type": "Controller",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "zwave-0xh-2",
"label": "Wall Plug",
"type": "zwave_router",
"ieee_address": "zwave-0xh-2",
"friendly_name": "Wall Plug",
"device_type": "Router",
"model": "ZW100",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-1",
},
]
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
@pytest.mark.asyncio
async def test_import_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 2
assert len(data["edges"]) == 1
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
assert coordinator["ieee_address"] == "zwave-0xh-1"
@pytest.mark.asyncio
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zwave/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"prefix": "myzwave",
"gateway_name": "gw1",
"mqtt_username": "admin",
"mqtt_password": "secret",
},
headers=headers,
)
assert res.status_code == 200
mock_fetch.assert_called_once_with(
mqtt_host="localhost",
mqtt_port=1883,
prefix="myzwave",
gateway_name="gw1",
username="admin",
password="secret",
tls=False,
tls_insecure=False,
)
@pytest.mark.asyncio
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = ConnectionError("broker unreachable")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 502
assert "broker unreachable" in res.json()["detail"]
@pytest.mark.asyncio
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = TimeoutError("timed out")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 504
@pytest.mark.asyncio
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = ValueError("malformed response")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = RuntimeError("boom")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 500
@pytest.mark.asyncio
async def test_import_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zwave/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 1883,
"mqtt_tls": False,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zwave/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "zwave-0xh-1",
"label": "Controller",
"type": "zwave_coordinator",
"ieee_address": "zwave-0xh-1",
"friendly_name": "Controller",
"device_type": "Controller",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "zwave-0xh-2",
"label": "Wall Plug",
"type": "zwave_router",
"ieee_address": "zwave-0xh-2",
"friendly_name": "Wall Plug",
"device_type": "Router",
"model": "ZW100",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-1",
},
{
"id": "zwave-0xh-3",
"label": "Door Sensor",
"type": "zwave_enddevice",
"ieee_address": "zwave-0xh-3",
"friendly_name": "Door Sensor",
"device_type": "EndDevice",
"model": "ZW120",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-2",
},
]
_PENDING_EDGES = [
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
]
@pytest.mark.asyncio
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
from unittest.mock import AsyncMock
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
res = await client.post(
"/api/v1/zwave/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
run = res.json()
assert run["kind"] == "zwave"
assert run["status"] == "running"
assert run["ranges"] == ["localhost:1883"]
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
from app.api.routes.zwave import _persist_pending_import
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 2
assert result.pending_updated == 0
assert result.coordinator is not None
assert result.coordinator.ieee_address == "zwave-0xh-1"
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
@pytest.mark.asyncio
async def test_persist_idempotent_updates_existing(db_session) -> None:
from app.api.routes.zwave import _persist_pending_import
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["model"] = "ZW111"
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 2
assert result.coordinator_already_existed is True
@pytest.mark.asyncio
async def test_persist_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
@pytest.mark.asyncio
async def test_persist_sets_coordinator_properties(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node
nodes = [dict(n) for n in _PENDING_NODES]
nodes[0]["vendor"] = "Aeotec"
nodes[0]["model"] = "ZW090"
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
coord = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
).scalar_one()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
assert all(p["visible"] is False for p in coord.properties)
@pytest.mark.asyncio
async def test_persist_skips_pending_for_approved_node(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
approved = Node(
label="Wall Plug",
type="zwave_router",
status="online",
check_method="none",
ieee_address="zwave-0xh-2",
services=[],
properties=[],
)
db_session.add(approved)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
pendings = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalars().all()
assert pendings == []
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
@pytest.mark.asyncio
async def test_persist_revives_orphaned_approved_device(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
orphan = PendingDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
device_subtype="Router",
status="approved",
discovery_source="zwave",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert revived.status == "pending"
assert result.pending_created == 1
assert result.pending_updated == 1
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
device_subtype="Router",
status="hidden",
discovery_source="zwave",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert still_hidden.status == "hidden"
+269
View File
@@ -0,0 +1,269 @@
"""Unit tests for zwave_service: parser, role mapping, hierarchy builder."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
import pytest
from app.services.zwave_service import (
build_zwave_properties,
fetch_zwave_network,
parse_zwave_nodes,
)
# ---------------------------------------------------------------------------
# Helpers — real zwavejs2mqtt getNodes shape
# ---------------------------------------------------------------------------
def _node(
node_id: int,
*,
controller: bool = False,
routing: bool = False,
name: str | None = None,
neighbors: list[int] | None = None,
manufacturer: str | None = None,
product_label: str | None = None,
home_id: str = "0xabcd1234",
) -> dict[str, Any]:
return {
"id": node_id,
"homeId": home_id,
"isControllerNode": controller,
"isRouting": routing,
"name": name,
"neighbors": neighbors or [],
"manufacturer": manufacturer,
"productLabel": product_label,
}
def _wrap(nodes: list[dict[str, Any]], success: bool = True) -> dict[str, Any]:
return {"success": success, "result": nodes}
HOME = "0xabcd1234"
def _ieee(node_id: int) -> str:
return f"zwave-{HOME}-{node_id}"
# ---------------------------------------------------------------------------
# Role mapping
# ---------------------------------------------------------------------------
class TestRoleMapping:
def test_controller_is_coordinator(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True)]))
assert nodes[0]["type"] == "zwave_coordinator"
assert nodes[0]["device_type"] == "Controller"
def test_routing_is_router(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(2, routing=True)]))
assert nodes[0]["type"] == "zwave_router"
assert nodes[0]["device_type"] == "Router"
def test_default_is_enddevice(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(3)]))
assert nodes[0]["type"] == "zwave_enddevice"
assert nodes[0]["device_type"] == "EndDevice"
def test_controller_wins_over_routing(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True, routing=True)]))
assert nodes[0]["type"] == "zwave_coordinator"
# ---------------------------------------------------------------------------
# parse_zwave_nodes
# ---------------------------------------------------------------------------
class TestParse:
def test_empty_payload(self) -> None:
nodes, edges = parse_zwave_nodes({})
assert nodes == []
assert edges == []
def test_empty_result(self) -> None:
nodes, edges = parse_zwave_nodes(_wrap([]))
assert nodes == []
assert edges == []
def test_success_false_raises(self) -> None:
with pytest.raises(ValueError, match="failure"):
parse_zwave_nodes(_wrap([], success=False))
def test_result_not_list_raises(self) -> None:
with pytest.raises(ValueError, match="not a list"):
parse_zwave_nodes({"success": True, "result": "oops"})
def test_missing_id_skipped(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([{"homeId": HOME, "isControllerNode": False}]))
assert nodes == []
def test_ieee_identity_format(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(5, controller=True)]))
assert nodes[0]["ieee_address"] == _ieee(5)
def test_name_fallback(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(7, name="Living Room")]))
assert nodes[0]["label"] == "Living Room"
assert nodes[0]["friendly_name"] == "Living Room"
def test_model_and_vendor(self) -> None:
nodes, _ = parse_zwave_nodes(
_wrap([_node(8, manufacturer="Aeotec", product_label="ZW100")])
)
assert nodes[0]["vendor"] == "Aeotec"
assert nodes[0]["model"] == "ZW100"
def test_lqi_is_none(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(9)]))
assert nodes[0]["lqi"] is None
def test_no_duplicate_nodes(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(1, routing=True), _node(1, routing=True)]))
assert len(nodes) == 1
def test_helper_keys_stripped(self) -> None:
nodes, _ = parse_zwave_nodes(_wrap([_node(1, neighbors=[2])]))
assert "neighbors" not in nodes[0]
assert "node_id" not in nodes[0]
class TestHierarchy:
def test_coordinator_router_enddevice_tree(self) -> None:
payload = _wrap([
_node(1, controller=True, neighbors=[2]),
_node(2, routing=True, neighbors=[1, 3]),
_node(3, neighbors=[2]),
])
nodes, edges = parse_zwave_nodes(payload)
by_id = {n["id"]: n for n in nodes}
assert by_id[_ieee(2)]["parent_id"] == _ieee(1)
assert by_id[_ieee(3)]["parent_id"] == _ieee(2)
pairs = {(e["source"], e["target"]) for e in edges}
assert pairs == {(_ieee(1), _ieee(2)), (_ieee(2), _ieee(3))}
def test_enddevice_without_router_falls_back_to_coordinator(self) -> None:
payload = _wrap([_node(1, controller=True), _node(3, neighbors=[])])
nodes, _ = parse_zwave_nodes(payload)
end = next(n for n in nodes if n["id"] == _ieee(3))
assert end["parent_id"] == _ieee(1)
def test_coordinator_has_no_incoming_edge(self) -> None:
payload = _wrap([
_node(1, controller=True, neighbors=[3]),
_node(3, neighbors=[1]),
])
_, edges = parse_zwave_nodes(payload)
assert all(e["target"] != _ieee(1) for e in edges)
def test_neighbor_to_unknown_node_dropped(self) -> None:
payload = _wrap([_node(1, controller=True, neighbors=[99])])
_, edges = parse_zwave_nodes(payload)
assert edges == []
def test_no_coordinator_means_no_edges(self) -> None:
payload = _wrap([_node(2, routing=True, neighbors=[3]), _node(3, neighbors=[2])])
_, edges = parse_zwave_nodes(payload)
assert edges == []
# ---------------------------------------------------------------------------
# build_zwave_properties
# ---------------------------------------------------------------------------
class TestBuildProperties:
def test_all_fields(self) -> None:
props = build_zwave_properties("zwave-x-1", "Aeotec", "ZW100")
keys = {p["key"]: p["value"] for p in props}
assert keys == {"Z-Wave ID": "zwave-x-1", "Vendor": "Aeotec", "Model": "ZW100"}
def test_omits_empty(self) -> None:
props = build_zwave_properties("zwave-x-1", None, None)
assert [p["key"] for p in props] == ["Z-Wave ID"]
def test_defaults_hidden(self) -> None:
props = build_zwave_properties("zwave-x-1", "V", "M")
assert all(p["visible"] is False for p in props)
def test_no_lqi_row(self) -> None:
props = build_zwave_properties("zwave-x-1", "V", "M")
assert all(p["key"] != "LQI" for p in props)
# ---------------------------------------------------------------------------
# fetch_zwave_network (mocked MQTT round-trip via mqtt_common)
# ---------------------------------------------------------------------------
_RESPONSE_TOPIC = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
_SAMPLE_PAYLOAD = {
"success": True,
"result": [
{"id": 1, "homeId": HOME, "isControllerNode": True, "name": "Controller"},
{"id": 2, "homeId": HOME, "isRouting": True, "name": "Wall Plug", "neighbors": [1]},
],
}
@pytest.mark.asyncio
async def test_fetch_zwave_network_success() -> None:
class _FakeMessage:
topic = _RESPONSE_TOPIC
payload = json.dumps(_SAMPLE_PAYLOAD).encode()
_yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _t: str) -> None:
pass
async def publish(self, _t: str, _p: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
nodes, edges = await fetch_zwave_network(mqtt_host="localhost", mqtt_port=1883)
assert any(n["type"] == "zwave_coordinator" for n in nodes)
assert any(n["type"] == "zwave_router" for n in nodes)
@pytest.mark.asyncio
async def test_fetch_zwave_network_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await fetch_zwave_network(mqtt_host="bad", mqtt_port=1883)