Initiales Halloween-Projekt

This commit is contained in:
marcinator_2 committed 2026-10-01 03:19:17 +02:00
commit e071fbad2a
36 files changed
+2220

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
"""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