M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion

This commit is contained in:
2026-06-22 20:11:54 +02:00
parent 75d685f04d
commit 35cdc7f22a
11 changed files with 502 additions and 59 deletions
+5 -2
View File
@@ -75,8 +75,11 @@ def put_config(
detail="invalid config submission",
) from exc
# Re-read settings after save (save_config_updates clears the settings cache)
refreshed_settings = get_settings()
# 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:
+6 -5
View File
@@ -23,6 +23,7 @@ from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import get_settings
from app.dependencies import get_db
from app.services.config_page import build_runtime_settings
from app.integrations.expose import CatalogEntry, build_catalog
from app.integrations.mqtt import mqtt_manager
from app.models.expose import ExposedEntityToggle
@@ -48,9 +49,9 @@ router = APIRouter(prefix="/api", tags=["api-expose"])
# ---------------------------------------------------------------------------
def _get_mqtt_status() -> MqttStatusSchema:
"""Build MQTT/Discovery status from live runtime state."""
settings = get_settings()
def _get_mqtt_status(db: Session) -> MqttStatusSchema:
"""Build MQTT/Discovery status from live runtime state (DB-merged settings)."""
settings = build_runtime_settings(db, get_settings())
return MqttStatusSchema(
mqtt_configured=mqtt_manager.is_configured(settings),
mqtt_connected=mqtt_manager.is_connected,
@@ -87,7 +88,7 @@ def _build_response_data(
"""Return (catalog_entries, mqtt_status) for building GET / PUT responses."""
catalog = build_catalog(session)
catalog_schema = [_entry_to_schema(e) for e in catalog]
mqtt_status = _get_mqtt_status()
mqtt_status = _get_mqtt_status(session)
return catalog_schema, mqtt_status
@@ -179,7 +180,7 @@ def post_expose_republish(
Returns a status indicating whether the publish was attempted (or skipped
because MQTT / discovery is not enabled / connected).
"""
settings = get_settings()
settings = build_runtime_settings(db, get_settings())
if not (settings.mqtt_enabled and settings.ha_discovery_enabled and mqtt_manager.is_connected):
return RepublishResponse(
ok=False,
+19 -3
View File
@@ -26,7 +26,7 @@ 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.config_page import build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.public_ip import check_public_ipv4_and_notify
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
from app.services.ha_discovery import publish_discovery, publish_states
@@ -74,10 +74,19 @@ def _run_scheduled_ha_state_publish() -> None:
``mqtt_manager.connect()`` would fire before the TCP handshake completes and
be a no-op. Instead, this periodic job picks it up within 60 seconds of the
broker becoming available — retained payloads make repeated publishes harmless.
Additionally, if MQTT is configured in DB but the manager is not yet connected
(e.g. MQTT was enabled via UI after startup), this job attempts to connect so
the user does not need to restart the server.
"""
session_local = get_session_local()
session: Session = session_local()
try:
runtime_settings = build_runtime_settings(session, get_settings())
# Reconnect if MQTT is configured (in DB) but not yet connected.
if mqtt_manager.is_configured(runtime_settings) and not mqtt_manager.is_connected:
logger.info("_run_scheduled_ha_state_publish: MQTT configured but not connected — attempting connect.")
mqtt_manager.connect(runtime_settings)
publish_discovery(session)
publish_states(session)
except Exception:
@@ -140,10 +149,17 @@ async def lifespan(_: FastAPI):
)
scheduler.start()
# MQTT: connect if configured.
# MQTT: connect using DB-merged runtime settings so broker configured via UI
# is picked up on restart (not just from env/bootstrap settings).
# Discovery will be published by the first run of _run_scheduled_ha_state_publish
# (within 60 s of startup), after the async paho handshake completes.
mqtt_manager.connect(get_settings())
_startup_session_local = get_session_local()
_startup_session: Session = _startup_session_local()
try:
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
finally:
_startup_session.close()
mqtt_manager.connect(_startup_runtime_settings)
yield
+10 -11
View File
@@ -35,6 +35,7 @@ from sqlalchemy.orm import Session
from app.integrations.expose import ExposableEntity, build_catalog
from app.integrations.mqtt import mqtt_manager
from app.services.config_page import build_runtime_settings
logger = logging.getLogger(__name__)
@@ -44,12 +45,6 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
def _get_settings() -> Any:
"""Return the current runtime settings (cached singleton)."""
from app.config import get_settings
return get_settings()
def _should_publish(settings: Any) -> bool:
"""Return True only if MQTT and HA Discovery are both enabled and connected."""
return bool(
@@ -180,7 +175,8 @@ def publish_discovery(session: Session) -> None:
No-op if MQTT / discovery is not enabled or the client is not connected.
All MQTT errors are caught internally — this function never raises.
"""
settings = _get_settings()
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
return
@@ -232,7 +228,8 @@ def publish_states(session: Session) -> None:
No-op if MQTT / discovery is not enabled or the client is not connected.
All errors are caught internally — this function never raises.
"""
settings = _get_settings()
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
return
@@ -320,7 +317,8 @@ def publish_device_state(session: Session, device: Any) -> None:
No-op if MQTT / discovery is not enabled or the client is not connected.
All errors are caught internally — never raises.
"""
settings = _get_settings()
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
return
@@ -386,7 +384,7 @@ def publish_device_state(session: Session, device: Any) -> None:
)
def publish_device_offline(device_uuid: str) -> None:
def publish_device_offline(session: Session, device_uuid: str) -> None:
"""Publish an "offline" availability payload for *device_uuid*.
Called by ``modbus_poll.poll_device`` when a poll fails, to immediately
@@ -395,7 +393,8 @@ def publish_device_offline(device_uuid: str) -> None:
No-op if MQTT / discovery is not enabled or the client is not connected.
Never raises.
"""
settings = _get_settings()
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
return
+1 -1
View File
@@ -132,7 +132,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
# Best-effort: publish "offline" availability after failed poll.
try:
publish_device_offline(device.uuid)
publish_device_offline(session, device.uuid)
except Exception: # noqa: BLE001
logger.exception(
"poll_device: MQTT offline publish failed for device %r (id=%d)",