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 @@
|
||||
"""Lokale Steueroberfläche für das Halloween-Grab."""
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Ein Kameraproduzent für mehrere Livebild-Betrachter."""
|
||||
|
||||
from io import BytesIO
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class CameraStream:
|
||||
def __init__(self, demo: bool = False):
|
||||
self.demo = demo
|
||||
self._condition = threading.Condition()
|
||||
self._thread = None
|
||||
self._stop = threading.Event()
|
||||
self._frame = None
|
||||
self._number = 0
|
||||
self._time = None
|
||||
self.error = None
|
||||
|
||||
def start(self):
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="camera")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
with self._condition:
|
||||
self._condition.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=3)
|
||||
|
||||
@staticmethod
|
||||
def _jpeg(image: Image.Image) -> bytes:
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=75)
|
||||
return buffer.getvalue()
|
||||
|
||||
def _publish(self, frame: bytes):
|
||||
with self._condition:
|
||||
self._frame = frame
|
||||
self._number += 1
|
||||
self._time = time.monotonic()
|
||||
self._condition.notify_all()
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
if self.demo:
|
||||
self._run_demo()
|
||||
else:
|
||||
self._run_pi()
|
||||
except Exception as exc:
|
||||
with self._condition:
|
||||
self.error = f"Kamera nicht verfügbar: {type(exc).__name__}: {exc}"
|
||||
self._condition.notify_all()
|
||||
|
||||
def _run_demo(self):
|
||||
while not self._stop.is_set():
|
||||
image = Image.new("RGB", (640, 480), (21, 28, 32))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((40, 105, 600, 400), outline=(144, 111, 65), width=8)
|
||||
draw.ellipse((165, 205, 475, 340), fill=(45, 52, 36), outline=(110, 91, 59), width=4)
|
||||
draw.text((42, 30), "DEMO - KEINE ECHTE KAMERA", fill=(255, 172, 72))
|
||||
draw.text((42, 440), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill="white")
|
||||
self._publish(self._jpeg(image))
|
||||
self._stop.wait(0.2)
|
||||
|
||||
def _run_pi(self):
|
||||
from picamera2 import Picamera2
|
||||
|
||||
camera = Picamera2()
|
||||
started = False
|
||||
try:
|
||||
# BGR888 liefert laut Picamera2-Dokumentation RGB-Arrays für PIL.
|
||||
config = camera.create_video_configuration(
|
||||
main={"size": (640, 480), "format": "BGR888"}
|
||||
)
|
||||
camera.configure(config)
|
||||
camera.start()
|
||||
started = True
|
||||
while not self._stop.is_set():
|
||||
pixels = camera.capture_array("main")
|
||||
self._publish(self._jpeg(Image.fromarray(pixels, "RGB")))
|
||||
self._stop.wait(0.2)
|
||||
finally:
|
||||
try:
|
||||
if started:
|
||||
camera.stop()
|
||||
finally:
|
||||
camera.close()
|
||||
|
||||
def latest(self):
|
||||
with self._condition:
|
||||
return self._frame
|
||||
|
||||
def latest_numbered(self):
|
||||
with self._condition:
|
||||
return self._number, self._frame
|
||||
|
||||
def wait_next(self, number: int, timeout: float = 10):
|
||||
with self._condition:
|
||||
self._condition.wait_for(
|
||||
lambda: self._number > number or self.error or self._stop.is_set(),
|
||||
timeout=timeout,
|
||||
)
|
||||
return self._number, self._frame
|
||||
|
||||
def status(self):
|
||||
with self._condition:
|
||||
age = None if self._time is None else round(time.monotonic() - self._time, 1)
|
||||
return {
|
||||
"source": "Demo" if self.demo else "Pi-Kamera",
|
||||
"ready": self._frame is not None and age is not None and age < 3,
|
||||
"frame_age_seconds": age,
|
||||
"error": self.error,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Sichere Zustandslogik für den simulierten Effektablauf."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODES = ("aus", "atmosphaere", "sanft", "voll")
|
||||
SEQUENCE_SECONDS = 3.0
|
||||
COOLDOWN_SECONDS = 10.0
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, state_dir: Path, clock=time.monotonic):
|
||||
self._lock = threading.RLock()
|
||||
self._clock = clock
|
||||
self._path = state_dir / "settings.json"
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.mode, self.config_error = self._load_mode()
|
||||
self.stopped = False
|
||||
self.zone_clear_seen = False
|
||||
self.started_at = None
|
||||
self.cooldown_until = 0.0
|
||||
self.last_event = None
|
||||
|
||||
def _load_mode(self):
|
||||
if not self._path.exists():
|
||||
return "voll", None
|
||||
try:
|
||||
mode = json.loads(self._path.read_text(encoding="utf-8"))["mode"]
|
||||
if mode not in MODES:
|
||||
raise ValueError("ungültiger Modus")
|
||||
return mode, None
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
return "aus", f"Einstellungen fehlerhaft: {exc}"
|
||||
|
||||
def _save_mode(self, mode: str):
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=self._path.parent,
|
||||
prefix="settings-", suffix=".tmp", delete=False,
|
||||
) as stream:
|
||||
temp_path = Path(stream.name)
|
||||
json.dump({"mode": mode}, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp_path, self._path)
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
def set_mode(self, mode: str):
|
||||
if mode not in MODES:
|
||||
raise ValueError("unbekannter Modus")
|
||||
with self._lock:
|
||||
self._save_mode(mode)
|
||||
if mode != self.mode:
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
self.mode = mode
|
||||
self.config_error = None
|
||||
|
||||
def zone_clear(self):
|
||||
with self._lock:
|
||||
if not self.stopped:
|
||||
self.zone_clear_seen = True
|
||||
|
||||
def trigger(self):
|
||||
"""Nur neuer Trigger nach freiem Bereich; bislang ohne Aktorausgabe."""
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
if self.stopped:
|
||||
return False, "Stopp aktiv"
|
||||
if self.config_error:
|
||||
return False, "Einstellungen fehlerhaft"
|
||||
if self.mode not in ("sanft", "voll"):
|
||||
return False, "Modus löst keinen Schreck aus"
|
||||
if not self.zone_clear_seen:
|
||||
return False, "Auslösebereich zuerst freigeben"
|
||||
if self.started_at is not None and now - self.started_at < SEQUENCE_SECONDS:
|
||||
return False, "Sequenz läuft"
|
||||
if now < self.cooldown_until:
|
||||
return False, "Sperrzeit läuft"
|
||||
self.started_at = now
|
||||
self.cooldown_until = now + COOLDOWN_SECONDS
|
||||
self.zone_clear_seen = False
|
||||
self.last_event = {"mode": self.mode, "time": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||
return True, "Simulation gestartet"
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
self.stopped = True
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self.stopped = False
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
elapsed = None if self.started_at is None else now - self.started_at
|
||||
if self.stopped:
|
||||
phase = "Stopp"
|
||||
elif elapsed is not None and elapsed < 0.8:
|
||||
phase = "Stille"
|
||||
elif elapsed is not None and elapsed < 1.7:
|
||||
phase = "Kratzen (simuliert)"
|
||||
elif elapsed is not None and elapsed < 2.4:
|
||||
phase = "Schreck (simuliert)" if self.mode == "voll" else "Sanft (simuliert)"
|
||||
elif elapsed is not None and elapsed < SEQUENCE_SECONDS:
|
||||
phase = "Rückkehr (simuliert)"
|
||||
elif now < self.cooldown_until:
|
||||
phase = "Sperrzeit"
|
||||
else:
|
||||
phase = "Bereit" if self.zone_clear_seen else "Bereich frei abwarten"
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"phase": phase,
|
||||
"stopped": self.stopped,
|
||||
"zone_clear_seen": self.zone_clear_seen,
|
||||
"cooldown_seconds": round(max(0, self.cooldown_until - now), 1),
|
||||
"last_event": self.last_event,
|
||||
"config_error": self.config_error,
|
||||
"actuators": "nur Simulation",
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Halloween-Grab · Steuerung</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111718; color: #eef0e9; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #182122; border-bottom: 1px solid #3a4644; }
|
||||
h1 { margin: 0; font-size: 1.5rem; }
|
||||
header p { margin: .35rem 0 0; color: #c1cbc2; }
|
||||
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
|
||||
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
|
||||
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
||||
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
button, select, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; cursor: pointer; }
|
||||
button:hover, a.download:hover { background: #36504a; }
|
||||
button:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
|
||||
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
|
||||
button.danger:hover { background: #9c352c; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
a.download { display: inline-block; text-decoration: none; }
|
||||
.value { font-weight: 650; color: #ffca78; }
|
||||
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
|
||||
.muted { color: #bfcbc6; }
|
||||
#message { min-height: 1.5rem; color: #ffca78; }
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
|
||||
dt { color: #adbfba; } dd { margin: 0; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Halloween-Grab</h1>
|
||||
<p>Lokale Kameraansicht und Steuerung</p>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Livebild</h2>
|
||||
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||||
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
|
||||
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
|
||||
<p class="notice">Aufnahmen und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Betrieb</h2>
|
||||
<dl>
|
||||
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
||||
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
|
||||
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
|
||||
<dt>Letzter Test</dt><dd id="last-event">–</dd>
|
||||
</dl>
|
||||
<label for="mode">Modus wählen</label>
|
||||
<div class="row" style="margin: .5rem 0 1rem">
|
||||
<select id="mode">
|
||||
<option value="voll">Voller Schreck</option>
|
||||
<option value="sanft">Sanft</option>
|
||||
<option value="atmosphaere">Atmosphäre</option>
|
||||
<option value="aus">Aus</option>
|
||||
</select>
|
||||
<button id="save-mode">Übernehmen</button>
|
||||
</div>
|
||||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Es gibt noch keine automatische Personenauslösung.</p>
|
||||
<div class="row">
|
||||
<button id="zone-clear">Bereich frei (Test)</button>
|
||||
<button id="test-trigger">Testauslösung</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1rem">
|
||||
<button class="danger" id="stop">Stopp</button>
|
||||
<button id="reset">Nach Stopp zurücksetzen</button>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
|
||||
async function api(path, body = {}) {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||||
return data;
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/api/status', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
const c = data.controller, cam = data.camera;
|
||||
$('mode-label').textContent = names[c.mode] || c.mode;
|
||||
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
|
||||
$('phase').textContent = c.phase;
|
||||
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
|
||||
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
|
||||
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
|
||||
if (c.config_error) $('message').textContent = c.config_error;
|
||||
} catch (error) {
|
||||
$('camera-status').textContent = 'Server nicht erreichbar';
|
||||
$('message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
async function action(path, body) {
|
||||
try {
|
||||
const data = await api(path, body);
|
||||
$('message').textContent = data.reason || 'Einstellung übernommen';
|
||||
await refresh();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
}
|
||||
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
|
||||
$('zone-clear').onclick = () => action('/api/zone-clear');
|
||||
$('test-trigger').onclick = () => action('/api/test-trigger');
|
||||
$('stop').onclick = () => action('/api/stop');
|
||||
$('reset').onclick = () => action('/api/reset');
|
||||
refresh();
|
||||
setInterval(refresh, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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