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

+1
View File
@@ -0,0 +1 @@
"""Lokale Steueroberfläche für das Halloween-Grab."""
+118
View File
@@ -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,
}
+133
View File
@@ -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",
}
+123
View File
@@ -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>
+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