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 ``/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