8faf5c1c79
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
236 lines
7.9 KiB
Python
236 lines
7.9 KiB
Python
"""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,
|
|
)
|