Private
Public Access
242 lines
8.7 KiB
Python
242 lines
8.7 KiB
Python
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
|
|
|
|
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
|
|
|
|
|
|
PAGE = (Path(__file__).parent / "index.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)
|
|
|
|
@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()
|
|
await asyncio.sleep(0.1)
|
|
|
|
camera.start()
|
|
detection.start()
|
|
watchdog_task = asyncio.create_task(detection_watchdog())
|
|
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
|
try:
|
|
yield
|
|
finally:
|
|
controller.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.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(),
|
|
"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):
|
|
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/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/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/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()
|
|
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.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
|