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:
@@ -0,0 +1,168 @@
|
||||
"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
|
||||
|
||||
Holds the credential-safe error sanitizer, the TLS context builder, and a
|
||||
generic request/response round-trip over MQTT used by gateway-style APIs
|
||||
(publish a request topic, wait for a single response topic message).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import aiomqtt
|
||||
except ImportError: # pragma: no cover
|
||||
aiomqtt = None # type: ignore[assignment]
|
||||
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are slow)
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
"""Return a generic, credential-free message for an MQTT error.
|
||||
|
||||
The raw aiomqtt/paho error string can include the broker URI with
|
||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||
detail that should not leak to API clients. Map known patterns to
|
||||
coarse categories; default to a generic failure message. The original
|
||||
exception is logged at WARNING level for operator debugging.
|
||||
"""
|
||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||
raw = str(exc).lower()
|
||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||
return "Authentication failed"
|
||||
if "refused" in raw:
|
||||
return "Connection refused by broker"
|
||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||
return "Broker hostname could not be resolved"
|
||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||
return "TLS handshake failed"
|
||||
if "timed out" in raw or "timeout" in raw:
|
||||
return "Connection to broker timed out"
|
||||
return "MQTT connection failed"
|
||||
|
||||
|
||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||
ctx = ssl.create_default_context()
|
||||
if insecure:
|
||||
logger.warning(
|
||||
"MQTT TLS certificate verification is DISABLED — "
|
||||
"use only with self-signed brokers on trusted networks."
|
||||
)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
async def request_response(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
request_topic: str,
|
||||
response_topic: str,
|
||||
request_payload: dict[str, Any],
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float = _RESPONSE_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
||||
JSON message received on ``response_topic`` as a dict.
|
||||
|
||||
Raises:
|
||||
ImportError: if aiomqtt is not installed.
|
||||
TimeoutError: if no response arrives in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is not valid JSON / is empty.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"aiomqtt is required for MQTT import. "
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
response_payload: dict[str, Any] = {}
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
await client.subscribe(response_topic)
|
||||
# Give the broker a brief window to register the subscription
|
||||
# before we publish the request. Without this, brokers that
|
||||
# race SUBACK with our PUBLISH may deliver the response before
|
||||
# the subscription is active and we'd hang until timeout.
|
||||
await asyncio.sleep(0.1)
|
||||
await client.publish(request_topic, json.dumps(request_payload))
|
||||
|
||||
async def _wait_for_response() -> None:
|
||||
async for message in client.messages:
|
||||
if str(message.topic) != response_topic:
|
||||
continue
|
||||
raw = message.payload
|
||||
try:
|
||||
payload_str = (
|
||||
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||
)
|
||||
response_payload.update(json.loads(payload_str))
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise ValueError(f"Malformed MQTT response: {exc}") from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
|
||||
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Timed out waiting for MQTT response") from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty MQTT response received")
|
||||
|
||||
return response_payload
|
||||
|
||||
|
||||
async def test_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||
|
||||
Returns True on success, raises ConnectionError/TimeoutError on failure.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError("aiomqtt is required")
|
||||
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
):
|
||||
return True
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Connection to broker timed out") from exc
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -20,42 +21,8 @@ _NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
"""Return a generic, credential-free message for an MQTT error.
|
||||
|
||||
The raw aiomqtt/paho error string can include the broker URI with
|
||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||
detail that should not leak to API clients. Map known patterns to
|
||||
coarse categories; default to a generic failure message. The original
|
||||
exception is logged at WARNING level for operator debugging.
|
||||
"""
|
||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||
raw = str(exc).lower()
|
||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||
return "Authentication failed"
|
||||
if "refused" in raw:
|
||||
return "Connection refused by broker"
|
||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||
return "Broker hostname could not be resolved"
|
||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||
return "TLS handshake failed"
|
||||
if "timed out" in raw or "timeout" in raw:
|
||||
return "Connection to broker timed out"
|
||||
return "MQTT connection failed"
|
||||
|
||||
|
||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||
ctx = ssl.create_default_context()
|
||||
if insecure:
|
||||
logger.warning(
|
||||
"MQTT TLS certificate verification is DISABLED — "
|
||||
"use only with self-signed brokers on trusted networks."
|
||||
)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
|
||||
|
||||
def build_zigbee_properties(
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
||||
|
||||
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
||||
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import request_response, test_connection
|
||||
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
||||
merge_zwave_properties = merge_zigbee_properties
|
||||
|
||||
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
||||
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
||||
|
||||
|
||||
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
||||
"""Map a Z-Wave node's role flags to a homelable node type.
|
||||
|
||||
Controller → coordinator. Mains-powered / routing nodes → router.
|
||||
Everything else (battery sensors, etc.) → end device.
|
||||
"""
|
||||
if raw.get("isControllerNode"):
|
||||
return "zwave_coordinator"
|
||||
if raw.get("isRouting"):
|
||||
return "zwave_router"
|
||||
return "zwave_enddevice"
|
||||
|
||||
|
||||
def _role_label(node_type: str) -> str:
|
||||
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
||||
return {
|
||||
"zwave_coordinator": "Controller",
|
||||
"zwave_router": "Router",
|
||||
"zwave_enddevice": "EndDevice",
|
||||
}.get(node_type, "EndDevice")
|
||||
|
||||
|
||||
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
||||
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
||||
node_id = raw.get("id")
|
||||
if node_id is None:
|
||||
return None
|
||||
ieee = f"zwave-{home_id}-{node_id}"
|
||||
node_type = _zwave_type_to_homelable(raw)
|
||||
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
||||
model = raw.get("productLabel") or raw.get("productDescription") or None
|
||||
vendor = raw.get("manufacturer") or None
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": name,
|
||||
"type": node_type,
|
||||
"ieee_address": ieee,
|
||||
"friendly_name": name,
|
||||
"device_type": _role_label(node_type),
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"vendor": vendor,
|
||||
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
||||
"parent_id": None,
|
||||
"neighbors": raw.get("neighbors") or [],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
||||
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
||||
controller_home = None
|
||||
for entry in raw_nodes:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
home = entry.get("homeId")
|
||||
if home is None:
|
||||
continue
|
||||
if entry.get("isControllerNode"):
|
||||
return str(home)
|
||||
if controller_home is None:
|
||||
controller_home = str(home)
|
||||
return controller_home or "0"
|
||||
|
||||
|
||||
def parse_zwave_nodes(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
||||
|
||||
Expected shape::
|
||||
|
||||
{"success": true, "result": [ {<node>}, ... ]}
|
||||
|
||||
Edges are a strict coordinator → router → end-device tree, derived from
|
||||
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
||||
"""
|
||||
if payload.get("success") is False:
|
||||
raise ValueError("Z-Wave gateway reported failure")
|
||||
|
||||
result = payload.get("result")
|
||||
if result is None:
|
||||
result = []
|
||||
if not isinstance(result, list):
|
||||
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
||||
|
||||
home_id = _resolve_home_id(result)
|
||||
|
||||
nodes_list: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
coordinator_id: str | None = None
|
||||
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
||||
id_by_node_id: dict[Any, str] = {}
|
||||
|
||||
for entry in result:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
node = _node_from_zwave(entry, home_id)
|
||||
if node is None or node["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(node["id"])
|
||||
id_by_node_id[node["node_id"]] = node["id"]
|
||||
nodes_list.append(node)
|
||||
if node["type"] == "zwave_coordinator":
|
||||
coordinator_id = node["id"]
|
||||
|
||||
# Translate neighbor lists into candidate edges (only between known nodes).
|
||||
raw_edges: list[dict[str, Any]] = []
|
||||
for node in nodes_list:
|
||||
src = node["id"]
|
||||
for neighbor in node.get("neighbors") or []:
|
||||
tgt = id_by_node_id.get(neighbor)
|
||||
if tgt and tgt != src:
|
||||
raw_edges.append({"source": src, "target": tgt})
|
||||
|
||||
# Build parent_id hierarchy: coordinator → routers → end devices.
|
||||
if coordinator_id:
|
||||
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
||||
for node in nodes_list:
|
||||
if node["type"] == "zwave_router":
|
||||
node["parent_id"] = coordinator_id
|
||||
elif node["type"] == "zwave_enddevice":
|
||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||
node["parent_id"] = parent or coordinator_id
|
||||
|
||||
# Final edges = strict parent → child tree (one edge per non-coordinator).
|
||||
edges_list: list[dict[str, Any]] = [
|
||||
{"source": node["parent_id"], "target": node["id"]}
|
||||
for node in nodes_list
|
||||
if node.get("parent_id")
|
||||
]
|
||||
|
||||
# Drop transient helper keys before returning.
|
||||
for node in nodes_list:
|
||||
node.pop("neighbors", None)
|
||||
node.pop("node_id", None)
|
||||
|
||||
return nodes_list, edges_list
|
||||
|
||||
|
||||
def build_zwave_properties(
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
||||
|
||||
Z-Wave has no LQI, so that row is omitted. New props default to
|
||||
``visible=False`` — users opt in from the right panel.
|
||||
"""
|
||||
props: list[dict[str, Any]] = []
|
||||
if ieee:
|
||||
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
|
||||
if vendor:
|
||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||
if model:
|
||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||
return props
|
||||
|
||||
|
||||
async def fetch_zwave_network(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str = "zwave",
|
||||
gateway_name: str = "zwavejs2mqtt",
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the gateway does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is malformed.
|
||||
"""
|
||||
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
|
||||
payload = await request_response(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
request_topic=request_topic,
|
||||
response_topic=response_topic,
|
||||
request_payload={"args": []},
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
|
||||
return parse_zwave_nodes(payload)
|
||||
|
||||
|
||||
async def test_zwave_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Quick MQTT reachability check for the Z-Wave broker."""
|
||||
return await test_connection(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
Reference in New Issue
Block a user