"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert.""" from contextlib import asynccontextmanager from pathlib import Path import asyncio import os from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, Response, StreamingResponse from pydantic import BaseModel from .camera import CameraStream from .controller import Controller, MODES PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8") class ModeInput(BaseModel): mode: str async def require_ui(request: Request): if request.headers.get("X-Halloween-UI") != "1": raise HTTPException(status_code=403, detail="UI-Anfrage erforderlich") def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: directory = state_dir or Path(os.environ.get("HALLOWEEN_STATE_DIR", "data")) camera = CameraStream(demo=demo) controller = Controller(directory) @asynccontextmanager async def lifespan(app: FastAPI): camera.start() yield camera.stop() app = FastAPI(title="Halloween-Grab", lifespan=lifespan) app.state.camera = camera app.state.controller = controller @app.middleware("http") async def security_headers(request: Request, call_next): response = await call_next(request) response.headers["Cache-Control"] = "no-store" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "no-referrer" return response @app.get("/", response_class=HTMLResponse) async def index(): return PAGE @app.get("/api/status") async def status(): return {"controller": controller.status(), "camera": camera.status()} @app.post("/api/mode", dependencies=[Depends(require_ui)]) async def set_mode(payload: ModeInput): if payload.mode not in MODES: raise HTTPException(status_code=422, detail="Unbekannter Modus") try: controller.set_mode(payload.mode) except OSError as exc: raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc return controller.status() @app.post("/api/zone-clear", dependencies=[Depends(require_ui)]) async def zone_clear(): controller.zone_clear() return controller.status() @app.post("/api/test-trigger", dependencies=[Depends(require_ui)]) async def test_trigger(): accepted, reason = controller.trigger() return {"accepted": accepted, "reason": reason, "controller": controller.status()} @app.post("/api/stop", dependencies=[Depends(require_ui)]) async def stop(): controller.stop() return controller.status() @app.post("/api/reset", dependencies=[Depends(require_ui)]) async def reset(): controller.reset() return controller.status() @app.get("/snapshot.jpg") async def snapshot(): frame = camera.latest() if frame is None: raise HTTPException(status_code=503, detail="Noch kein Kamerabild") return Response( frame, media_type="image/jpeg", headers={"Content-Disposition": 'attachment; filename="halloween-snapshot.jpg"'}, ) @app.get("/stream.mjpg") async def stream(): if camera.error: raise HTTPException(status_code=503, detail=camera.error) async def frames(): last_number = 0 while True: number, frame = camera.latest_numbered() if camera.error: return if frame is None or number == last_number: await asyncio.sleep(0.1) continue last_number = number yield ( b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + str(len(frame)).encode("ascii") + b"\r\n\r\n" + frame + b"\r\n" ) await asyncio.sleep(0.1) return StreamingResponse( frames(), media_type="multipart/x-mixed-replace; boundary=frame" ) return app