M5-T10: add paho MQTT client with lifespan connect/reconnect and mqtt/test endpoint

This commit is contained in:
2026-06-22 15:06:30 +02:00
parent 360cddc05b
commit 9d05f5ea62
10 changed files with 1072 additions and 3 deletions
+184
View File
@@ -9,12 +9,14 @@ from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager
from app.schemas.config import (
ConfigField,
ConfigResponse,
ConfigSection,
ConfigUpdateRequest,
ConfigUpdateResponse,
MqttTestResponse,
SmtpTestResponse,
)
from app.services.auth import AuthenticatedSession
@@ -58,7 +60,12 @@ def put_config(
- Blank secret value keeps the existing stored value (no change).
- Invalid values return 422 and nothing is written to the database.
- If MQTT-related settings changed, the MQTT client reconnects automatically.
"""
# Detect whether any MQTT-related key is being submitted (non-secret change
# or non-blank secret change) so we know to reconnect after saving.
mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates)
try:
save_config_updates(db, body.updates, settings)
except ConfigSaveError as exc:
@@ -70,6 +77,12 @@ def put_config(
# Re-read settings after save (save_config_updates clears the settings cache)
refreshed_settings = get_settings()
# Reconnect MQTT client if any MQTT setting was updated.
if mqtt_keys_submitted:
logger.info("MQTT settings changed — triggering reconnect.")
mqtt_manager.reconnect(refreshed_settings)
sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@@ -117,3 +130,174 @@ def post_smtp_test(
status_code=status.HTTP_200_OK,
content={"result": "success", "message": "Test email sent successfully."},
)
# ---------------------------------------------------------------------------
# POST /api/config/mqtt/test — M5-T10
# ---------------------------------------------------------------------------
class _MqttConfigurationError(ValueError):
"""Raised when MQTT settings are incomplete or disabled."""
class _MqttConnectionError(RuntimeError):
"""Raised when MQTT broker connection or publish fails."""
@router.post(
"/config/mqtt/test",
responses={
200: {"model": MqttTestResponse},
400: {"model": MqttTestResponse},
502: {"model": MqttTestResponse},
},
)
def post_mqtt_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Test MQTT broker connectivity by attempting to connect and publishing a
test message to ``<ha_discovery_prefix>/home-automation/test``.
The message is visible in MQTT Explorer (or any subscriber) so users can
confirm the full broker publish path is working.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (not configured)
- 502 { "result": "failed", "message": ... } (connection/publish error)
MQTT credentials are never echoed in the response.
"""
try:
_run_mqtt_test(settings)
except _MqttConfigurationError as exc:
logger.warning("MQTT test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except _MqttConnectionError as exc:
logger.warning("MQTT test connection/publish failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"result": "success",
"message": (
f"Test message published to "
f"{settings.ha_discovery_prefix}/home-automation/test."
),
},
)
def _run_mqtt_test(settings: Settings) -> None:
"""Attempt a transient MQTT connection and publish a test message.
Raises
------
_MqttConfigurationError
When MQTT is not enabled or broker host is not configured.
_MqttConnectionError
When the broker is unreachable or the publish fails.
"""
import socket
import paho.mqtt.client as mqtt
if not settings.mqtt_broker_host:
raise _MqttConfigurationError("MQTT broker host is not configured.")
test_topic = f"{settings.ha_discovery_prefix}/home-automation/test"
test_payload = '{"source": "home-automation", "event": "mqtt_test"}'
connected_event = __import__("threading").Event()
published_event = __import__("threading").Event()
connect_error: list[str] = []
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="home-automation-test",
)
def _on_connect(
_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
if reason_code.is_failure:
connect_error.append(f"Broker refused connection: {reason_code}")
connected_event.set()
def _on_publish(
_client: mqtt.Client,
_userdata: object,
_mid: int,
_reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
published_event.set()
client.on_connect = _on_connect
client.on_publish = _on_publish
if settings.mqtt_tls_enabled:
try:
client.tls_set()
except Exception as exc:
raise _MqttConnectionError(f"TLS setup failed: {exc}") from exc
if settings.mqtt_username:
client.username_pw_set(
username=settings.mqtt_username,
password=settings.mqtt_password or None,
)
client.loop_start()
try:
try:
client.connect(
host=settings.mqtt_broker_host,
port=settings.mqtt_broker_port,
keepalive=10,
)
except (OSError, socket.error) as exc:
# Sanitise: ensure password never leaks into the error message.
msg = _sanitize_mqtt_error(str(exc), settings.mqtt_password)
raise _MqttConnectionError(f"Cannot reach broker: {msg}") from exc
# Wait up to 5 s for connection acknowledgement.
if not connected_event.wait(timeout=5):
raise _MqttConnectionError("Broker connection timed out (5 s).")
if connect_error:
raise _MqttConnectionError(connect_error[0])
# Publish test message.
client.publish(test_topic, payload=test_payload, qos=1, retain=False)
# Wait up to 5 s for the publish ACK (QoS 1).
if not published_event.wait(timeout=5):
raise _MqttConnectionError("Publish timed out (5 s) — broker reachable but no ACK.")
finally:
try:
client.disconnect()
except Exception:
pass
client.loop_stop()
def _sanitize_mqtt_error(message: str, password: str | None) -> str:
"""Replace *password* in *message* with ``[redacted]``."""
if password:
return message.replace(password, "[redacted]")
return message
+257
View File
@@ -0,0 +1,257 @@
"""MQTT client integration (paho-mqtt 2.x).
Provides :class:`MqttManager`: a long-lived MQTT client that runs paho's
loop in a background thread. Designed to be started in FastAPI's lifespan
and shared across the application as a module-level singleton (``mqtt_manager``).
Key design decisions
--------------------
- **paho 2.x API**: ``Client`` requires ``CallbackAPIVersion.VERSION2`` as the
first positional argument. VERSION2 on_connect callback receives
``(client, userdata, connect_flags, reason_code, properties)`` — not the
older RC int.
- **Background thread**: ``loop_start()`` spawns a daemon thread; ``loop_stop()``
joins it cleanly on shutdown.
- **Never crash the process**: all paho operations are wrapped in try/except;
connection failure is logged but does not propagate to the caller.
- **Password safety**: the MQTT password is never passed to ``logger`` calls.
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
establishes a fresh connection with the new settings. Callers (e.g. PUT
/api/config) call this after saving MQTT-related config values.
"""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING
import paho.mqtt.client as mqtt
if TYPE_CHECKING:
from app.config import Settings
logger = logging.getLogger(__name__)
# MQTT settings keys that, when changed, should trigger a reconnect.
MQTT_SETTINGS_KEYS = {
"mqtt_enabled",
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_tls_enabled",
}
def _is_configured(settings: Settings) -> bool:
"""Return True if MQTT is enabled *and* the broker host is set."""
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
class MqttManager:
"""Long-lived MQTT client wrapper.
Lifecycle
---------
1. ``connect(settings)`` — start background loop + connect to broker.
2. ``publish(...)`` — publish messages while connected.
3. ``disconnect()`` — graceful teardown (called on app shutdown).
4. ``reconnect(settings)`` — disconnect then re-connect with new settings
(called after saving updated MQTT configuration).
When MQTT is not enabled or not configured, all methods are no-ops.
Connection failures are caught and logged; they do not raise.
"""
def __init__(self) -> None:
self._client: mqtt.Client | None = None
self._lock = threading.Lock()
self._connected = False
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""True if the underlying paho client is currently connected."""
return self._connected
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def is_configured(self, settings: Settings) -> bool:
"""Return True if MQTT is enabled and broker host is configured."""
return _is_configured(settings)
def connect(self, settings: Settings) -> None:
"""Connect to the MQTT broker and start the background loop thread.
No-op if MQTT is not enabled or broker host is not set.
Connection errors are logged but do not raise.
"""
if not _is_configured(settings):
logger.debug("MQTT not configured or not enabled — skipping connect.")
return
with self._lock:
self._start_client(settings)
def disconnect(self) -> None:
"""Disconnect from the broker and stop the background loop thread.
No-op if no client is active.
"""
with self._lock:
self._stop_client()
def reconnect(self, settings: Settings) -> None:
"""Disconnect the current client (if any) and reconnect with *settings*.
Call this after saving updated MQTT configuration values.
No-op if MQTT is not enabled or broker host is not set.
"""
with self._lock:
self._stop_client()
self.connect(settings)
def publish(
self,
topic: str,
payload: str | bytes | None,
*,
retain: bool = False,
qos: int = 0,
) -> None:
"""Publish a message to *topic*.
If the client is not connected the call is silently skipped.
Errors are logged but do not raise.
"""
with self._lock:
client = self._client
if client is None or not self._connected:
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
return
try:
client.publish(topic, payload=payload, qos=qos, retain=retain)
except Exception:
logger.exception("MQTT publish error (topic=%s).", topic)
# ------------------------------------------------------------------
# Internal helpers — must be called with self._lock held
# ------------------------------------------------------------------
def _start_client(self, settings: Settings) -> None:
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="home-automation",
)
# Callbacks — VERSION2 on_connect signature:
# (client, userdata, connect_flags, reason_code, properties)
def _on_connect(
_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
if reason_code.is_failure:
logger.warning("MQTT connection refused: %s", reason_code)
self._connected = False
else:
logger.info("MQTT connected (reason_code=%s).", reason_code)
self._connected = True
# VERSION2 on_disconnect signature:
# (client, userdata, disconnect_flags, reason_code, properties)
def _on_disconnect(
_client: mqtt.Client,
_userdata: object,
_disconnect_flags: mqtt.DisconnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
self._connected = False
if reason_code.is_failure:
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
else:
logger.info("MQTT disconnected cleanly.")
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
# TLS
if settings.mqtt_tls_enabled:
try:
client.tls_set()
except Exception:
logger.exception("MQTT TLS setup failed.")
return
# Credentials — never log the password
if settings.mqtt_username:
client.username_pw_set(
username=settings.mqtt_username,
password=settings.mqtt_password or None,
)
# Start background loop thread *before* connect so paho can handle
# the connection handshake asynchronously.
client.loop_start()
try:
client.connect(
host=settings.mqtt_broker_host,
port=settings.mqtt_broker_port,
keepalive=60,
)
except Exception:
# Log without leaking the password
logger.exception(
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
settings.mqtt_broker_host,
settings.mqtt_broker_port,
)
try:
client.loop_stop()
except Exception:
pass
return
self._client = client
logger.info(
"MQTT client started (host=%s, port=%s).",
settings.mqtt_broker_host,
settings.mqtt_broker_port,
)
def _stop_client(self) -> None:
"""Disconnect and stop the background loop. Idempotent."""
client = self._client
if client is None:
return
self._client = None
self._connected = False
try:
client.disconnect()
except Exception:
logger.debug("MQTT disconnect raised (ignoring).", exc_info=True)
try:
client.loop_stop()
except Exception:
logger.debug("MQTT loop_stop raised (ignoring).", exc_info=True)
logger.info("MQTT client stopped.")
# ---------------------------------------------------------------------------
# Module-level singleton — shared across lifespan and route handlers
# ---------------------------------------------------------------------------
mqtt_manager = MqttManager()
+9
View File
@@ -23,6 +23,7 @@ from app.api.routes.poo import router as poo_router
from app.api.routes.public_ip import router as public_ip_router
from app.api.routes.ticktick import router as ticktick_router
from app.config import get_settings
from app.integrations.mqtt import mqtt_manager
from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.public_ip import check_public_ipv4_and_notify
@@ -103,7 +104,15 @@ async def lifespan(_: FastAPI):
coalesce=True,
)
scheduler.start()
# MQTT: connect if configured.
mqtt_manager.connect(get_settings())
yield
# MQTT: clean shutdown before the process exits.
mqtt_manager.disconnect()
scheduler.shutdown(wait=False)
+7
View File
@@ -38,3 +38,10 @@ class SmtpTestResponse(BaseModel):
result: Literal["success", "config-error", "failed"]
message: str
class MqttTestResponse(BaseModel):
"""Response from POST /api/config/mqtt/test."""
result: Literal["success", "config-error", "failed"]
message: str