import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy.orm import Session from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router from app.api.routes.api.data import router as api_data_router from app.api.routes.api.energy_contracts import router as api_energy_contracts_router from app.api.routes.api.expose import router as api_expose_router from app.api.routes.api.modbus import router as api_modbus_router from app.api.routes.api.session import router as api_session_router from app.api.routes import status from app.db import get_session_local from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router from app.api.routes.public_ip import router as public_ip_router 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 build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from app.services.dsmr_ingest import handle_message as dsmr_handle_message 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 from app.services.tibber_prices import refresh_prices from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db logger = logging.getLogger(__name__) _REPO_ROOT = Path(__file__).resolve().parents[1] def _get_spa_dist_dir() -> Path: env_val = os.environ.get("SPA_DIST_DIR") if env_val: return Path(env_val) return _REPO_ROOT / "frontend" / "dist" def _run_scheduled_public_ip_check() -> None: session_local = get_session_local() session: Session = session_local() try: check_public_ipv4_and_notify(session, bootstrap_settings=get_settings()) finally: session.close() def _run_scheduled_modbus_poll() -> None: session_local = get_session_local() session: Session = session_local() try: poll_all_enabled_devices(session, bootstrap_settings=get_settings()) finally: session.close() def _run_scheduled_tibber_refresh() -> None: """Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price. Runs every hour so that: - Today's prices are available from startup. - Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are picked up within an hour of publication without requiring a server restart. The job is a no-op when: - No active energy contract with kind="tibber" exists. - The Tibber API token is empty in the runtime settings. Any client exceptions (auth failures, network errors) are caught and logged so that a single failed fetch does not crash the scheduler or affect the other background jobs. """ session_local = get_session_local() session: Session = session_local() try: runtime_settings = build_runtime_settings(session, get_settings()) refresh_prices(session, runtime_settings) except Exception: logger.exception("_run_scheduled_tibber_refresh: unexpected error") finally: session.close() def _run_scheduled_ha_state_publish() -> None: """Periodic job: publish discovery configs + state + availability for all enabled exposed entities. Runs every 60 seconds. When the MQTT broker is connected: - Publishes (or re-publishes) all HA Discovery configs (retained, idempotent). - Publishes the current state / availability for all enabled entities. This also serves as the reliable "publish discovery after connect" mechanism: because paho connects asynchronously, a synchronous call immediately after ``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: logger.exception("_run_scheduled_ha_state_publish: unexpected error") finally: session.close() def ensure_auth_db_ready() -> None: session_local = get_session_local() session: Session = session_local() try: validate_app_runtime_db(get_settings().app_database_url) initialize_auth_schema(session, get_settings()) seed_missing_config_from_bootstrap(session, get_settings()) sync_app_hostname_from_bootstrap(session, get_settings()) except AppDatabaseAdoptionError as exc: raise RuntimeError(str(exc)) from exc except AuthBootstrapError as exc: raise RuntimeError(str(exc)) from exc finally: session.close() def ensure_runtime_dirs() -> None: settings = get_settings() if settings.app_sqlite_path is not None: settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True) @asynccontextmanager async def lifespan(_: FastAPI): ensure_runtime_dirs() ensure_auth_db_ready() scheduler = BackgroundScheduler(timezone="UTC") scheduler.add_job( _run_scheduled_public_ip_check, trigger=IntervalTrigger(hours=4), id="public-ip-check", replace_existing=True, max_instances=1, coalesce=True, ) scheduler.add_job( _run_scheduled_modbus_poll, trigger=IntervalTrigger(seconds=BASE_POLL_TICK_SECONDS), id="modbus-poll", replace_existing=True, max_instances=1, coalesce=True, ) # Periodic HA state / availability publish (60-second fallback sweep). scheduler.add_job( _run_scheduled_ha_state_publish, trigger=IntervalTrigger(seconds=60), id="ha-state-publish", replace_existing=True, max_instances=1, coalesce=True, ) # Tibber price refresh: fetch today + tomorrow every hour. # The job is a no-op when no active tibber contract or token is configured, # so it is safe to register unconditionally. scheduler.add_job( _run_scheduled_tibber_refresh, trigger=IntervalTrigger(hours=1), id="tibber-refresh", replace_existing=True, max_instances=1, coalesce=True, ) scheduler.start() # 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. _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) # DSMR ingest: subscribe to the configured MQTT topic when enabled. # The handler captures a snapshot of the current runtime settings (for the # dsmr_sample_interval_s value). Runtime setting changes take effect after # a server restart or an explicit reconnect — consistent with other MQTT # configuration in this project. if _startup_runtime_settings.dsmr_ingest_enabled: _dsmr_topic = _startup_runtime_settings.dsmr_mqtt_topic _dsmr_settings_snapshot = _startup_runtime_settings mqtt_manager.subscribe( _dsmr_topic, lambda payload: dsmr_handle_message(payload, _dsmr_settings_snapshot), ) logger.info("DSMR ingest enabled — subscribed to topic=%s.", _dsmr_topic) else: logger.debug("DSMR ingest disabled — not subscribing to DSMR topic.") yield # MQTT: clean shutdown before the process exits. mqtt_manager.disconnect() scheduler.shutdown(wait=False) def create_app() -> FastAPI: settings = get_settings() app = FastAPI( title=settings.app_name, debug=settings.app_debug, version="0.1.0", lifespan=lifespan, description=( "Home automation backend with auth, runtime config, Home Assistant " "integrations, TickTick integration, and SQLite-backed recorders." ), ) static_dir = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(status.router) app.include_router(api_config_router) app.include_router(api_data_router) app.include_router(api_energy_contracts_router) app.include_router(api_expose_router) app.include_router(api_modbus_router) app.include_router(api_session_router) app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) app.include_router(public_ip_router) app.include_router(ticktick_router) # SPA hosting: mount frontend/dist if it exists and has index.html. # If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely # so that pytest stays green with only the API routes registered. spa_dist = _get_spa_dist_dir() spa_index = spa_dist / "index.html" if spa_dist.is_dir() and spa_index.is_file(): spa_assets = spa_dist / "assets" if spa_assets.is_dir(): app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets") # Resolve the dist root once so the containment check is fast and consistent. _spa_root = spa_dist.resolve() @app.get("/{full_path:path}", include_in_schema=False) async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029 # Explicit 404 for unmatched /api/* — never return index.html for API paths. if full_path.startswith("api/"): raise HTTPException(status_code=404, detail="not found") # Resolve candidate to an absolute path and verify it stays within the SPA # dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f") # bypass Starlette's path parameter handling and allow arbitrary file reads. candidate = (spa_dist / full_path).resolve() if candidate.is_file() and candidate.is_relative_to(_spa_root): return FileResponse(candidate) # For any path outside the dist root, or for SPA client routes that don't # correspond to a real file, return index.html so the SPA router handles it. return FileResponse(spa_index) else: logger.warning( "SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist ) return app app = create_app()