Initial commit
Made-with: Cursor
This commit is contained in:
157
backend/app/main.py
Normal file
157
backend/app/main.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user