Files
home-automation/app/api/routes/api/config.py
T
tliu93 8d4f496ff4 DSMR hardening: restart-free config + decouple from telegram id
Config hot-reload (no restart):
- MqttManager.unsubscribe(); apply_dsmr_subscription(settings) re-applies the
  DSMR subscription (subscribe/unsubscribe/topic-change, fresh snapshot so the
  sample interval also takes effect).
- Called from lifespan AND PUT /api/config, so toggling DSMR ingest in the UI
  takes effect immediately without restarting the app.

Decouple ingest from the DSMR telegram id (overflows / resets to zero):
- Migration 20260624_12: drop UNIQUE(source_id), make recorded_at UNIQUE.
- Idempotency now keys on recorded_at (telegram timestamp); the table's own
  autoincrement PK is the stable identity. source_id kept only as a reference.
- Regression test: colliding telegram ids no longer drop new data.
2026-06-24 11:02:32 +02:00

313 lines
11 KiB
Python

from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
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
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-config"])
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
result = []
for section in sections_raw:
fields = [ConfigField(**f) for f in section["fields"]]
result.append(ConfigSection(name=section["name"], fields=fields))
return result
@router.get("/config", response_model=ConfigResponse)
def get_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
) -> ConfigResponse:
"""Return all configuration sections. Secret field values are masked (empty string)."""
sections_raw = build_config_sections(db, settings)
return ConfigResponse(sections=_sections_from_raw(sections_raw))
@router.put("/config", response_model=ConfigUpdateResponse)
def put_config(
body: ConfigUpdateRequest,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ConfigUpdateResponse:
"""
Save configuration updates.
- 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:
logger.warning("Rejected config update via API: %s", exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid config submission",
) from exc
# Re-read settings after save (save_config_updates clears the settings cache).
# Use build_runtime_settings so the reconnect picks up DB-stored values, not
# just the bootstrap env (otherwise a broker configured via the UI is ignored).
from app.services.config_page import build_runtime_settings
refreshed_settings = build_runtime_settings(db, 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)
# Re-apply the DSMR subscription so enabling/disabling DSMR ingest (or changing
# its topic / sample interval) takes effect immediately, without an app restart.
# Done after any MQTT reconnect so it operates on the current client.
from app.services.dsmr_ingest import apply_dsmr_subscription
apply_dsmr_subscription(refreshed_settings)
sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@router.post(
"/config/smtp/test",
responses={
200: {"model": SmtpTestResponse},
400: {"model": SmtpTestResponse},
502: {"model": SmtpTestResponse},
},
)
def post_smtp_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.
"""
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except EmailDeliveryError as exc:
logger.warning("SMTP test delivery 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": "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