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
169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
"""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
|