Files
Halloween/halloween/web.py
T
2026-10-16 01:38:10 +02:00

333 lines
13 KiB
Python

"""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