"""Lokale WebUI und MJPEG-Livebild; Grabaktoren simuliert, Kameraservos separat freigebbar.""" from contextlib import asynccontextmanager, suppress from pathlib import Path import asyncio import logging import os import sqlite3 from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.responses import HTMLResponse, Response, StreamingResponse from pydantic import BaseModel, Field from .camera import CameraStream from .controller import Controller, MODES from .event_store import EventStore from .detection import DetectionService from .follow import FollowService PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8") CAMERA_PAGE = (Path(__file__).parent / "camera.html").read_text(encoding="utf-8") PROFILE_CLEANUP_INTERVAL_SECONDS = 3600 LOGGER = logging.getLogger(__name__) class ModeInput(BaseModel): mode: str class ProfileInput(BaseModel): label: str = Field(min_length=1, max_length=80) class ProfileAssignment(BaseModel): profile_id: int | None 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) events = EventStore(directory) detection = DetectionService(camera, controller, events, directory) follow = FollowService(camera, directory) follow_api_lock = asyncio.Lock() @asynccontextmanager async def lifespan(app: FastAPI): async def periodic_profile_cleanup(): while True: await asyncio.sleep(PROFILE_CLEANUP_INTERVAL_SECONDS) try: events.cleanup_expired() except sqlite3.Error: LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden") async def detection_watchdog(): while True: detection.watchdog() follow.watchdog() await asyncio.sleep(0.1) camera.start() detection.start() follow.start() watchdog_task = asyncio.create_task(detection_watchdog()) cleanup_task = asyncio.create_task(periodic_profile_cleanup()) try: yield finally: controller.stop() follow.stop() watchdog_task.cancel() with suppress(asyncio.CancelledError): await watchdog_task detection.stop() cleanup_task.cancel() with suppress(asyncio.CancelledError): await cleanup_task camera.stop() app = FastAPI(title="Halloween-Grab", lifespan=lifespan) app.state.camera = camera app.state.controller = controller app.state.events = events app.state.detection = detection app.state.follow = follow @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("/grab", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse) async def index(): return PAGE @app.get("/kamera", response_class=HTMLResponse) async def camera_page(): return CAMERA_PAGE @app.get("/api/status") async def status(): return {"controller": controller.status(), "camera": camera.status(), "detection": detection.status()} @app.get("/api/detection") async def detection_result(): return detection.snapshot() @app.get("/api/detection/config") async def detection_config(): return detection.get_config() @app.post("/api/detection/config", dependencies=[Depends(require_ui)]) async def configure_detection(payload: dict): if follow.driver is not None and payload.get("enabled"): raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen") try: return detection.configure(payload) except (ValueError, TypeError, KeyError, IndexError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except OSError as exc: raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc @app.get("/api/follow") async def follow_status(): return {**follow.status(), "camera": camera.status()} @app.post("/api/follow/{action}", dependencies=[Depends(require_ui)]) async def follow_action(action: str, payload: dict): async with follow_api_lock: try: if action == "config": return follow.configure(payload) if action == "capture": return follow.capture() if action == "select": return follow.select(payload.get("token"), payload.get("x"), payload.get("y")) if action == "start": if type(payload.get("hardware", False)) is not bool: raise ValueError("Ungültige Servofreigabe") if payload.get("hardware", False) and detection.get_config()["enabled"]: raise HTTPException(status_code=409, detail="Für echte Kamerabewegung zuerst auf der Grab-Seite die automatische Auslösung ausschalten und speichern") session = follow.begin(payload.get("hardware", False), payload.get("acknowledged") is True, payload.get("operation", "follow")) return {"session": session} if action == "jog": return follow.jog(payload.get("session"), payload.get("direction"), payload.get("sequence")) if action == "center": return follow.center(payload.get("session"), payload.get("sequence")) if action == "remember": return follow.remember(payload.get("session"), payload.get("direction")) if action == "heartbeat": follow.heartbeat(payload.get("session")) return {"ok": True} if action == "stop": follow.halt() return follow.status() raise HTTPException(status_code=404, detail="Unbekannte Aktion") except (ValueError, TypeError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except OSError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.get("/api/events") async def recent_events(limit: int = Query(20, ge=1, le=100)): try: return {"events": events.recent(limit)} except sqlite3.Error as exc: raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc @app.get("/api/events/{event_id}/image") async def event_image(event_id: int): try: content = events.image_path(event_id).read_bytes() except (KeyError, FileNotFoundError) as exc: raise HTTPException(status_code=404, detail="Bild nicht vorhanden") from exc except (OSError, sqlite3.Error) as exc: raise HTTPException(status_code=503, detail="Bild nicht verfügbar") from exc return Response(content, media_type="image/jpeg", headers={ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }) @app.delete("/api/events/{event_id}/image", dependencies=[Depends(require_ui)]) async def delete_event_image(event_id: int): try: return {"deleted": events.delete_image(event_id)} except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc.args[0])) from exc except (OSError, sqlite3.Error) as exc: raise HTTPException(status_code=503, detail="Bild konnte nicht gelöscht werden") from exc @app.get("/api/profiles") async def list_profiles(): try: return {"profiles": events.profiles()} except sqlite3.Error as exc: raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc @app.post("/api/profiles", dependencies=[Depends(require_ui)]) async def create_profile(payload: ProfileInput): try: return {"profile": events.create_profile(payload.label)} except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except sqlite3.Error as exc: raise HTTPException(status_code=503, detail=f"Profil nicht gespeichert: {exc}") from exc @app.patch("/api/events/{event_id}/profile", dependencies=[Depends(require_ui)]) async def assign_profile(event_id: int, payload: ProfileAssignment): try: events.assign_profile(event_id, payload.profile_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc.args[0])) from exc except sqlite3.Error as exc: raise HTTPException(status_code=503, detail=f"Zuordnung nicht gespeichert: {exc}") from exc return {"updated": True} @app.delete("/api/profiles/{profile_id}", dependencies=[Depends(require_ui)]) async def delete_profile(profile_id: int): try: deleted = events.delete_profile(profile_id) except sqlite3.Error as exc: raise HTTPException(status_code=503, detail=f"Profil nicht gelöscht: {exc}") from exc if not deleted: raise HTTPException(status_code=404, detail="Profil nicht gefunden") return {"deleted": True} @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() if accepted: try: events.record_test_trigger(controller.mode) except sqlite3.Error as exc: raise HTTPException( status_code=503, detail=f"Test gestartet, aber Ereignis nicht gespeichert: {exc}", ) from exc return {"accepted": accepted, "reason": reason, "controller": controller.status()} @app.post("/api/grave/stop", dependencies=[Depends(require_ui)]) async def grave_stop(): controller.stop() return controller.status() @app.post("/api/grave/reset", dependencies=[Depends(require_ui)]) async def grave_reset(): controller.reset() detection.reset() return controller.status() @app.post("/api/stop", dependencies=[Depends(require_ui)]) async def stop(): follow.halt() controller.stop() return controller.status() @app.post("/api/reset", dependencies=[Depends(require_ui)]) async def reset(): follow.halt() controller.reset() detection.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.02) 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.02) return StreamingResponse( frames(), media_type="multipart/x-mixed-replace; boundary=frame" ) return app