Files
2026-10-16 01:52:41 +02:00

551 lines
39 KiB
HTML
Raw Permalink 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>Kamera / Finger · Halloween</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #070a10; color: #eef4ff; }
* { box-sizing: border-box; }
body { margin: 0; }
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #0b111c; border-bottom: 1px solid #22334d; }
h1 { margin: 0; font-size: 1.5rem; }
header p { margin: .35rem 0 0; color: #a7b7d0; }
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
section { background: #0d1522; border: 1px solid #243550; 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: #03060b; 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 #344b6c; border-radius: 7px; background: #152238; color: #f1f6ff; font: inherit; padding: .65rem .8rem; }
button, select, a.download { cursor: pointer; }
button:hover, a.download:hover { background: #1d3659; }
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; }
nav a[aria-current="page"] { background:#123c69; border:2px solid #5aafff; font-weight:700; }
.endpoint-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; }
@media (max-width:520px) { .endpoint-grid { grid-template-columns:1fr; } }
.value { font-weight: 650; color: #8bd6ff; }
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #211c14; margin: .8rem 0; }
.muted { color: #acbdd4; }
#message { min-height: 1.5rem; color: #8bd6ff; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
dt { color: #a7b7d0; } 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; }
main { grid-template-columns: minmax(0,1fr); max-width: 1000px; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
/* Wide work area, natural page scrolling and readable controls. */
[hidden] { display: none !important; }
header { padding: .8rem 2vw; display: grid; grid-template-columns: 1fr auto; gap: .25rem 1rem; align-items: center; position: sticky; top: 0; z-index: 20; }
header h1, header > p { grid-column: 1; }
header nav { grid-column: 2; grid-row: 1 / span 2; margin: 0 !important; }
#all-stop-status:empty { display: none; }
#all-stop-status { grid-column: 1 / -1; }
main { width: 100%; max-width: 1800px; padding: 1rem 2vw; grid-template-columns: minmax(0,1.35fr) minmax(360px,1fr); align-items: start; }
section { min-width: 0; }
p { line-height: 1.45; }
summary { cursor: pointer; font-weight: 650; padding: .65rem 0; }
summary:focus-visible { outline: 3px solid #ffb44f; outline-offset: 3px; }
.follow-picture { width: min(100%, calc((100vh - 380px)*1.3333)); min-width: 220px; margin: auto; }
.follow-picture img, .follow-canvas { display: block; width: 100%; height: auto; aspect-ratio: 4/3; border-radius: 8px; background: #03060b; }
.follow-canvas { cursor: crosshair; }
#follow-status { padding: .75rem; background: #0a1b30; border: 1px solid #35577e; border-radius: 8px; color: #8bd6ff; font-weight: 600; }
.start-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
#follow-stop { grid-column: 1 / -1; }
#follow-hardware { background: #165ca1; border-color: #69b6ff; }
.settings-card { margin-top: 1rem; border-top: 1px solid #344b6c; }
.settings-card > label { display: inline-flex; align-items: center; gap: .4rem; margin: .6rem .5rem .6rem 0; }
.settings-card .hint, #follow-settings-status { color: #acbdd4; }
.help { margin-top: .6rem; }
.help summary { color: #b7c9e3; }
.calibration-panel { grid-column: 1 / -1; }
.calibration-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 1.2rem; margin-top: .8rem; }
.calibration-card { min-width: 0; padding: 1rem; border: 1px solid #35577e; border-radius: 8px; }
.calibration-card label { display: inline-flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
button { min-height: 44px; }
@media (max-width: 999px) {
header { position: static; }
main, .calibration-grid { grid-template-columns: minmax(0,1fr); }
.follow-picture { width: min(100%,640px); min-width: 0; }
.control-panel { grid-row: 2; }
}
@media (max-width: 580px) {
header { display: block; padding: .8rem; }
header nav { margin-top: .8rem !important; }
main { padding: .7rem; }
section { padding: .8rem; }
.start-actions { grid-template-columns: 1fr; }
.calibration-card { padding: .65rem; }
.calibration-panel summary span { display: block; }
button, select { max-width: 100%; overflow-wrap: anywhere; }
}
/* Static blue highlights: no animation loop, external fonts or graphics. */
:root { --accent: #62b4ff; --line: #294466; --muted: #acbdd4; }
body { background: radial-gradient(ellipse at 15% 0%, #101f39 0, transparent 48%), #070a10; }
header { background: #090f19; border-bottom: 1px solid #28466a; box-shadow: 0 5px 26px #0006; }
h1 { letter-spacing: -.035em; font-weight: 750; display: flex; align-items: center; gap: .65rem; }
h1::before { content: ''; display: inline-block; flex: 0 0 12px; width: 12px; height: 12px; border: 2px solid #89d7ff; border-radius: 4px; background: #1475c5; box-shadow: 0 0 16px #238bff70; }
header p { font-size: .92rem; }
section { background: linear-gradient(145deg, #101c2d, #0b121e 65%); border: 1px solid #29405f; border-radius: 16px; box-shadow: 0 10px 28px #0004, inset 0 1px 0 #a7cfff0a; }
h2 { display: flex; align-items: center; gap: .55rem; letter-spacing: -.015em; font-weight: 700; }
h2::before { content: ''; width: 3px; height: 1em; border-radius: 2px; background: var(--accent); box-shadow: 0 0 10px #339eff60; }
nav { gap: .6rem !important; }
nav a.download { border-radius: 10px; background: #101b2b; border-color: #293e5a; }
nav a[aria-current="page"] { background: linear-gradient(135deg,#174b7c,#122d50); border: 1px solid #69b6ff; box-shadow: 0 0 18px #1987ff28, inset 0 1px 0 #a0d7ff22; }
button, a.download, select, input { border-radius: 9px; }
button, a.download { transition: border-color .15s ease, background-color .15s ease; }
button:not(:disabled):hover, a.download:hover { border-color: #72bdff; background: #1b385a; box-shadow: 0 0 14px #278fff24; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline: 3px solid #84d4ff; outline-offset: 3px; }
input[type="number"], input[type="text"], select { background: #080f1b; border-color: #3b5375; color: #f1f6ff; }
input[type="checkbox"] { accent-color: #459fff; width: 1.05em; height: 1.05em; flex-shrink: 0; }
button:disabled { opacity: .55; box-shadow: none; }
#follow-hardware, #save-mode, #save-detection { background: linear-gradient(135deg,#1768b5,#164880); border-color: #69b6ff; box-shadow: 0 0 20px #1684ff24; font-weight: 650; }
#follow-hardware:not(:disabled):hover, #save-mode:hover, #save-detection:hover { background: #1c6db5; box-shadow: 0 0 22px #1684ff45; }
button.danger { background: #381c27; border-color: #bf5d72; color: #ffe8ee; box-shadow: none; }
button.danger:not(:disabled):hover { background: #5b2433; border-color: #ff8da2; box-shadow: 0 0 14px #e94a6820; }
#follow-status { background: linear-gradient(110deg,#102b49,#0b1a2e); border-color: #3677ac; color: #c5e9ff; box-shadow: 0 0 18px #1684ff12; line-height: 1.5; }
#follow-camera-status, #camera-status { color: #a9c7e9; font-size: .92rem; font-variant-numeric: tabular-nums; }
.follow-picture, .camera-wrap { border-radius: 12px; box-shadow: 0 0 0 1px #34547c, 0 0 24px #1782ff12; overflow: hidden; }
.camera, .follow-canvas { border-radius: 12px; }
.value { color: #91d4ff; font-variant-numeric: tabular-nums; }
summary { color: #d8e9ff; }
summary::marker { color: #65b7ff; }
details[open] > summary { color: #8fd0ff; }
.settings-card { border-color: #294466; }
.calibration-card, .endpoint-grid > div { background: #0a1423; border-color: #2d4c70; }
.endpoint-grid > div { box-shadow: inset 0 1px 0 #9dd1ff0a; }
.notice { background: #211c14; color: #f1dbb5; border-left-color: #ddb361; border-radius: 0 8px 8px 0; }
dl { background: #0a1423; border: 1px solid #263c59; border-radius: 10px; padding: .9rem; }
a { color: #9bd6ff; }
@media (prefers-reduced-motion: reduce) { button, a.download { transition: none; } }
</style>
</head>
<body>
<header>
<h1>Kamera / Finger</h1>
<p>Farbpunkt verfolgen und Kameraservos einrichten</p>
<nav class="row" aria-label="Bereich wählen" style="margin-top:1rem">
<a class="download" href="/grab" >Grab</a>
<a class="download" href="/kamera" aria-current="page">Kamera / Finger</a>
<button class="danger" id="all-stop">Alles stoppen</button>
</nav>
<p id="all-stop-status" role="status"></p>
</header>
<main>
<section class="live-panel" id="follow-panel">
<h2>Kamerabild & Farbpunkt</h2>
<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: Foto aufnehmen und den Farbpunkt antippen. Das Foto bleibt ungespeichert; die gewählte Farbe bleibt bis zu deiner nächsten Farbwahl gespeichert.</p>
<p class="muted" id="follow-camera-status" role="status"></p>
<div class="follow-picture">
<img id="follow-live" class="camera" src="/stream.mjpg" alt="Livebild für Kamera und Farbpunkt" >
<canvas hidden id="follow-canvas" width="640" height="480" class="follow-canvas" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
</div>
</section>
<section class="control-panel" aria-label="Kamerasteuerung">
<h2>Nachführung starten & stoppen</h2>
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
<p class="muted">Vorschau zeigt die Erkennung. Echte Nachführung bewegt die Kamera. Dafür muss die Grabautomatik ausgeschaltet sein.</p>
<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="start-actions">
<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>
<details class="settings-card">
<summary>Farbe, Drehrichtung, Tempo und Suche einstellen</summary>
<div class="row">
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
</div>
<div class="row" style="margin-top:.6rem">
<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>
<label>Nachführtempo <input id="follow-speed" type="number" min="40" max="800" step="10" value="80" style="width:6em"> µs/s</label>
<label><input id="follow-search" type="checkbox"> Bei Punktverlust automatisch suchen (nur echte Nachführung)</label>
<button id="follow-save">Farbe, Richtung, Tempo und Suche speichern</button>
<p class="hint">Nachführtempo zum Testen: 400 µs/s. Bei Pendeln reduzieren; manuelle Einrichtung bleibt langsam.</p>
<p id="follow-settings-status" role="status">Endpositionen separat über „Bewegungsgrenzen“ oder die vier Endpunktkarten speichern. Änderungen hier sind nur bei gestoppter Kamera möglich.</p>
<details class="help"><summary>Hinweise zu Tempo und Suche</summary> <p class="notice">Nach erster Erkennung: bei Punktverlust nach 1,5 Sekunden innerhalb der gespeicherten Bewegungsgrenzen suchen. Höchstens 200 µs/s und 60 Sekunden, danach ohne Suchbewegung auf denselben Punkt warten. Wiedererkennung beendet die Suche. Kameraservos stoppen bricht jederzeit ab.</p>
</details>
</details>
<details class="help"><summary>Hinweise zum sicheren Start und Stopp</summary>
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt jede Achse auf die Mitte ihrer gespeicherten Bewegungsgrenzen; 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>
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Suchbewegung nur mit gespeicherter Option nach erster Punkterkennung. Bei Punktverlust bleibt die Farbe gespeichert. Ohne Suche wartet die Kamera an ihrer Position. Nach einem Suchdurchlauf oder spätestens 60 Sekunden wartet sie ebenfalls; bei Wiedererkennung folgt sie weiter. Die Servos halten dabei ihre letzte Position. 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>
</details>
</section>
<section class="calibration-panel">
<details id="calibration-details">
<summary>Endpunkte & manuelle Einrichtung <span class="muted">· Grenzen ändern, Richtungstasten, Mittelstellung</span></summary>
<div class="calibration-grid">
<div class="calibration-card">
<h2>Bewegungsgrenzen manuell eingeben</h2>
<p>Erst Kameraservos stoppen, Werte ändern und ausdrücklich speichern. Die Bewegungsgrenzen gelten für die Nachführung; den erlaubten Fahrweg zum Ermitteln der Endpunkte stellst du darunter ein.</p>
<div class="row">
<label>PAN Minimum <input id="follow-pan-min" type="number" min="500" max="2500" step="1" value="1400" style="width:7em"></label>
<label>PAN Maximum <input id="follow-pan-max" type="number" min="500" max="2500" step="1" value="1600" style="width:7em"></label>
</div>
<div class="row" style="margin-top:.6rem">
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="500" max="2500" step="1" value="1400" style="width:7em"></label>
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="500" max="2500" step="1" value="1600" style="width:7em"></label>
</div>
<h3>Einrichtbereich zum Anfahren der Endpunkte</h3>
<p>Hier kannst du die bisherige Grenze von 1100–1900 µs erweitern. Nur schrittweise erweitern und mit den Richtungstasten prüfen. 500–2500 µs ist nur die technische Eingabegrenze, kein bestätigter Servoweg. Vor mechanischem Anschlag loslassen.</p>
<div class="row" style="margin-top:.6rem"><label>PAN Einrichtung Minimum <input id="follow-pan-setup-min" type="number" min="500" max="2500" step="1" value="1100" style="width:7em"></label><label>PAN Einrichtung Maximum <input id="follow-pan-setup-max" type="number" min="500" max="2500" step="1" value="1900" style="width:7em"></label></div>
<div class="row" style="margin-top:.6rem"><label>TILT Einrichtung Minimum <input id="follow-tilt-setup-min" type="number" min="500" max="2500" step="1" value="1100" style="width:7em"></label><label>TILT Einrichtung Maximum <input id="follow-tilt-setup-max" type="number" min="500" max="2500" step="1" value="1900" style="width:7em"></label></div>
<p id="start-center" class="value" role="status"></p>
<p id="manual-limits-saved" class="value"></p>
<div class="row"><button id="manual-limits-save">Bewegungs- und Einrichtgrenzen speichern</button><button id="manual-limits-discard">Eingaben verwerfen</button></div>
<p id="manual-limits-status" role="status">Werte geladen; keine ungespeicherten Änderungen.</p>
<p class="muted">Einheit µs, keine Winkel. Nachführgrenzen müssen innerhalb des Einrichtbereichs liegen. Minimum darf nicht größer als Maximum sein. Die Startmitte ergibt sich je Achse aus den gespeicherten Bewegungsgrenzen. Die vier Endpunktkarten unten bleiben ebenfalls nutzbar.</p>
</div>
<div class="calibration-card">
<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>
<div class="row"><button id="setup-start">Einrichtmodus starten</button>
<button id="setup-center" disabled>In Mittelstellung fahren</button></div>
<p id="setup-center-status" role="status">Im Einrichtmodus: einmal klicken, langsam zur gespeicherten Mitte fahren. „Bewegung anhalten“ bricht ab.</p>
<p id="setup-note" role="status">1. Einrichtmodus starten. 2. Richtung halten und loslassen. 3. Bei dieser Richtung die Position speichern.</p>
<p id="setup-position" class="value" role="status">Sollposition: lädt …</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="endpoint-grid">
<div style="border:1px solid #35577e;border-radius:8px;padding:.8rem">
<h3 style="margin:0 0 .5rem">Links</h3>
<p id="setup-value-left" class="value">Gespeichert: lädt …</p>
<button id="setup-save-left">Diese Position als Links speichern</button>
<p id="setup-hint-left" class="muted"></p>
</div>
<div style="border:1px solid #35577e;border-radius:8px;padding:.8rem">
<h3 style="margin:0 0 .5rem">Rechts</h3>
<p id="setup-value-right" class="value">Gespeichert: lädt …</p>
<button id="setup-save-right">Diese Position als Rechts speichern</button>
<p id="setup-hint-right" class="muted"></p>
</div>
<div style="border:1px solid #35577e;border-radius:8px;padding:.8rem">
<h3 style="margin:0 0 .5rem">Oben</h3>
<p id="setup-value-up" class="value">Gespeichert: lädt …</p>
<button id="setup-save-up">Diese Position als Oben speichern</button>
<p id="setup-hint-up" class="muted"></p>
</div>
<div style="border:1px solid #35577e;border-radius:8px;padding:.8rem">
<h3 style="margin:0 0 .5rem">Unten</h3>
<p id="setup-value-down" class="value">Gespeichert: lädt …</p>
<button id="setup-save-down">Diese Position als Unten speichern</button>
<p id="setup-hint-down" class="muted"></p>
</div>
</div>
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gilt der oben gespeicherte Einrichtbereich, auch außerhalb der Nachführgrenzen. Mehr Weg nötig? Stoppen, den Einrichtbereich schrittweise erweitern und erneut starten. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet in der Mitte der dann gespeicherten Bewegungsgrenzen.</p>
</div>
</div>
</details>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
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;
}
$('all-stop').onclick = async () => {
try {
await api('/api/stop');
$('all-stop-status').textContent = 'Grab gestoppt und Kameraservo-PWM beendet.';
} catch(e) { $('all-stop-status').textContent = 'Stopp nicht bestätigt: '+e.message; }
};
// Camera marker test has its own frame/overlay, never the trigger geometry.
let followConfig = null, followSession = null, followSample = null, followBusy = false;
let followLatest = null, configEpoch = 0, manualLimitsDirty = false;
let followEpoch = 0;
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
function fillManualLimits(c) {
$('start-center').textContent = 'Beim nächsten Start: PAN '+Math.floor((c.pan_min+c.pan_max+1)/2)+' / TILT '+Math.floor((c.tilt_min+c.tilt_max+1)/2)+' µs (Mitte der gespeicherten Bewegungsgrenzen).';
$('manual-limits-saved').textContent = 'Gespeichert: PAN '+c.pan_min+'–'+c.pan_max+' / TILT '+c.tilt_min+'–'+c.tilt_max+' µs · Einrichtung PAN '+c.pan_setup_min+'–'+c.pan_setup_max+' / TILT '+c.tilt_setup_min+'–'+c.tilt_setup_max+' µs';
for (const axis of ['pan','tilt']) for (const bound of ['min','max','setup-min','setup-max'])
$(`follow-${axis}-${bound}`).value = c[axis+'_'+bound.replace('-','_')];
}
async function saveManualLimits() {
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
const c = {...followConfig};
for (const axis of ['pan','tilt']) {
for (const bound of ['min','max','setup-min','setup-max'])
c[axis+'_'+bound.replace('-','_')] = Number($(`follow-${axis}-${bound}`).value);
if (!['min','max','setup_min','setup_max'].every(b => Number.isInteger(c[axis+'_'+b])) ||
!(500 <= c[axis+'_setup_min'] && c[axis+'_setup_min'] <= c[axis+'_min'] &&
c[axis+'_min'] <= c[axis+'_max'] &&
c[axis+'_max'] <= c[axis+'_setup_max'] && c[axis+'_setup_max'] <= 2500))
throw new Error(axis.toUpperCase()+': Einrichtbereich 500–2500 µs; Minimum darf nicht größer als Maximum sein; Bewegungsgrenzen müssen im Einrichtbereich liegen.');
}
const saved = await api('/api/follow/config',c);
followConfig = saved; configEpoch++; manualLimitsDirty = false;
fillManualLimits(saved);
if (followLatest) renderEndpoints({...followLatest,config:saved});
$('manual-limits-status').textContent = 'Bewegungsgrenzen gespeichert. Farbe, Drehrichtung und Tempo bleiben unverändert.';
}
// Manual limit form bindings.
$('manual-limits-save').onclick = () => followAction(saveManualLimits,'manual-limits-status');
$('manual-limits-discard').onclick = () => {
if (!followConfig) return;
manualLimitsDirty = false; fillManualLimits(followConfig);
$('manual-limits-status').textContent = 'Eingaben verworfen; gespeicherte Grenzen wiederhergestellt.';
};
for (const axis of ['pan','tilt']) for (const bound of ['min','max','setup-min','setup-max']) {
$(`follow-${axis}-${bound}`).oninput = () => {
manualLimitsDirty = ['pan','tilt'].some(a => ['min','max','setup-min','setup-max'].some(b => Number($(`follow-${a}-${b}`).value) !== followConfig?.[a+'_'+b.replace('-','_')]));
$('manual-limits-status').textContent = manualLimitsDirty
? 'Noch nicht gespeichert. „Bewegungs- und Einrichtgrenzen speichern“ drücken oder Eingaben verwerfen.'
: 'Eingaben entsprechen den gespeicherten Grenzen.';
};
}
function fillFollow(c) {
followConfig = c; configEpoch++;
if (!manualLimitsDirty) fillManualLimits(c);
for (const axis of ['pan','tilt']) {
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
}
$('follow-tolerance').value = c.tolerance;
$('follow-speed').value = c.follow_speed ?? 80;
$('follow-search').checked = c.search_enabled ?? false;
}
async function showFollowImage(b64, target, epoch) {
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
await image.decode();
if (epoch !== followEpoch) return;
followCanvas.hidden = false; $('follow-live').hidden = true;
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, statusId = 'follow-status') {
if (followBusy) return;
followBusy = true;
try { await fn(); }
catch (e) { $(statusId).textContent = e.message; }
finally { followBusy = false; }
}
async function saveFollow() {
const c = {...followConfig};
for (const axis of ['pan','tilt']) {
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
}
c.tolerance = Number($('follow-tolerance').value);
c.follow_speed = Number($('follow-speed').value);
c.search_enabled = $('follow-search').checked;
if (!Number.isFinite(c.follow_speed) || c.follow_speed < 40 || c.follow_speed > 800) throw new Error('Nachführtempo: 40–800 µs/s.');
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-settings-status').textContent = 'Farbe, Richtung, Tempo und Suche gespeichert. Endpositionen bleiben unverändert.';
},'follow-settings-status');
$('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.');
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
if (manualLimitsDirty) throw new Error('Bewegungsgrenzen sind noch nicht gespeichert. Bitte dort speichern oder Eingaben verwerfen.');
if (['pan','tilt'].some(axis => $(`follow-${axis}-reverse`).checked !== followConfig[axis+'_reverse']) ||
Number($('follow-tolerance').value) !== followConfig.tolerance || Number($('follow-speed').value) !== (followConfig.follow_speed ?? 80) || $('follow-search').checked !== (followConfig.search_enabled ?? false))
throw new Error('Farbe, Richtung, Tempo oder Suche wurden geändert. Bitte zuerst dort speichern.');
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
if (operation === 'setup') $('setup-note').textContent = 'Einrichtmodus aktiv: Richtung halten, loslassen und die passende Position speichern.';
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; followSample = 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);
const config = await api('/api/follow/remember',{session:followSession,direction});
fillFollow(config);
if (followLatest) renderEndpoints({...followLatest,config});
const names = {left:'Links',right:'Rechts',up:'Oben',down:'Unten'};
const item = endpointState({...followLatest,config},direction);
$('setup-note').textContent = names[direction]+' gespeichert: '+item.saved+' µs. 10 µs Abstand nach innen sind berücksichtigt.';
},'setup-note');
}
async function centerSetup() {
if (!setupReady || !followSession) throw new Error('Zuerst Einrichtmodus starten.');
releaseJog();
const result = await api('/api/follow/center',{session:followSession,sequence:++jogSequence});
if (!result.accepted) throw new Error('Mittelstellungsfahrt durch neueren Bedienbefehl abgebrochen.');
$('setup-note').textContent = 'Mittelstellungsfahrt beauftragt. „Bewegung anhalten“ bricht ab.';
}
async function stopSetupMotion() {
releaseJog();
if (!setupReady || !followSession) return;
await sendJog(null);
$('setup-note').textContent = 'Bewegung angehalten; Sollposition wird gehalten.';
}
$('setup-center').onclick = () => followAction(centerSetup,'setup-note');
$('setup-release').onclick = () => followAction(stopSetupMotion,'setup-note');
document.addEventListener('pointerup',releaseJog);
document.addEventListener('pointercancel',releaseJog);
window.addEventListener('blur',releaseJog);
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'),'setup-note');
function endpointState(f, direction) {
const axis = ['left','right'].includes(direction) ? 'pan' : 'tilt';
let sign = ['left','up'].includes(direction) ? -1 : 1;
if (f.config[axis+'_reverse']) sign = -sign;
const proposed = Math.round(f[axis+'_us'])+(sign < 0 ? 10 : -10);
const origin = f.setup_origin?.[axis] ?? Math.floor((f.config[axis+'_min']+f.config[axis+'_max']+1)/2);
const sideOK = sign < 0 ? proposed <= origin : proposed >= origin;
const saved = f.config[axis+(sign < 0 ? '_min' : '_max')];
return {saved, proposed, canSave:setupReady && !f.centering && sideOK,
hint: !setupReady ? 'Zuerst Einrichtmodus starten.' : f.centering ? 'Mittelstellungsfahrt zuerst anhalten oder abwarten.' : !sideOK
? 'Zuerst in diese Richtung von der Startmitte dieser Sitzung weg fahren.'
: 'Beim Speichern: '+proposed+' µs (10 µs nach innen).'};
}
function renderEndpoints(f) {
for (const direction of ['left','right','up','down']) {
const item = endpointState(f,direction);
$('setup-value-'+direction).textContent = 'Gespeichert: '+item.saved+' µs';
$('setup-save-'+direction).disabled = !item.canSave;
$('setup-hint-'+direction).textContent = item.hint;
}
}
async function pollFollow() {
try {
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
const requestEpoch = configEpoch;
const r = await fetch('/api/follow',{cache:'no-store'});
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
const f = await r.json();
if (requestEpoch !== configEpoch) { setTimeout(pollFollow,100); return; }
followLatest = f;
$('follow-camera-status').textContent = f.camera.error || (f.camera.ready ? f.camera.source+' · '+(f.camera.fps ?? '–')+' Bilder/s · Nachführung '+(f.fps ?? '–')+' Auswertungen/s'+(f.analysis_ms == null ? '' : ' · Farberkennung '+f.analysis_ms+' ms') : 'Warte auf Kamerabild');
if (!f.active && !followSample) { followCanvas.hidden = true; $('follow-live').hidden = false; }
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-center').disabled = !setupReady || f.centering;
$('setup-center-status').textContent = f.centering
? 'Fährt langsam zur Mitte der gespeicherten Endpunkte. „Bewegung anhalten“ bricht ab.'
: 'Im Einrichtmodus: einmal klicken, langsam zur gespeicherten Mitte fahren. „Bewegung anhalten“ bricht ab.';
renderEndpoints(f);
for (const id of ['follow-pan-reverse','follow-tilt-reverse','follow-tolerance','follow-speed','follow-search','follow-save','follow-green','follow-red','follow-capture','follow-preview','follow-hardware','setup-start']) $(id).disabled = f.active;
if (followConfig) followConfig = {...f.config};
if (!manualLimitsDirty) fillManualLimits(f.config);
for (const id of ['follow-pan-min','follow-pan-max','follow-tilt-min','follow-tilt-max','follow-pan-setup-min','follow-pan-setup-max','follow-tilt-setup-min','follow-tilt-setup-max','manual-limits-save','manual-limits-discard']) $(id).disabled = f.active;
$('manual-limits-saved').textContent = 'Gespeichert: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs · Einrichtung PAN '+f.config.pan_setup_min+'–'+f.config.pan_setup_max+' / TILT '+f.config.tilt_setup_min+'–'+f.config.tilt_setup_max+' µs';
$('start-center').textContent = 'Beim nächsten Start: PAN '+Math.floor((f.config.pan_min+f.config.pan_max+1)/2)+' / TILT '+Math.floor((f.config.tilt_min+f.config.tilt_max+1)/2)+' µs (Mitte der gespeicherten Bewegungsgrenzen).';
$('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' ? (f.centering ? 'Mittelstellung wird angefahren' : 'Richtungstaste gedrückt halten') : (f.reacquiring ? 'Punkt wieder sichtbar · bestätige Erkennung' : f.waiting_for_target ? 'Warte auf gespeicherten Farbpunkt · keine Suchbewegung' : f.searching ? 'Suche nach Farbpunkt · '+f.search_seconds+' / 60 s' : f.target ? 'Punkt erkannt' : 'Farbpunkt gerade nicht sichtbar · Auswahl bleibt gespeichert'))+' · 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,100);
}
pollFollow();
</script>
</body>
</html>