"""EMS FastAPI – health, provozní režimy, PostgREST doplňky.""" from __future__ import annotations import logging import os from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Annotated import asyncpg import httpx from app.deps import set_pg_pool from app.routers.plan import router as plan_router from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field logger = logging.getLogger(__name__) def _dsn() -> str: host = os.getenv("DB_HOST", "localhost") port = os.getenv("DB_PORT", "5432") name = os.getenv("DB_NAME", "ems") user = os.getenv("DB_USER", "ems_user") password = os.getenv("DB_PASSWORD", "") return f"postgresql://{user}:{password}@{host}:{port}/{name}" pool: asyncpg.Pool | None = None @asynccontextmanager async def lifespan(app: FastAPI): global pool pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5) set_pg_pool(pool) yield set_pg_pool(None) if pool: await pool.close() pool = None app = FastAPI(title="EMS Platform", lifespan=lifespan) app.include_router(plan_router, prefix="/api/v1") app.add_middleware( CORSMiddleware, allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(","), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) async def get_pool() -> asyncpg.Pool: if pool is None: raise HTTPException(status_code=503, detail="Database pool not ready") return pool @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"} class SetSiteModeBody(BaseModel): mode: str = Field(..., min_length=1) notes: str | None = None valid_until: datetime | None = None class SetSiteModeResponse(BaseModel): success: bool mode: str activated_at: datetime @app.post("/api/v1/sites/{site_id}/mode", response_model=SetSiteModeResponse) async def set_site_mode( site_id: int, body: SetSiteModeBody, db: Annotated[asyncpg.Pool, Depends(get_pool)], ) -> SetSiteModeResponse: mode = body.mode.strip().upper() allowed = {"AUTO", "SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE", "MANUAL"} if mode not in allowed: raise HTTPException(status_code=400, detail=f"Unsupported mode: {body.mode}") async with db.acquire() as conn: site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) if not site_ok: raise HTTPException(status_code=404, detail="Site not found") try: await conn.execute( "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", site_id, mode, "user:api", body.valid_until, body.notes, ) except asyncpg.PostgresError as e: logger.warning("fn_set_mode failed: %s", e) raise HTTPException(status_code=400, detail=str(e)) from e row = await conn.fetchrow( """ SELECT m.mode_code, m.activated_at, d.loxone_mode_value FROM ems.site_operating_mode m JOIN ems.operating_mode_def d ON d.code = m.mode_code WHERE m.site_id = $1 """, site_id, ) if row is None: raise HTTPException(status_code=500, detail="Mode row missing after set") ep = await conn.fetchrow( """ SELECT host, port, protocol FROM ems.site_endpoint WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true ORDER BY id LIMIT 1 """, site_id, ) activated_at: datetime = row["activated_at"] if activated_at.tzinfo is None: activated_at = activated_at.replace(tzinfo=timezone.utc) loxone_val: int | None = row["loxone_mode_value"] if ep and loxone_val is not None: proto = (ep["protocol"] or "http").lower() if proto not in ("http", "https"): proto = "http" host = ep["host"] port = int(ep["port"] or (443 if proto == "https" else 80)) base = f"{proto}://{host}:{port}" url = f"{base}/dev/sps/io/EMS_Mode/{loxone_val}" user = os.getenv("LOXONE_USER") or "" password = os.getenv("LOXONE_PASSWORD") or "" auth = (user, password) if user else None try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.get(url, auth=auth) r.raise_for_status() except Exception as e: logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e) return SetSiteModeResponse(success=True, mode=row["mode_code"], activated_at=activated_at)