Private
Public Access
Initiales Halloween-Projekt
This commit is contained in:
commit
e071fbad2a
36 files changed
+2220
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user