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.
313 lines
11 KiB
Python
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
|