M5-T10: add paho MQTT client with lifespan connect/reconnect and mqtt/test endpoint
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user