M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user