Files
Halloween/halloween/index.html
T
2026-10-15 20:31:02 +02:00

636 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.camera-wrap { position: relative; aspect-ratio: 4/3; }
.camera-wrap .camera { position: absolute; inset: 0; height: 100%; }
#detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
button, select, a.download { cursor: pointer; }
button:hover, a.download:hover { background: #36504a; }
button:focus-visible, select:focus-visible, input: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; }
.events { grid-column: 1 / -1; }
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
#events-list li { margin: .35rem 0; }
#profiles-list li { margin: .35rem 0; }
.event-image { display: block; max-width: 200px; max-height: 150px; border-radius: 7px; margin: .5rem 0; }
.event-tools { margin: .3rem 0 .8rem; }
@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>
<div class="camera-wrap">
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
</div>
<h2 style="margin-top:1rem">Automatische Auslösung</h2>
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
<label for="detection-method">Erkennung wählen</label>
<select id="detection-method">
<option value="person_line">Person überquert Linie</option>
<option value="motion_test">Test: Bewegung in Pfeilrichtung</option>
</select>
<p id="detection-help" class="muted"></p>
<div class="row">
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
<button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button>
</div>
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
<p id="motion-details" class="muted" role="status" hidden></p>
<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">Bei automatischer Auslösung wird der grüne Bereich als Bild lokal auf dem Pi gespeichert, bis du es löschst. Den Bereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</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>Letzte Auslösung</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>Grabablauf nur simuliert.</strong> Hand, Atemmotor, Licht und Ton bleiben simuliert. Kameraservos können im separaten Farbtest wirklich bewegt werden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
<div class="row">
<button id="zone-clear">Bereich frei (manueller Test)</button>
<button id="test-trigger">Manuelle 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>
<section class="events" id="follow-panel">
<h2>Kamera folgt einem Farbpunkt · Testmodus</h2>
<p>Halte einen kräftig roten oder grünen Aufkleber vor die Kamera. Ein Punkt am Finger geht auch. Kein Training und kein Internet nötig. Zuerst die automatische Auslösung ausschalten und speichern.</p>
<div class="row">
<button id="follow-green">Grün wählen</button><button id="follow-red">Rot wählen</button>
<button id="follow-capture">Beispielfoto aufnehmen</button>
</div>
<p>Eigene Farbe: Beispielfoto aufnehmen und dort auf die Mitte des Farbpunkts tippen. Das Foto wird nicht gespeichert.</p>
<canvas id="follow-canvas" width="640" height="480" style="width:100%;max-width:720px;background:#050808;cursor:crosshair" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
<details>
<summary>Servogrenzen und Richtung einstellen</summary>
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt beide Servos auf 1500 µs; dabei kann die Halterung springen. Beim ersten Test Servohörner/Gestänge lösen, Mittelstellung prüfen und danach spannungsfrei wieder montieren. Grenzen nur schrittweise erweitern.</p>
<div class="row">
<label>PAN Minimum <input id="follow-pan-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
<label>PAN Maximum <input id="follow-pan-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
</div>
<div class="row" style="margin-top:.6rem">
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
<label><input id="follow-tilt-reverse" type="checkbox"> TILT umkehren</label>
</div>
<label>Farbtoleranz <input id="follow-tolerance" type="number" min="5" max="35" value="18" style="width:6em"></label>
<button id="follow-save">Einstellungen speichern</button>
</details>
<p><label><input id="follow-ack" type="checkbox"> Mittelstellung und Bewegungsraum geprüft, Kamerakabel hat Spiel; Servoversorgung kann ich sofort abziehen.</label></p>
<div class="row">
<button id="follow-preview">Nur Farbvorschau starten</button>
<button id="follow-hardware">Echte Kameranachführung starten</button>
<button id="follow-stop" class="danger">Kameraservos stoppen</button>
</div>
<div style="border:2px solid #759c8f;border-radius:8px;padding:1rem;margin-top:1rem">
<h2>Endpunkte einrichten · manuell</h2>
<p>Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.</p>
<button id="setup-start">Einrichtmodus starten</button>
<p id="setup-note" role="status">Noch keine Grenze in dieser Sitzung gespeichert.</p>
<p id="setup-limits" class="muted"></p>
<p id="setup-position" class="value" role="status">Sollposition: PAN 1500 / TILT 1500 µs</p>
<div style="display:grid;grid-template-columns:repeat(3,minmax(80px,150px));gap:.7rem;max-width:480px">
<span></span><button id="setup-up" style="font-size:1.3rem;touch-action:none;user-select:none">▲ Hoch halten</button><span></span>
<button id="setup-left" style="font-size:1.3rem;touch-action:none;user-select:none">◀ Links halten</button>
<button id="setup-release" class="danger">Bewegung anhalten</button>
<button id="setup-right" style="font-size:1.3rem;touch-action:none;user-select:none">Rechts halten ▶</button>
<span></span><button id="setup-down" style="font-size:1.3rem;touch-action:none;user-select:none">▼ Runter halten</button><span></span>
</div>
<p>Taste gedrückt halten: langsame Bewegung. Loslassen: Zielposition bleibt stehen. Bei fehlendem Tastensignal endet die Bewegung spätestens nach 0,35 s, sofern Pi und Treiber reagieren.</p>
<div class="row">
<button id="setup-save-left">Links-Grenze speichern</button>
<button id="setup-save-right">Rechts-Grenze speichern</button>
<button id="setup-save-up">Oben-Grenze speichern</button>
<button id="setup-save-down">Unten-Grenze speichern</button>
</div>
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gelten 1100–1900 µs, auch außerhalb der bisherigen Farbtest-Grenzen. Falls das nicht reicht: stoppen, nichts erzwingen. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet wieder bei 1500 µs.</p>
</div>
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Bei Punktverlust keine Suchbewegung; nach einer Sekunde wird die Servositzung beendet. Bei verlorener Bedienverbindung oder Kamerafehler werden die PWM-Signale abgeschaltet, soweit die Verbindung zum Treiber funktioniert. Die Stromversorgung bleibt eingeschaltet. Bei einem Hardwarefehler Servoversorgung abziehen.</p>
</section>
<section class="events">
<h2>Gespeicherte Ereignisse</h2>
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<button id="refresh-events">Ereignisse aktualisieren</button>
<ul id="events-list"><li>Lädt …</li></ul>
</section>
<section class="events">
<h2>Personenprofile</h2>
<p class="notice">Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.</p>
<div class="row">
<input id="profile-label" type="text" maxlength="80" aria-label="Profilkennung" placeholder="Profilkennung oder Spitzname">
<button id="create-profile">Profil anlegen</button>
</div>
<ul id="profiles-list"><li>Lädt …</li></ul>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
let profiles = [];
async function api(path, body = {}, method = 'POST') {
const response = await fetch(path, {
method, 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;
const detection = data.detection;
let phase = c.phase;
// The manual zone-clear flag is not the automatic detection gate.
// Preserve running sequence, stop and cooldown labels from the controller.
if (detection.enabled && ['Bereit', 'Bereich frei abwarten'].includes(phase)) {
phase = !['sanft', 'voll'].includes(c.mode) ? 'Modus ohne Schreckauslösung'
: detection.reset_required ? 'Erkennung gesperrt; zurücksetzen'
: !detection.ready ? 'Auf Erkennung warten'
: detection.armed ? 'Bereit (Automatik)'
: detection.method === 'motion_test' ? 'Ruhiges Bild abwarten (Automatik)'
: 'Freien Bereich abwarten (Automatik)';
}
$('phase').textContent = phase;
$('zone-clear').title = 'Nur für manuelle Tests. Die Automatik prüft die Freigabe selbst.';
$('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();
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
}
async function refreshProfiles() {
const response = await fetch('/api/profiles', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Profile nicht verfügbar');
profiles = data.profiles;
const list = $('profiles-list');
list.replaceChildren();
if (!profiles.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Profile';
list.append(item);
}
for (const profile of profiles) {
const item = document.createElement('li');
const label = document.createElement('span');
label.textContent = '#' + profile.id + ' · ' + profile.label + ' · ' + profile.event_count + ' Ereignisse ';
const remove = document.createElement('button');
remove.textContent = 'Löschen';
remove.onclick = async () => {
if (!confirm('Profil und seine Zuordnungen entfernen? Die Ereignisse bleiben erhalten.')) return;
try {
await api('/api/profiles/' + profile.id, {}, 'DELETE');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
item.append(label, remove);
list.append(item);
}
}
async function refreshEvents() {
try {
const response = await fetch('/api/events', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ereignisse nicht verfügbar');
const list = $('events-list');
list.replaceChildren();
if (!data.events.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Auslösung gespeichert';
list.append(item);
}
for (const event of data.events) {
const item = document.createElement('li');
const summary = document.createElement('span');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + ({person_line_crossing: 'Linienüberquerung', motion_direction_test: 'Bewegungs-Test', test_trigger: 'Manueller Test'}[event.kind] || event.kind) + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
const tools = document.createElement('div');
tools.className = 'row event-tools';
const select = document.createElement('select');
select.setAttribute('aria-label', 'Profil für Ereignis ' + event.id);
select.add(new Option('Ohne Profil', ''));
for (const profile of profiles) select.add(new Option('#' + profile.id + ' · ' + profile.label, String(profile.id)));
select.value = event.profile_id === null ? '' : String(event.profile_id);
const assign = document.createElement('button');
assign.textContent = 'Zuordnen';
assign.onclick = async () => {
try {
await api('/api/events/' + event.id + '/profile', { profile_id: select.value ? Number(select.value) : null }, 'PATCH');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
tools.append(select, assign);
item.append(summary);
if (event.image_url) {
const link = document.createElement('a');
link.href = event.image_url; link.target = '_blank'; link.rel = 'noopener';
link.textContent = 'Bild ansehen';
const preview = document.createElement('img');
preview.className = 'event-image'; preview.loading = 'lazy';
preview.src = event.image_url; preview.alt = 'Auslösebild zu Ereignis #' + event.id;
link.prepend(preview);
const removeImage = document.createElement('button');
removeImage.textContent = 'Bild löschen';
removeImage.onclick = async () => {
if (!confirm('Bild unwiderruflich löschen? Der Ereigniseintrag bleibt erhalten.')) return;
try {
await api(event.image_url, {}, 'DELETE');
await refreshEvents();
} catch (error) { $('message').textContent = error.message; }
};
item.append(link, removeImage);
} else {
const missing = document.createElement('span');
missing.className = 'muted'; missing.textContent = ' · Kein gespeichertes Bild';
item.append(missing);
}
item.append(tools);
list.append(item);
}
} catch (error) { $('events-list').textContent = error.message; }
}
async function refreshDb() {
try {
await refreshProfiles();
await refreshEvents();
} catch (error) {
$('profiles-list').textContent = error.message;
$('events-list').textContent = error.message;
}
}
$('refresh-events').onclick = refreshEvents;
$('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');
$('create-profile').onclick = async () => {
try {
await api('/api/profiles', { label: $('profile-label').value });
$('profile-label').value = '';
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0;
let detectionEpoch = 0;
const overlay = $('detection-overlay'), context = overlay.getContext('2d');
function drawDetection() {
context.clearRect(0, 0, 640, 480);
if (!detectionConfig) return;
const c = detectionConfig, [x1,y1,x2,y2] = c.region;
context.lineWidth = 3; context.strokeStyle = '#78d9a2';
context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480);
const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480;
context.strokeStyle = '#ffb44f'; context.beginPath();
context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke();
const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240;
const nx = -dy/length*c.direction, ny = dx/length*c.direction;
context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke();
for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) {
context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill();
}
if (performance.now()-lastImageAt > 2000) return;
for (const person of people) {
const [l,t,r,bottom] = person.box;
context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6';
context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480);
context.fillStyle = context.strokeStyle; context.font = '16px sans-serif';
context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5));
}
}
function pointer(event) {
const rect = overlay.getBoundingClientRect();
return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),
Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))];
}
overlay.onpointerdown = event => {
if (!detectionConfig) return;
const p = pointer(event), c = detectionConfig;
const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)];
const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480));
const index = distances.indexOf(Math.min(...distances));
if (distances[index] > 30) return;
drag = index; overlay.setPointerCapture(event.pointerId);
};
overlay.onpointermove = event => {
if (drag === null) return;
const p = pointer(event), c = detectionConfig;
if (drag < 2) c.line[drag] = p;
else c.region.splice((drag-2)*2,2,...p);
drawDetection();
};
overlay.onpointerup = overlay.onpointercancel = () => { drag = null; };
$('reverse-direction').onclick = () => {
if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); }
};
$('save-detection').onclick = async () => {
if (!detectionConfig) return;
try {
detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig.method = $('detection-method').value;
detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection();
$('message').textContent = detectionConfig.method === 'motion_test'
? 'Testmodus gespeichert; Bild zwei Sekunden ruhig halten. Bei Stopp bewusst zurücksetzen.'
: 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; }
};
async function refreshDetection() {
const epoch = detectionEpoch, requestedAt = performance.now();
try {
const response = await fetch('/api/detection', {cache:'no-store'});
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
const data = await response.json(), status = data.status;
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
const motionTest = status.method === 'motion_test';
$('motion-details').hidden = !motionTest;
const motion = data.motion;
$('motion-details').textContent = motionTest && motion
? 'Bildänderung: '+(motion.changed_fraction*100).toLocaleString('de-DE', {maximumFractionDigits:2})+
' % · '+motion.note+' · Bestätigung '+motion.confirmed_frames+'/2'
: 'Warte auf Bewegungswerte';
const detail = motionTest && data.motion?.moving
? (data.motion.directed ? ' · Bewegung in Pfeilrichtung' : ' · Bewegung erkannt') : '';
$('detection-status').textContent = (motionTest ? 'Bewegungs-Test · ' : '') + (status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed
? (motionTest ? 'Bereit für Bewegung in Pfeilrichtung' : 'Bereit für Überquerung zum Grab')
: (motionTest ? 'Ruhiges Bild / Sperrzeit abwarten' : 'Freigabe / Sperrzeit abwarten'))) +
detail+' · '+status.fps+' Auswertungen/s';
if (data.image_base64) {
const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64;
await image.decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('live-image').src = image.src;
await $('live-image').decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
processed = true; people = data.people;
// Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten.
lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000;
} else {
people = [];
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
}
drawDetection();
} catch (error) {
people = []; drawDetection();
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
$('detection-status').textContent = error.message;
}
setTimeout(refreshDetection, 200);
}
function updateDetectionHelp() {
$('detection-help').textContent = $('detection-method').value === 'motion_test'
? 'Nur Test: Hand, Kopf oder Gegenstand im grünen Bereich in Pfeilrichtung bewegen. Keine Linienüberquerung nötig. Zuerst zwei Sekunden ruhig halten; danach zehn Sekunden Sperrzeit und erneut Ruhe. Benötigt OpenCV, kein Personenmodell. Auch Kamerabewegung kann auslösen. Ein ruhiges Bild bedeutet nicht, dass der Bereich menschenfrei ist.'
: 'Eine erkannte Person muss die Linie in Pfeilrichtung überqueren. Zuerst den Bereich zwei Sekunden freihalten. Benötigt OpenCV und das installierte Personenmodell.';
}
$('detection-method').onchange = updateDetectionHelp;
updateDetectionHelp();
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
detectionConfig = c; $('detection-enabled').checked = c.enabled;
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; });
// Camera marker test has its own frame/overlay, never the trigger geometry.
let followConfig = null, followSession = null, followSample = null, followBusy = false;
let followEpoch = 0;
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
function fillFollow(c) {
followConfig = c;
for (const axis of ['pan','tilt']) {
$( `follow-${axis}-min`).value = c[axis+'_min'];
$( `follow-${axis}-max`).value = c[axis+'_max'];
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
}
$('follow-tolerance').value = c.tolerance;
}
async function showFollowImage(b64, target, epoch) {
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
await image.decode();
if (epoch !== followEpoch) return;
followCtx.drawImage(image,0,0,640,480);
followCtx.strokeStyle = '#fff'; followCtx.lineWidth = 2;
followCtx.beginPath(); followCtx.moveTo(305,240); followCtx.lineTo(335,240);
followCtx.moveTo(320,225); followCtx.lineTo(320,255); followCtx.stroke();
if (target) {
followCtx.strokeStyle = '#ffca78'; followCtx.lineWidth = 5;
followCtx.beginPath(); followCtx.arc(target.x*640,target.y*480,Math.max(15,target.radius*640),0,Math.PI*2); followCtx.stroke();
}
}
async function followAction(fn) {
if (followBusy) return;
followBusy = true;
try { await fn(); }
catch (e) { $('follow-status').textContent = e.message; }
finally { followBusy = false; }
}
async function saveFollow() {
const c = {...followConfig};
for (const axis of ['pan','tilt']) {
c[axis+'_min'] = Number($(`follow-${axis}-min`).value);
c[axis+'_max'] = Number($(`follow-${axis}-max`).value);
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
}
c.tolerance = Number($('follow-tolerance').value);
fillFollow(await api('/api/follow/config',c));
}
for (const [id,hue] of [['follow-green',85],['follow-red',0]]) {
$(id).onclick = () => followAction(async () => {
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
fillFollow(await api('/api/follow/config',{...followConfig,hue}));
$('follow-status').textContent = 'Farbe gespeichert. Vorschau starten.';
});
}
$('follow-save').onclick = () => followAction(async () => {
await saveFollow(); $('follow-status').textContent = 'Grenzen und Richtung gespeichert';
});
$('follow-capture').onclick = () => followAction(async () => {
const photo = await api('/api/follow/capture');
followSample = photo.token; followEpoch++;
await showFollowImage(photo.image_base64,null,followEpoch);
$('follow-status').textContent = 'Standbild: Jetzt auf die Mitte des Farbpunkts tippen.';
});
followCanvas.onclick = e => followAction(async () => {
if (!followSample) return;
const rect = followCanvas.getBoundingClientRect();
const config = await api('/api/follow/select',{token:followSample,
x:(e.clientX-rect.left)/rect.width,y:(e.clientY-rect.top)/rect.height});
fillFollow(config); $('follow-status').textContent = 'Eigene Farbe übernommen. Vorschau starten.';
});
async function beginFollow(hardware, operation = 'follow') {
if (hardware && !$('follow-ack').checked) throw new Error('Bitte zuerst Mittelstellung und freien Bewegungsraum prüfen und bestätigen.');
await saveFollow();
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
releaseJog(); jogSequence = 0; followSession = result.session; followSample = null; followEpoch++;
$('follow-status').textContent = hardware ? 'Echte Servos aktiv' : 'Farbvorschau ohne Servobewegung';
}
$('follow-preview').onclick = () => followAction(() => beginFollow(false));
$('follow-hardware').onclick = () => followAction(() => beginFollow(true));
$('follow-stop').onclick = async () => {
releaseJog(); setupReady = false; followSession = null; followEpoch++;
try { await api('/api/follow/stop'); $('follow-status').textContent = 'Kameraservos gestoppt'; }
catch(e) { $('follow-status').textContent = 'Stopp nicht bestätigt – Servoversorgung abziehen. '+e.message; }
};
document.addEventListener('visibilitychange', () => {
if (document.hidden && followSession) $('follow-stop').click();
});
// Hold-to-jog: separate short motion lease; sequence numbers reject late requests.
let jogTimer = null, jogSequence = 0, setupReady = false, followDemo = false;
async function sendJog(direction) {
if (!followSession) return;
return api('/api/follow/jog',{session:followSession,direction,sequence:++jogSequence});
}
function releaseJog() {
if (jogTimer === null) return;
clearInterval(jogTimer); jogTimer = null;
sendJog(null).catch(e => { $('follow-status').textContent = 'Tastenstopp nicht bestätigt: '+e.message; });
}
function pressJog(direction) {
if (!setupReady || !followSession || jogTimer !== null) return;
const send = () => sendJog(direction).catch(e => {
releaseJog(); $('follow-status').textContent = e.message;
});
jogTimer = setInterval(send,100); send();
}
for (const direction of ['left','right','up','down']) {
const button = $('setup-'+direction);
button.disabled = true;
button.onpointerdown = e => {
if (e.button !== 0) return;
e.preventDefault(); button.setPointerCapture(e.pointerId); pressJog(direction);
};
button.onpointerup = button.onpointercancel = button.onlostpointercapture = releaseJog;
button.onkeydown = e => {
if ([' ','Enter'].includes(e.key)) { e.preventDefault(); if (!e.repeat) pressJog(direction); }
};
button.onkeyup = e => { if ([' ','Enter'].includes(e.key)) { e.preventDefault(); releaseJog(); } };
button.onblur = releaseJog;
$('setup-save-'+direction).disabled = true;
$('setup-save-'+direction).onclick = () => followAction(async () => {
releaseJog();
await sendJog(null);
fillFollow(await api('/api/follow/remember',{session:followSession,direction}));
$('setup-note').textContent = 'Grenze gespeichert – mit 10 µs Abstand nach innen.';
});
}
$('setup-release').onclick = releaseJog;
document.addEventListener('pointerup',releaseJog);
document.addEventListener('pointercancel',releaseJog);
window.addEventListener('blur',releaseJog);
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'));
async function pollFollow() {
try {
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
const r = await fetch('/api/follow',{cache:'no-store'});
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
const f = await r.json();
followDemo = f.demo;
$('setup-start').textContent = followDemo ? 'Einrichtmodus als Demo starten' : 'Einrichtmodus starten';
setupReady = f.active && f.operation === 'setup' && !!followSession;
if (!setupReady) releaseJog();
for (const direction of ['left','right','up','down']) {
$('setup-'+direction).disabled = !setupReady;
$('setup-save-'+direction).disabled = !setupReady;
}
$('setup-limits').textContent = 'Gespeicherte Grenzen: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs';
$('setup-position').textContent = 'Sollposition: PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
if (!followConfig) {
fillFollow(f.config);
$('follow-status').textContent = 'Bereit. Farbe wählen oder Beispielfoto aufnehmen.';
}
if (!f.active) followSession = null;
if (f.active || f.error) $('follow-status').textContent = f.error ||
(f.operation === 'setup' ? (f.hardware ? 'Einrichtmodus · echte Servos' : 'Einrichtmodus · nur Demo')
: (f.hardware ? 'Echte Servos aktiv' : 'Vorschau ohne Bewegung'))+' · '+
(f.operation === 'setup' ? 'Richtungstaste gedrückt halten' : (f.target ? 'Punkt erkannt' : 'Kein eindeutiger Farbpunkt'))+' · PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
if (f.active && f.image_base64 && !followSample) await showFollowImage(f.image_base64,f.target,followEpoch);
} catch(e) {
if (followSession) {
releaseJog(); setupReady = false; followSession = null;
$('follow-status').textContent = 'Verbindung verloren; Nachführung wird beendet. '+e.message;
}
}
setTimeout(pollFollow,300);
}
pollFollow();
refreshDetection();
setInterval(drawDetection, 250);
refresh();
refreshDb();
setInterval(refresh, 1000);
setInterval(refreshDb, 15000);
</script>
</body>
</html>