Files
home-automation/app/main.py
T

220 lines
8.3 KiB
Python

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.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 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
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_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.
"""
session_local = get_session_local()
session: Session = session_local()
try:
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,
)
scheduler.start()
# MQTT: connect if configured.
# 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())
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_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()