working camera movement

This commit is contained in:
marcinator_2 committed 2026-10-16 01:38:10 +02:00
1 parent fce8f62534
commit 11e0875a20
21 files changed
+2117 -334

No files matched your search

+48 -8
View File
@@ -7,8 +7,10 @@ Gewünscht sind ein kräftiger Schreckeffekt, Kamera, Weboberfläche und lokal
gespeicherte Bilder/Videoclips. Der Nutzer hat nun eine erste Pi-Software mit
Liveansicht und simuliertem Effektablauf beauftragt; das allgemeine
Uploadskript und die Blender-Konzeptvorschau wurden ebenfalls freigegeben.
Hardwarebau und Anschluss echter Aktoren folgen nach Klärung der Mechanik,
Versorgung und Sicherheit im Plan.
Die beiden Kameraservos sind inzwischen laut Nutzer verdrahtet; Farbpunktmodus
und manuelle Einrichtung sind implementiert. Hardwarebau/Anschluss der
Grabaktoren folgen nach Klärung von Mechanik, Versorgung und Sicherheit.
Aktueller Stand und offene Pi-Prüfungen stehen am Anfang von `PLAN.md`.
## Arbeitsweise
@@ -68,25 +70,63 @@ Versorgung und Sicherheit im Plan.
Pi-Pinübersicht `RASPI_PINS.svg`; Änderungen an betroffenen Darstellungen
synchron pflegen und in `PLAN.md` erfassen. Geplant: vorhandener PCA9685 am
Pi 5, CH0=PAN (unten), CH1=TILT (oben), VCC=3,3 V, eigene vorläufige
5-V-Servoversorgung. Platinenfoto und Reihenbeschriftung dokumentiert; Netzteil,
Servosteckerpolung, Kameraport und reale Verdrahtung noch ungeprüft. 6-V-Handversorgung nicht auf dieselbe
5-V-Servoversorgung. Platinenfoto und Reihenbeschriftung dokumentiert.
Verkabelungsfotos in `VERDRAHTUNG.md`: sichtbare Anordnung plausibel,
Netzteildaten, elektrische Prüfung, Kameraport und vollständiger Funktionstest offen.
6-V-Handversorgung nicht auf dieselbe
V+-Schiene legen. Nutzer meldet Verdrahtung fertig; aktuell Powerbank für
Kameraservos, Modell/Stromreserve und reale Funktion noch ungeprüft.
- Neuer Farbpunkt-Test (`halloween/follow.py`): lokale HSV-Farbwahl per
flüchtigem Beispielfoto oder Rot/Grün, Vorschau ohne Hardware; echte Ausgabe
nur bewusst und außerhalb der automatischen Auslösung am PCA9685 CH0/CH1.
1500-µs-Startstellung vorab entkoppelt prüfen, enge konfigurierbare Grenzen,
langsame Nachführung. Globaler Stopp/Reset beendet PWM. Heartbeat-/Bild-/
Auswertungs- und Zielverlust beenden die Sitzung; kein automatischer Neustart.
Startstellung je Achse aus (Bewegungsminimum + Bewegungsmaximum)/2, halbe µs
aufrunden; vorab entkoppelt prüfen. Enge konfigurierbare Grenzen,
einstellbare Nachführung 40–800 µs/s, neuer Standard 400, alte Dateien ohne
Tempo behalten 80. Follow-Zeitschritt maximal 100 ms; manuelle Einrichtung
bleibt langsam. Pi-Kamera-Zielrate 15 Bilder/s, Demo ca. 5; gemessene Bildrate,
Auswertungsrate und Farberkennungsdauer im UI, reale Pi-Leistung noch prüfen.
„Alles stoppen“ beendet PWM. Heartbeat-/Bild-/
Auswertungsverlust beenden die Sitzung; kein automatischer Neustart.
Optionale persistierte Farbpunktsuche (Default aus): nur echte Nachführung,
erst nach erkanntem Ziel und 1,5 s Verlust. Einmalige Schwenks auf aktueller
Höhe und an beiden TILT-Grenzen, nur Follow-Endpunkte, max. min(Tempo,200) µs/s.
Nach einem Durchlauf/60 s an letzter Position auf denselben Farbpunkt warten;
zwei aufeinanderfolgende Erkennungen übernehmen Nachführung. Erste Erkennung
hält Suchbewegung an. Ohne Suche/Ersterkennung ebenfalls warten, PWM hält
Position. Farbe bleibt dauerhaft bis neuer Farbwahl gespeichert. Bild-/Heartbeatwatchdogs gelten weiter.
Farb-/Pulsparameter bleiben in data/follow.json; keine Bilder/Trainingsdaten.
Noch keine unabhängige OE-/Hardwareabschaltung; I²C-Ausfall oder harter
Prozessabbruch kann PWM stehen lassen. Nur beaufsichtigter Test, echte
Servos und Versorgung noch zu prüfen. Details in SOFTWARE.md.
- WebUI getrennt: `/` oder `/grab` für Grab/Ereignisse/Profile, `/kamera` für
Farbpunkt/Endpunkte mit sofortigem Livebild. Eigenständige Bedienelemente und
Poller, ein gemeinsamer Kameraproduzent. Grab-Stopp/-Reset wirkt nur auf Grab,
Kamerastopp nur auf Follow; „Alles stoppen“ auf beide. Farbvorschau parallel
erlaubt, echte Kamerabewegung bei aktiver Grabautomatik weiterhin gesperrt.
Kamera-Start/-Stopp verändert keinen gespeicherten Grabmodus/Controllerstopp.
- WebUI nutzt breite Desktopansicht: Bild links, Bedienung rechts; schmal
untereinander. Kamera-Endpunkte/manuelle Einrichtung und Detailparameter
aufklappbar, Status/Start/Stopp unmittelbar sichtbar. Keine Änderung an
Servo-/Speicherverträgen durch Layoutänderungen.
- Manueller Kamera-Einrichtmodus: Richtungstasten halten statt automatischer
Anschlagfahrt; beide Achsen vor Anschlag stoppen. 40 µs/s, 0,35-s-Bewegungslease,
Loslassen hält Position, Stopp beendet PWM. Kein Farbzielsuchen; Grenzen aus
Sollwerten mit 10 µs Innenabstand speichern. Keine Endschalter/Strommessung;
absolute Testgrenzen 1100–1900 µs bleiben. Kein realer Anschlagtest bestätigt.
Einrichtbereich pro Achse gestoppt im WebUI einstellbar, bisherige Werte
1100–1900 bleiben als Standard. Technische Eingabehülle 500–2500 µs ist
kein bestätigter Servoweg; nur schrittweise erweitern. Nachführgrenzen liegen
darin; Minimum darf Maximum nicht überschreiten. Kein realer Anschlagtest bestätigt.
Startmitte je Sitzung als festen Bezug für Grenzspeicherung halten; erst
beim nächsten bewussten Start neu berechnen. Speichern bewegt keine Servos.
„In Mittelstellung fahren“ nur in aktiver Einrichtsitzung: beide Achsen
höchstens 40 µs/s zur aktuellen Endpunktmitte, ohne Grenzen zu speichern.
Stopp/Richtungstaste bricht ab, Kamera-/Heartbeat-Watchdog bleibt aktiv.
Endpunkte in vier Richtungs-Karten mit Serverwert und Speicherkandidat zeigen;
zusätzliche Bewegungs-/Einricht-Min-/Max-Eingabe mit gemeinsamem
Speichern/Verwerfen nur gestoppt; alte Konfiguration erhält für die
Einrichtung 1100–1900, bestehende Endpunkte bleiben erhalten.
Entwurf und Serverwerte deutlich trennen. Start speichert nichts. Farbe/Richtung
nur separat und gestoppt speichern; Speicherfehler direkt im Einrichtbereich.
- Aktueller Nutzerumfang: zwei Antriebe, einer für die Hand und einer
für die Atemrotation. Nutzer sieht nun 230 V mit Relais für Rotation
vor; CHANCS 60KTYZ (Amazon B07WXFWYBZ, 220–240 V, ca. 2,5 U/min)
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+286 -1
View File
@@ -1,4 +1,43 @@
# Halloween – Projektregeln
# Halloween – Arbeitsplan
## Aktueller Stand · 16. Oktober 2026
- Implementiert: getrennte Grab-/Kameraseiten, Farbpunktwahl, manuelle
Servoeinrichtung, vier Endpunktkarten sowie numerische Bewegungs- und
Einrichtgrenzen pro Achse. Bedienung in [SOFTWARE.md](SOFTWARE.md).
- Aktueller Zielverlustvertrag: Farbe dauerhaft gespeichert; Hardware-Sitzung
wartet bei Punktverlust weiter. Optionale Suche nach 1,5 s, nach einem
Durchlauf/60 s ohne Suchbewegung warten, bei zwei Funden weiter folgen.
Stopp/Fehler/Neustart beenden weiterhin die Freigabe.
- Einrichtbereich standardmäßig 1100–1900 µs, im WebUI gestoppt innerhalb
500–2500 µs einstellbar. Nachführgrenzen liegen darin. Startmitte je Achse = (Minimum + Maximum)/2.
Technische Eingabehülle ist keine Bestätigung des mechanischen Fahrwegs.
- Zuletzt lokal geprüft: 104 Python-Tests (89 bestanden, 15 optionale
übersprungen) und drei JS-Prüfungen. Optionale begrenzte Farbpunktsuche
implementiert; echter Suchtest und Upload stehen noch aus.
- Pi-API am 16. Oktober: Kamera 15,0 Bilder/s, Nachführung inaktiv, Tempo noch
80 µs/s. Aktuelle Grenzen PAN 588–2448, TILT 715–1620; nur gelesen.
SSH-Batchzugang weiterhin abgelehnt; kein Deployment durch diese Prüfung.
- Farberkennung beschleunigt (JPEG-Reduktion, Pillow-Maske, 10-ms-Takt): lokal
ca. 11,6 → 2,2 ms, gleiche Ergebnisse; Pi-Messung und Upload offen.
- Nutzer meldet Pendeln bei ruhigem Punkt (Tempo 800). Gegenmaßnahme: Verstärkung
auf 600 µs/s je Bildanteil begrenzt; Wirkung am Pi noch zu prüfen.
- Nutzer meldet Zielverlust: Screenshot zeigt blauen Punkt auf bläulichem Papier
(Papier Hue 152/S 80–100, Punkt 168/157); Toleranz 18 ließ das Papier als zweite
Fläche mitzählen → kein Ziel. `locate()` nutzt jetzt Stufen streng→locker;
lokal mit dem Screenshot und Regressionstest geprüft, Pi-Test offen.
- Nutzerstand: echtes Kameralivebild bestätigt, Aufbau laut Nutzer verdrahtet,
separate Powerbank; Nutzer erprobt die Servoeinrichtung. Keine vollständig
protokollierten realen Endpunkte oder bestätigte Fehlerabschaltung vorhanden.
- Offen: letzte Version auf den Pi übertragen, Achsrichtungen/Mittelstellung,
Endpunkte, Versorgung und Stoppverhalten am Aufbau prüfen und dokumentieren.
- Breites responsives Layout lokal umgesetzt: Bild/Steuerung nebeneinander,
Details aufklappbar. UI-Prüfungen siehe Abschnitt vom 16. Oktober unten.
- Anschlussplan Revision 14; Softwareänderungen erfordern keine neue Verdrahtung.
Die folgenden datierten Abschnitte dokumentieren auch frühere Zwischenstände.
Bei abweichenden Grenzen oder Bedienabläufen gilt der aktuelle Stand oben.
## Verdrahtungsplan vom 15. Oktober 2026
@@ -396,3 +435,249 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen.
Loslassen/Stopp bei echtem Aufbau prüfen. Beide Achsen vor Anschlag stoppen.
- Abschlussprüfung lokal: 87 Python-Tests, davon 72 bestanden, 15 optionale
OpenCV-/Modelltests übersprungen. Bisherige JS-Tests und JS-Syntaxprüfung bestanden.
## Sichtprüfung der Verkabelung vom 15. Oktober 2026
- Nutzerfoto ohne angeschlossene Servoversorgung als PCA9685_VERKABELT_FOTO.jpg
abgelegt und in VERDRAHTUNG.md Revision 7 verlinkt. Sichtbare Servosteckrichtung
und Anordnung der Steuerstecker erscheinen passend zum Plan.
- Nicht bestätigt: Pi-seitige Pins (nicht vollständig im Foto), genaue
Kanalnummern/Zuordnung zu PAN/TILT, elektrische Durchgängigkeit,
Versorgungspolung und echte Funktion. Kein Einschalt- oder Bewegungstest.
- Ergänzendes Pi-Foto RASPI_VERKABELT_FOTO.jpg in VERDRAHTUNG.md Revision 8:
sichtbare Anordnung passt zu Grün=1, Blau=3, W03=5 (violett wirkend), Grau=6.
Frühere Lücke der Pi-Sichtprüfung ergänzt; keine elektrische Messung oder
Bestätigung der Versorgungspolung/Servofunktion daraus ableiten.
## Getrennte Grab und Kameraseiten vom 15. Oktober 2026
- Erledigt: `/` und `/grab` mit Grabsteuerung, Ereignissen und Profilen;
`/kamera` mit Livebild, Farbpunkt und Endpunkteinrichtung. Jede Seite hat
eigene Bedienelemente und Poller; gemeinsamer Kameraproduzent bleibt erhalten.
- Erledigt: Vorschau verändert den Grabzustand nicht und darf parallel zur
Automatik laufen. Nur echte Kamerabewegung bleibt bei aktiver Automatik
gesperrt. Keine automatische Umschaltung gespeicherter Einstellungen.
- Erledigt: Grab-Stopp/-Reset wirkt nur auf Grab; Kamerastopp nur auf Kamera.
Explizites „Alles stoppen“ bleibt auf beiden Seiten, alte globale APIs bleiben
für bestehende Clients erhalten. Livebild auf Kameraseite auch ohne Sitzung.
- Lokal geprüft: Seiteninhalt/Pollertrennung, unabhängige API-Aktionen,
Hardware-Interlock und globale Abschaltung mit künstlichem Treiber.
- Offen am Pi: Upload, Navigation und getrennte Bedienung am Gerät prüfen;
gemeinsame Kamera erfordert nach Bewegung neue Bereichs-/Linienkontrolle.
- Abschluss lokal: 89 Python-Tests, 74 bestanden, 15 optionale OpenCV-/
Modelltests übersprungen; beide bestehenden JS-UI-Tests und Syntaxprüfung
beider Seiten bestanden. Beide Seiten und Navigation im Demo-Browser
visuell geprüft; sofortiges Kameralivebild bestätigt. Kein Pi-Upload erfolgt.
## Verständliche Endpunktspeicherung vom 15. Oktober 2026
- Befund aus Nutzer-Screenshot: eingegebene 500/2500 µs widersprechen den
gespeicherten 1400–1600 µs. Oberer Speichern-Knopf wird während aktiver
Einrichtung abgelehnt; Fehleranzeige war vom Bedienort entfernt.
- Erledigt: editierbare Grenzfelder durch vier Richtungs-Karten mit tatsächlichem
Serverwert, Kandidat (10 µs nach innen) und eindeutigem Speicherknopf ersetzt.
Unpassende Seite/Mittelstellung sperrt den Knopf mit Hinweis. Bestätigung und
Fehler im Einrichtbereich; gespeicherte Werte bleiben dauerhaft sichtbar.
- Erledigt: Start speichert keine Formularwerte; Farb-/Richtungsspeicherung
übernimmt keine Grenzwerte aus alten Feldern. Aktive Einstellungen/Startknöpfe
gesperrt. Veraltete Statusantworten nach Speicherung verworfen.
- Damaliger Zwischenstand: feste Testgrenzen 1100–1900 µs; durch den
späteren einstellbaren Einrichtbereich unten abgelöst.
- Lokal geprüft: Endpoint-UI-Regressionen und Backendtests; Browserdemo mit
Links-Bewegung und bestätigtem Speichern auf 1460 µs. Dies ist ein künstlicher
Testwert, keine Endposition der echten Halterung. Upload und Pi-Prüfung offen.
- Abschluss: 89 Python-Tests, davon 74 bestanden und 15 optionale OpenCV-/
Modelltests übersprungen. Drei JS-UI-Prüfungen inklusive neuer Endpunkt-
Regression und JS-Syntaxprüfung bestanden. Zweispaltiges Kartenlayout und
persistente Werte nach Demo-Neustart im Browser geprüft.
## Manuelle Grenzfelder wiederhergestellt am 15. Oktober 2026
- Auf Nutzerwunsch: sichtbare PAN-/TILT-Min-/Max-Felder zusätzlich zu den
Endpunktkarten. Eigener Speichern-/Verwerfen-Knopf; nur gestoppt editierbar.
- Entwurf und gespeicherte Serverwerte getrennt anzeigen, ungespeicherte Werte
nicht durch Polling überschreiben; Start verlangt Speichern oder Verwerfen.
Separate Speicherung erhält Farbe/Drehrichtung. Damals noch feste Grenzen
1100–1900 µs; inzwischen durch den einstellbaren Einrichtbereich abgelöst.
- Lokale JS-Regression für korrekte Werte, getrennte Speicherung, ungültige
Grenzen und Startblockade ergänzt. Echte Pi-Prüfung nach Upload offen.
## Einstellbarer Einrichtbereich am 15. Oktober 2026
- Nutzerkorrektur: feste 1100–1900 µs reichen zum Ermitteln realer Endpunkte
möglicherweise nicht. Jetzt pro Achse separate Einricht-Min-/Max-Felder,
zusammen mit Nachführgrenzen nur gestoppt explizit speichern/verwerfen.
- Jog begrenzt auf gespeicherten Einrichtbereich, Nachführung auf Endpunkte.
Technische Hülle 500–2500 µs, keine Aussage über mechanisch erlaubte Winkel.
Bestehende Konfigurationen behalten 1100–1900 und ihre bisherigen Endpunkte.
- Lokal geprüft: Migration, Persistenz, aktive Änderung abgelehnt, erweitertes
Jog/Begrenzen und Endpunktspeichern, getrennte Followgrenzen; JS-Formtests.
- Offen: Upload, Anzeige und beaufsichtigtes Einrichten am echten Pi.
- Abschlussprüfung lokal: 90 Python-Tests (75 bestanden, 15 optionale übersprungen),
drei JS-Prüfungen bestanden; Browserdemo bestätigt erweiterten Einrichtwert
und Erhalt nach Neuladen sowie Verwerfen. Keine echte Servobewegung getestet.
## Dokumentationsabgleich am 15. Oktober 2026
- README, Bedienanleitung, Uploadhinweise, Anschlussplan und Projekt-/Modulregeln
auf getrennte Seiten und einstellbare Einrichtgrenzen abgeglichen.
- Frühere feste Grenzen im Verlauf als abgelöste Zwischenstände gekennzeichnet.
Lokale Prüfung, Nutzerbeobachtungen und offene Pi-Tests getrennt dargestellt.
- Nur Dokumentation geändert. Verifiziert: 24 lokale Links einschließlich
Überschriftenzielen, Revision 12 in Plan/Zeichnung, gültige SVG-Dateien,
AGENTS.md unter 400 Zeilen und git diff --check ohne Fehler.
Keine erneute Hardware-/Softwareprüfung erforderlich.
## Startmitte aus gespeicherten Endpunkten · 15. Oktober 2026
- Bewusster Start von Einrichtung/Nachführung berechnet PAN/TILT getrennt aus
gespeicherten Bewegungsgrenzen, halbe µs aufgerundet. Dienststart bleibt
inaktiv. Startwerte werden im WebUI angezeigt; kein automatischer Resume.
- Bereiche müssen nicht mehr 1500 enthalten, aber geordnet im Einrichtbereich
liegen. Speicherknöpfe beziehen sich auf feste Startmitte der Sitzung;
Speichern bewegt nichts, nächster Start verwendet die neuen Endpunkte.
- Dokumentation und Anschlussplan Revision 13 nachgeführt. Upload und echte
Zentrierfahrt am Pi offen; keine Hardware im lokalen Test angesteuert.
- Verifiziert lokal: 92 Python-Tests (77 bestanden, 15 optionale übersprungen),
drei JS-Prüfungen bestanden. Browserdemo startet bei PAN 1450/TILT 1600 µs
aus gespeicherten 1200–1700/1400–1800; keine reale Servoausgabe.
## Mittelstellungsbutton · 15. Oktober 2026
- Im aktiven Einrichtmodus „In Mittelstellung fahren“: beide Achsen bis 40 µs/s
zur Mitte der zuletzt gespeicherten Bewegungsgrenzen. Kein neuer Hardwarestart
oder Umschalten aus der Nachführung durch diesen Button. Keine Endpunktänderung.
- „Bewegung anhalten“/Richtungstaste bricht ab; alte Sequenzen können keine
spätere Stoppentscheidung aufheben. Watchdogs und Kamerastopp bleiben aktiv.
- Dokumentation und Anschlussplan Revision 14 aktualisiert. Upload und echte
Fahrt am Pi weiterhin offen; lokale Prüfung mit künstlichem Treiber/Demo.
- Verifiziert: 95 Python-Tests (80 bestanden, 15 optionale übersprungen),
drei JS-Prüfungen und JS-Syntaxprüfung bestanden. Browserdemo: PAN von 1450
auf 1508 verfahren und per Mittelstellungsbutton auf 1450 zurückgeführt,
TILT blieb bei 1600. Anschließend Kamerastopp geprüft, keine echte Hardware.
## FPS- und Nachführungsdiagnose · 15. Oktober 2026
- Nur lesend am laufenden Pi über Heimnetz geprüft; keine Bewegung, kein Start,
kein Deployment und keine Änderung der Laufzeiteinstellungen.
- MJPEG-Messung über 15,09 s: 71 empfangene Bilder, 4,72 Bilder/s zwischen
erstem und letztem Bild. Median Bildabstand 201,4 ms, größter Abstand 310,9 ms.
Gemessen ist die ausgelieferte Bildrate über WLAN, nicht die Sensorbildrate.
- API meldet echte Pi-Kamera ohne Fehler, Detektionsauswertung ca. 5/s.
Nachführung inaktiv; letzter Fehler „Farbpunkt verloren; erneut starten“.
- Vom Pi gelieferte camera.html ist bytegleich zur lokalen Datei. SSH ohne
interaktive Anmeldung abgelehnt; Python-Dateien auf dem Pi nicht abgeglichen.
- Lokaler Code: camera.py wartet nach jedem Bild 0,2 s; stream.mjpg zusätzlich
0,1 s zwischen Prüfungen. Kamera-Follow-Canvas wird über Statuspolling mit
mindestens 0,3 s Abstand erneuert (höchstens ca. 3,3 Anzeigen/s).
- follow.py wertet nur neue Kamerabilder aus. Nachführung: normierter
Bildfehler × 160 µs/s, höchstens ca. 80 µs/s, Totzone ±6 % der Bildbreite/-höhe.
Einrichtung/Mittelstellung bleiben absichtlich auf 40 µs/s begrenzt.
- Live gespeicherte Bewegungsbereiche: PAN 588–2448 µs, TILT 715–1620 µs.
Falls der laufende Python-Code dem lokalen entspricht, dauert bereits die
Sollwertänderung über den ganzen Bereich selbst bei durchgehend maximalem
Tempo mindestens ca. 23,3 s bzw. 11,3 s. Keine gemessene mechanische Fahrzeit.
- Ergebnis: geringe Auslieferungs-/Anzeigebildrate und sehr langsame
Sollwertnachführung sind konkrete Softwarebremsen. Die reale Servogeschwindigkeit,
Versorgung unter Last und tatsächliche Nachführleistung bleiben ungeprüft.
- Möglicher nächster Schritt: höhere kontrollierte Kamerabildrate und einstellbare
Nachführgeschwindigkeit, anschließend beaufsichtigter Vergleich mit echten
Bewegungen. Bestehende Endpunkte, langsames Einrichten und Watchdogs erhalten.
## Schnellere Farbnachführung · 15. Oktober 2026
- Nutzer meldet schnelle Grundstellungsfahrt und wünscht vergleichbar zügige
Nachführung. Das bestätigt keine gemessene mechanische Geschwindigkeit.
- Lokal umgesetzt: Nachführtempo 40–800 µs/s, Standard bei neuer Konfiguration
400. Vorhandene Dateien ohne diesen Wert behalten 80 und alle Endpunkte;
nach Upload zum Test ausdrücklich 400 im WebUI speichern. Nah am Bildzentrum
proportional langsamer, Totzone und Endpunkte bleiben erhalten.
- Pi-Kamera-Zielrate 15 Bilder/s bei 640×480, keine gespeicherte Altbild-Queue;
bisherige feste 200-ms-Pause ersetzt. MJPEG-Prüfung alle 20 ms, Follow-UI
100 ms nach Antwort. Demo bleibt ca. 5 Bilder/s. Gemessene Producer-FPS,
Follow-Auswertungsrate und Farberkennungsdauer jetzt sichtbar.
- Bei verzögerten Bildern höchstens 100 ms Sollwertänderung nachholen.
Einrichtung/Mittelstellungsbutton weiter 40 µs/s, Stopp-/Watchdog-Regeln
unverändert. Kein zusätzlicher Hardwarestart oder automatisches Wiederanlaufen.
- Verifiziert: 98 Python-Tests, davon 83 bestanden und 15 optionale übersprungen;
drei JS-Prüfungen und git diff --check bestanden. Regressionen prüfen Tempo
bei 15/20 Hz, Bildlücken, Altdateien und Endpunkterhalt.
- Lokale Browserdemo: Tempo 500 und anschließend 400 gespeichert, Endpunkte
unverändert. Vorschau ca. 4,8 Auswertungen/s, Producer ca. 4,9 Bilder/s,
Farberkennung rund 18–20 ms auf Entwicklungsrechner. Stopp und Sperrung der
Eingaben während Vorschau geprüft. Screenshot-Aufnahme scheiterte zweimal
am Browser-Timeout; sichtbare Zustände über Browser-Zugänglichkeitsbaum geprüft.
- README, SOFTWARE, Modul-/Testanweisungen und AGENTS aktualisiert.
- Offen: Upload, echte Pi-Bildrate/Last und Nachführung mit bewegtem Farbpunkt.
Kein Deployment und keine echte Servobewegung durch diese Änderung.
## Begrenzte Farbpunktsuche · 16. Oktober 2026
- Nutzer beauftragt Suchbewegung innerhalb gespeicherter Endpunkte.
- Lokal umgesetzt: gespeicherte Option (anfangs aus), erst nach erkanntem
Farbpunkt und 0,5 Sekunden Verlust; zunächst letzte Bildrichtung, dann
Schwenks auf aktueller Höhe und beiden TILT-Grenzen. Grenzen bleiben erhalten.
Maximal min(Nachführtempo,200) µs/s, ein Durchlauf oder 60 Sekunden; ohne Fund
Sitzung/PWM beenden. Zwei aufeinanderfolgende Funde beenden Suche; erster
Fund hält Suchfahrt an. Preview/Setup suchen nicht. Kein automatischer Start.
- Verifiziert: 102 Python-Tests (87 bestanden, 15 übersprungen), drei JS-Tests.
Grenzen, umgekehrte Achsen, Zeitlimit, Durchlaufende, Wiedererkennung, erneuter
Verlust, gesperrte Achse, Stopp und Kamera-/Heartbeat-/Auswertungsfehler simuliert.
- Live nur gelesen: 15,0 Producer-Bilder/s, Suchoption noch nicht auf Pi,
Nachführtempo 80, Achsumkehr jeweils aus. Keine Servos angesteuert.
- Offen: Upload per Nutzeranmeldung, Suchoption aktivieren und realen Punkt
erst erkennen lassen, dann aus dem Bild führen. Tatsächliche Suchabdeckung,
Wiedererkennung, Richtung und Geschwindigkeit am Aufbau verifizieren.
- Browserdemo verifiziert: Suchoption eingeschaltet, gespeichert und nach
Neuladen weiterhin aktiv; Endpunkte unverändert. Screenshot unter
`/tmp/halloween-suchoption.png` (lokale Demo, keine echte Bewegung).
JS-Syntax und git diff --check ebenfalls bestanden.
## Übersichtliche WebUI · 16. Oktober 2026
- Auf Nutzerwunsch breite Ansicht bis 1800 px, ab 1000 px zwei Spalten.
Kamera: Bild links, Status/Start/Stopp rechts, Detailparameter aufklappbar.
Endpunkte/manuelle Einrichtung gemeinsamer Klappbereich, darin Zahlenwerte
und Richtungstasten nebeneinander. Grabseite entsprechend verbreitert;
Auslöseeinstellungen, Ereignisse und Profile aufklappbar.
- Lesbare Schrift/Bedienelemente beibehalten; Desktopkopf mit Alles-Stopp
bleibt sichtbar. Mobil normale einspaltige Seite ohne seitlichen Überlauf.
IDs, JavaScript-Abläufe, API, Servo- und Speicherlogik unverändert.
- Verifiziert: zwei Seitentests, drei bestehende JS-Tests, JS-Syntax, eindeutige
HTML-IDs und git diff --check. Kein neuer Volltest der unveränderten Steuerung.
Lokale Browserdemo bei normaler Desktopgröße, 1920×1080 und 390×844:
Bild/Bedienung nebeneinander bzw. untereinander, kein horizontaler Überlauf
auf beiden schmalen Seiten, Ausklappen erreichbar. Farbvorschau gestartet
und gestoppt; Demo-Einrichtung PAN 1500→1534 und Mittelstellung→1500 geprüft.
Keine echten Aktoren angesteuert, kein Pi-Upload.
- Dokumentation/Anweisungen aktualisiert. Upload und Kontrolle auf Nutzer-Pi
noch offen; vorhandene Endpunkte bleiben erhalten.
## Farbpunkt bei Bildverlust behalten · 16. Oktober 2026
- Ursache: Farbe war bereits gespeichert; bisheriger 1-s-Zielverluststopp bzw.
Suchende beendete jedoch die Servositzung. Nutzer wünscht dauerhaftes Behalten.
- Geändert: Ohne Fund aktive gesunde Sitzung an letzter Position warten lassen;
keine neuen Bewegungsbefehle, PWM hält Position. Nach zwei Funden wieder folgen.
Suchbeginn von 0,5 auf 1,5 s verzögert; ein Durchlauf/60 s endet im Wartezustand
statt Sitzungsabbruch. Keine endlosen Suchrunden. UI benennt Wartezustand.
- Farbe bleibt wie bisher atomar in follow.json bis neuer Farbwahl, auch nach
Stopp/Neustart. Kein gespeichertes Foto oder eindeutige Objektidentität.
Nach explizitem Stopp, Fehler oder Neustart weiterhin bewusst neu starten.
- Verifiziert: 104 Python-Tests (89 bestanden, 15 optionale übersprungen), drei
JS-Tests. Unter anderem drei Minuten simulierte Punktabwesenheit, kein
wiederholtes Suchen, Wiederaufnahme, Farbe nach Neukonstruktion sowie
bestehende Stopp-/Watchdog-Regeln. JS-Syntax und git diff --check bestanden.
- Nur lokale Umsetzung; Upload und echter Wiedererkennungstest noch offen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+44 -1
View File
@@ -14,6 +14,48 @@ GPIO-Steuerung der Grabaktoren, Besucher-Wiedererkennung und Clip-Aufnahmen blei
Der separate [Farbpunkt-Test](SOFTWARE.md#kamera-folgt-einem-farbpunkt) kann nach
bewusstem Start die beiden Kameraservos am PCA9685 bewegen; echte Hardwareprüfung steht aus.
Stand der Dokumentation: **16. Oktober 2026**. Die neue Grenzeneinstellung ist
lokal einschließlich Browserdemo geprüft; Upload und Prüfung dieser Version
an der echten Halterung stehen noch aus.
Die WebUI hat zwei Seiten: **Grab** (`/grab`, auch Startseite `/`) und
**Kamera / Finger** (`/kamera`), mit eigener Bedienung und gemeinsamem Livebild.
Auf breiten Bildschirmen stehen Bild und Steuerung nebeneinander. Detailwerte
sind aufklappbar; Endpunkte und Mittelstellung findest du unter
**Endpunkte & manuelle Einrichtung**. Auf kleinen Bildschirmen wird einspaltig angezeigt.
Grab-Stopp und Kamerastopp wirken auf ihren jeweiligen Bereich; **Alles stoppen**
beendet beide. Farbvorschau und Grabautomatik können parallel laufen; echte
Kamerabewegung erfordert ausgeschaltete Grabautomatik.
Auf **Kamera / Finger** lässt sich ein Farbpunkt im Beispielfoto auswählen.
Der Name bezeichnet keine automatische Erkennung eines unmarkierten Fingers.
Für die Servoeinrichtung ist kein Farbpunkt nötig: Richtungstaste halten,
vor dem Anschlag loslassen und die Position als Links/Rechts/Oben/Unten speichern.
**In Mittelstellung fahren** bringt beide Achsen im Einrichtmodus langsam zurück
zur gespeicherten Mitte; **Bewegung anhalten** bricht die Fahrt ab.
**Nachführtempo** ist separat einstellbar (40–800 µs/s). Als ersten schnelleren
Test 400 bei gestoppten Servos speichern; alte Konfigurationen behalten zunächst
80. Neue Kamera-Zielrate: 15 Bilder/s, tatsächliche Bild-/Auswertungsrate steht
über der Vorschau. Die Pi-API meldet am 16. Oktober 15 Bilder/s ohne aktive Nachführung.
Die optionale **Suche bei Punktverlust** ist lokal ergänzt: nach erster
Erkennung nur innerhalb der gespeicherten Bewegungsgrenzen suchen, bei Fund
weiter folgen; ohne Fund nach einem Durchlauf/spätestens 60 Sekunden anhalten
und auf denselben Punkt warten. Die gewählte Farbe bleibt dauerhaft gespeichert.
Unter **Farbe, Drehrichtung, Tempo und Suche einstellen** ausdrücklich aktivieren
und speichern. Upload und echter Suchtest stehen noch aus.
Die [Grenzeneinstellung](SOFTWARE.md#eindeutige-speicherung-der-endpositionen)
bietet zwei Bereiche pro Achse:
- **Bewegungsgrenzen** begrenzen die spätere Farbnachführung.
- **Einrichtbereich** begrenzt die Richtungstasten beim Ermitteln der Endpunkte.
Er lässt sich bei gestoppten Servos schrittweise erweitern und speichern.
Standard: Nachführung 1400–1600 µs, Einrichtung 1100–1900 µs. Die technische
Eingabehülle 500–2500 µs ist kein bestätigter mechanischer Fahrweg. Beim bewussten Start fährt jede Achse
in die Mitte ihrer gespeicherten Bewegungsgrenzen: (Minimum + Maximum) / 2. Bestehende Einstellungen bleiben beim Update
erhalten; eine Kamerasitzung startet immer bewusst.
## Lokal ohne Hardware starten
```bash
@@ -29,12 +71,13 @@ manuelle Start für direkten Heimnetzzugriff steht in [SOFTWARE.md](SOFTWARE.md)
## Dokumentation
- [Verdrahtung von Kamera und Schwenkarm](VERDRAHTUNG.md) · [Anschlusszeichnung](VERDRAHTUNG.svg)
- [Verdrahtung von Kamera und Schwenkarm](VERDRAHTUNG.md) · [Anschlusszeichnung](VERDRAHTUNG.svg) · [Große Pi-Pinübersicht](RASPI_PINS.svg)
- [Software, Pi-Einrichtung und lokale Prüfungen](SOFTWARE.md)
- [Projektdateien auf den Pi übertragen](UPLOAD.md)
- [Arbeitsplan, Hardwarestand und offene Entscheidungen](PLAN.md)
- [Blender-Konzeptvorschau](preview/README.md)
- [Projektregeln](AGENTS.md)
- Modulanweisungen: [Software](halloween/instructions.md), [Tests](tests/instructions.md), [Werkzeuge](tools/instructions.md), [Vorschau](preview/instructions.md)
Code, Planung und bearbeitbare Blender-Szenen werden versioniert.
Laufzeitdaten unter `data/`, `.venv`, Referenzclips und gerenderte
+190 -33
View File
@@ -27,8 +27,9 @@ python3 -m venv --system-site-packages .venv
Picamera2 wird über die Raspberry-Pi-Pakete installiert; die virtuelle
Umgebung kann dieses Systempaket durch `--system-site-packages` verwenden.
Der Server bindet nur an `127.0.0.1` auf dem Pi. So ist das Livebild nicht
direkt im WLAN sichtbar.
Beim Start über `run.py` bindet der Server nur an `127.0.0.1` auf dem Pi.
Der versionierte Dienst nutzt dagegen die beim Upload gewählte Bind-Adresse
(siehe unten); mit `0.0.0.0` ist die WebUI direkt im Heimnetz erreichbar.
### WebUI beim Hochfahren starten
@@ -238,7 +239,7 @@ ein manueller Testbutton umgeht keine Fehlersperre.
Kamera-/Modellfehler und mehr als zwei Sekunden ohne gültige Auswertung
sperren die aktivierte Automatik und stoppen den simulierten Ablauf. Nach
Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich
Beheben des Fehlers „Grab zurücksetzen“ verwenden und Bereich
freihalten. Modellprobleme bei deaktivierter Automatik verhindern keine
manuellen Tests. Der Demo-Livefeed und manuelle Tests brauchen weder OpenCV
noch Modell; der Bewegungs-Test benötigt auch im Demo-Modus OpenCV. Synthetische
@@ -303,7 +304,7 @@ Sperrzeit und erneut mindestens zwei Sekunden ruhiges Bild. Ein ruhiges Bild
beweist **keinen menschenfreien Bereich**. Stopp, Fehler-Watchdog, bewusstes
Rücksetzen und erneute Freigabe nach Start/Konfigurationswechsel bleiben wirksam.
Bei bereits gesetzter Fehlersperre nach Auswahl und Speichern bewusst
„Nach Stopp zurücksetzen“ drücken.
„Grab zurücksetzen“ drücken.
Dies ist ein **Bewegungs-Test für die Simulation**, keine Personenerkennung
für den späteren Gartenbetrieb. Auch Kamera-/Hintergrundbewegung kann auslösen.
@@ -343,8 +344,8 @@ die Linien-/API-/Installer-Tests benötigen weder Modell noch OpenCV.
## Kamera folgt einem Farbpunkt
In der WebUI gibt es einen eigenen Abschnitt **Kamera folgt einem Farbpunkt ·
Testmodus**. Er verfolgt einen deutlich gefärbten Aufkleber oder Punkt, auch
Auf der Seite **Kamera / Finger** (`/kamera`) gibt es den Abschnitt
**Kamera folgt einem Farbpunkt · Testmodus**. Er verfolgt einen deutlich gefärbten Aufkleber oder Punkt, auch
am Finger. Eine nackte Hand beziehungsweise ein Finger wird nicht als solcher
erkannt. Die Farbe wird lokal mit Pillow ausgewertet, ohne Modelltraining,
OpenCV oder Internet. NoIR-Farben, IR-Licht und wechselnder Weißabgleich können
@@ -372,8 +373,10 @@ Vor Updates/Neustarts die Servoversorgung abziehen.
### Farbe auswählen und ohne Bewegung prüfen
1. **Automatisch auslösen** ausschalten und **Linie und Bereich speichern**
drücken. Farbtest und automatische Auslösung schließen sich gegenseitig aus.
1. Oben **Kamera / Finger** öffnen. Das Livebild erscheint sofort. Für eine
Farbvorschau muss die Grabautomatik nicht ausgeschaltet werden. Nur vor
echter Servobewegung auf **Grab** die automatische Auslösung ausschalten
und **Linie und Bereich speichern** drücken.
2. **Grün wählen** oder **Rot wählen** drücken. Alternativ **Beispielfoto
aufnehmen** und im eingefrorenen Foto auf die Mitte des Farbpunkts tippen.
Ein ausreichend heller und gesättigter Farbton ist nötig. Das Foto und die
@@ -388,7 +391,12 @@ Vor Updates/Neustarts die Servoversorgung abziehen.
### Erste echte Bewegung
Der Start der echten Nachführung setzt beide Servos sofort auf **1500 µs**.
Der bewusste Start der echten Nachführung oder des Einrichtmodus setzt jede
Achse auf **(gespeichertes Minimum + gespeichertes Maximum) / 2** ihrer
Bewegungsgrenzen. Halbe Mikrosekunden werden aufgerundet. Beispiel: PAN
1000–1800 → 1400 µs, TILT 1300–1800 → 1550 µs. Die WebUI zeigt die Werte unter
**Beim nächsten Start**. Ohne angepasste Grenzen ergibt sich weiterhin 1500 µs.
Beim Hochfahren des Pi wird keine Kamerasitzung gestartet.
Das kann aus einer unbekannten bisherigen Stellung eine größere Bewegung
sein. Für die erste Zentrierung Servohörner/Gestänge lösen, lose Teile sichern
und freie Mittelstellung prüfen. Danach spannungsfrei montieren. Die anfänglichen
@@ -397,25 +405,96 @@ Halterung bestätigten Winkel. Das Kamerakabel darf nicht unter Zug geraten.
Nach Prüfung den Hinweis zum freien Bewegungsraum bestätigen und **Echte
Kameranachführung starten** drücken. PAN- und TILT-Pulsbreiten werden angezeigt.
Bewegt sich eine Achse vom Punkt weg, stoppen, unter **Servogrenzen und Richtung
Bewegt sich eine Achse vom Punkt weg, stoppen, unter **Farbe, Drehrichtung, Tempo und Suche
einstellen** die betreffende Richtung umkehren und speichern. Grenzen pro Achse
schrittweise ermitteln; absolut erlaubt sind 1100–1900 µs mit 1500 innerhalb
des Bereichs. Gleiche Minimum-/Maximumwerte von 1500 sperren eine Achse auf die
Mittelstellung. Diese Werte sind Pulsbreiten, keine garantierten Winkel.
schrittweise ermitteln; die Nachführgrenzen müssen im gespeicherten
Einrichtbereich liegen; Minimum darf nicht größer als Maximum sein. Gleiche
Minimum-/Maximumwerte sperren eine Achse bei der Nachführung auf diesen Wert. Diese Werte sind Pulsbreiten, keine garantierten Winkel.
Die Regelung hat eine Totzone um die Bildmitte. **Nachführtempo** stellt die
maximale Pulsänderung zwischen 40 und 800 µs/s ein; nahe der Mitte wird langsamer
nachgeführt. Zum ersten schnelleren Test: Kameraservos stoppen, **400** eintragen,
**Farbe, Richtung, Tempo und Suche speichern**, anschließend bewusst neu starten.
400 ist der Standard für neue Konfigurationen. Alte Einstellungen behalten beim
Update zunächst 80; Endpunkte bleiben erhalten. Die Regelverstärkung ist auf 600 µs/s je Bildbreite/-höhe begrenzt (Bildverzögerung
lässt höhere Werte pendeln); das Tempo wirkt als Obergrenze, mehr als ca. 300 µs/s
wird dadurch bei Zielen nahe der Bildmitte/-rand nicht mehr erreicht. Bei Pendeln Tempo reduzieren.
Einrichtung und Mittelstellungsbutton bleiben auf 40 µs/s begrenzt. Der bewusste
Sitzungsstart gibt hingegen unmittelbar die gespeicherte Mitte als Sollwert aus;
dessen reale Servogeschwindigkeit wurde nicht gemessen.
Die Pi-Kamera zielt jetzt auf **15 Bilder/s** bei 640×480, die synthetische Demo
bleibt bei etwa 5. Die Konfiguration nutzt Picamera2-`FrameDurationLimits`
([Herstellerhandbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf)).
Über dem Bild stehen gemessene Kamerabilder/s, Nachführ-Auswertungen/s und die
Dauer der Farberkennung. Das sind Softwaremessungen, keine CPU-Auslastung oder
Bestätigung der realen mechanischen Geschwindigkeit. Die Vorschau fragt etwa
alle 100 ms plus Antwort-/Darstellungszeit ab; die Regelung läuft unabhängig davon.
Bei verzögerten Bildern werden maximal 100 ms Bewegung nachgeholt. Ob der Pi
15 Bilder/s unter jeder Last erreicht, bleibt zu prüfen. Am 16. Oktober meldet
seine Status-API 15,0 Bilder/s bei inaktiver Nachführung.
### Übersichtliche Bedienung
Die Seiten nutzen bis zu 1800 Pixel Breite. Ab 1000 Pixel Bildschirmbreite
stehen Kamerabild und Bedienung nebeneinander; darunter untereinander.
Auf der Kameraseite sind Status, Vorschau, echte Nachführung und Kamerastopp
rechts direkt sichtbar. **Farbe, Drehrichtung, Tempo und Suche einstellen**
öffnet die Detailparameter. **Endpunkte & manuelle Einrichtung** unter den
beiden Hauptbereichen öffnet Grenzwerte, Richtungstasten und Mittelstellung.
Am Desktop bleibt **Alles stoppen** im Seitenkopf beim Scrollen erreichbar.
Auf der Grabseite sind **Automatische Auslösung einstellen**, **Gespeicherte
Ereignisse** und **Personenprofile** aufklappbar. Die Auswertungs- und
Betriebsstatus bleiben sichtbar. Auf-/Zuklappen speichert keine Einstellungen
und startet oder stoppt keine Sitzung.
### Farbpunkt nach Verlust suchen
Unter **Farbe, Drehrichtung, Tempo und Suche einstellen** die Option
**Bei Punktverlust automatisch suchen** aktivieren und **Farbe, Richtung,
Tempo und Suche speichern** drücken. Danach den Punkt vor die Kamera halten
und **Echte Kameranachführung starten**. Vorschau bewegt keine Servos.
Die gewählte Farbe bleibt in `data/follow.json` gespeichert, bis du bewusst
Rot, Grün oder einen neuen Punkt im Beispielfoto wählst; auch ein Neustart
löscht sie nicht. Gespeichert wird die Farbe, keine eindeutige Objektidentität.
Die Suchoption ist zunächst aus, bleibt nach dem Speichern erhalten und
verändert keine Endpunkte. Auch mit gespeicherter Option startet die Sitzung nie selbst.
Nach erster eindeutiger Erkennung hält die Kamera bei Punktverlust kurz still.
Nach 1,5 Sekunden beginnt sie in der zuletzt gesehenen Richtung zu suchen,
unter Berücksichtigung der Achsumkehr. Sie schwenkt einmal auf der aktuellen
Höhe und an den beiden gespeicherten TILT-Grenzen hin und her. Nur gespeicherte
**Bewegungsgrenzen** gelten, niemals der größere Einrichtbereich. Suchtempo:
höchstens 200 µs/s beziehungsweise das kleinere eingestellte Nachführtempo.
Die tatsächliche Sichtfeldabdeckung hängt von Optik und Mechanik ab; der
Suchweg garantiert keine lückenlose Erfassung des gesamten Bereichs.
Bei einem wieder sichtbaren Punkt hält die Suche sofort an. Zwei aufeinander-
folgende eindeutige Erkennungen übernehmen wieder die normale Nachführung.
Ohne Fund endet nur die Suchfahrt nach einem Durchlauf oder spätestens
60 Sekunden. Die Kamera wartet danach unbegrenzt auf die gespeicherte Farbe,
solange Kamerabild und Bedienverbindung funktionieren. Die Servos halten ihre
letzte Position; es werden keine weiteren Suchrunden gestartet. Sobald der
Punkt in zwei aufeinanderfolgenden Bildern wieder eindeutig erkannt wird,
folgt die Kamera weiter. Gesperrte Achsen bleiben unverändert.
**Kameraservos stoppen**, **Alles stoppen** und die bisherigen Bild-/Verbindungs-
und Auswertungswatchdogs brechen auch die Suche ab. Ohne aktivierte Suche oder
wenn seit Sitzungsstart noch kein Punkt erkannt wurde, wartet die Kamera
an ihrer Position ebenfalls weiter. Im manuellen Einrichtmodus
findet keine Farbpunktsuche statt.
Die Regelung hat eine Totzone um die Bildmitte und ändert die Pulse um maximal
80 µs/s. Bei fehlendem oder uneindeutigem Ziel wird nicht weiter nachgeführt;
nach einer Sekunde wird die Hardware-Sitzung beendet. Es gibt keine Suchfahrt.
Sehr schnelle Bewegung oder ein enger eingestellter Bereich kann dazu führen,
dass der Punkt aus dem Bild läuft. Der Modus ersetzt keine zuverlässige
Personen- oder Fingererkennung.
### Stopp und Fehler
**Kameraservos stoppen**, der allgemeine **Stopp** und **Nach Stopp
zurücksetzen** beenden die Nachführung und setzen CH0/CH1 auf PWM aus.
Der Grabablauf bleibt nach dem Farbtest bis zum bewussten Zurücksetzen
angehalten. Schließen/Neuladen des Browserfensters oder ein Wechsel in den
**Kameraservos stoppen** und **Alles stoppen** beenden die Nachführung und
setzen CH0/CH1 auf PWM aus. **Grab stoppen** und **Grab zurücksetzen** wirken
nur auf den Grabablauf. Die Kamera verändert dessen Modus und Stoppzustand
nicht. Die Farbvorschau darf parallel zur Grabautomatik laufen; nur echte
Kamerabewegung bleibt bei eingeschalteter Grabautomatik gesperrt. Schließen/Neuladen des Browserfensters oder ein Wechsel in den
Hintergrund beendet die Sitzung über Stopp beziehungsweise den Heartbeat:
nach 1,5 Sekunden ohne die startende Bedienseite wird abgeschaltet.
Die Nachführung benötigt neue Kamerabilder und neue Auswertungen innerhalb
@@ -448,9 +527,9 @@ Technische Referenzen: [Pillow HSV](https://pillow.readthedocs.io/en/stable/hand
## Endpunkte der Kamerahalterung manuell einrichten
Im Farbpunkt-Abschnitt **Endpunkte einrichten · manuell** öffnen. Die
Servomittelstellung muss wie oben beschrieben vorab geprüft sein. Die
Automatik bleibt ausgeschaltet. Bewegungsraum bestätigen und **Einrichtmodus
starten** drücken. Der Start setzt wieder beide Achsen auf 1500 µs. Im Demo-
Servomittelstellung muss wie oben beschrieben vorab geprüft sein. Für echte Bewegung muss die
Grabautomatik ausgeschaltet sein. Bewegungsraum bestätigen und **Einrichtmodus
starten** drücken. Der Start fährt jede Achse in die Mitte ihrer gespeicherten Bewegungsgrenzen. Im Demo-
Betrieb heißt der Knopf **Einrichtmodus als Demo starten** und bewegt nur
virtuelle Werte; es gibt keinen I²C-Zugriff.
@@ -462,17 +541,31 @@ virtuelle Werte; es gibt keinen I²C-Zugriff.
Tastensignal endet die Verstellung nach 0,35 s; bei Verbindungsverlust gilt
zusätzlich die bisherige Abschaltung der ganzen Sitzung. Die Position wird
nach dem Loslassen weiter per PWM gehalten, bis **Kameraservos stoppen**
beziehungsweise der allgemeine Stopp die Ausgabe beendet.
- **Links-Grenze speichern**, **Rechts-Grenze speichern**, **Oben-Grenze
speichern** oder **Unten-Grenze speichern** übernimmt den zuletzt
angeforderten Wert mit 10 µs Abstand nach innen. Die gespeicherten Grenzen
sind unter den Tasten dauerhaft sichtbar und gelten für die Farbnachführung.
beziehungsweise **Alles stoppen** die Ausgabe beendet.
- **In Mittelstellung fahren** ist im aktiven Einrichtmodus verfügbar. Einmal
klicken: beide Achsen fahren mit maximal 40 µs/s zur Mitte ihrer aktuell
gespeicherten Bewegungsgrenzen. Die Endpunkte bleiben unverändert. Kein
Gedrückthalten nötig; Kamera und Bedienverbindung müssen verfügbar bleiben.
**Bewegung anhalten** bricht die Fahrt ab und hält die erreichte Sollposition.
Eine Richtungstaste übernimmt ebenfalls die Bedienung. Kamerastopp beendet PWM.
Während der Fahrt sind Endpunktspeicherknöpfe gesperrt. Zum Zentrieren aus der
Farbnachführung zuerst Kameraservos stoppen und Einrichtmodus starten.
- Unter den Richtungstasten zeigt jede der vier Karten **Links**, **Rechts**,
**Oben** und **Unten** den tatsächlich gespeicherten Wert. **Diese Position
als … speichern** übernimmt den aktuellen Sollwert dieser Achse mit 10 µs
Abstand nach innen. Der vorgesehene neue Wert steht schon vor dem Speichern
dabei; die Bestätigung nennt danach Richtung und gespeicherten Wert.
- Ein Speicherknopf ist erst freigegeben, wenn die Achse ausreichend in die
zugehörige Richtung von der Mittelstellung gefahren ist. Bei falscher Seite
steht direkt an der Karte, was zu tun ist. Fehler beim Speichern erscheinen
im Einrichtbereich. Die Bestätigung bleibt sichtbar, bis eine neue Aktion
sie ersetzt. Die Grenzen gelten für die Farbnachführung.
Richtungsumkehr wird bei Bewegung und Speicherung gleich berücksichtigt.
- Der Einrichtmodus darf die bisher gespeicherten Farbtest-Grenzen bewusst
überschreiten; die absoluten Softwaregrenzen bleiben **1100–1900 µs**.
Ist damit nicht genug Weg verfügbar, stoppen und Mechanik/Servodaten klären,
nicht die Halterung gewaltsam verstellen. Die Grenzen müssen weiterhin die
1500-µs-Startposition einschließen.
überschreiten; er hält sich an den **Einrichtbereich** pro Achse. Dieser
beginnt bei 1100–1900 µs und kann gestoppt über die WebUI geändert werden.
Nur schrittweise erweitern, vor dem mechanischen Anschlag loslassen. Die
Nachführgrenzen müssen innerhalb des Einrichtbereichs liegen.
Es gibt keine automatische Anschlagfahrt und keine Endschalterauswertung.
Angezeigte Werte sind **Sollwerte**, keine gemessene Position. Ohne Encoder,
@@ -482,7 +575,71 @@ Sicherheitsgarantie. Die Einrichtung braucht keinen Farbpunkt; frische Kamera,
Sitzungs-Heartbeat und Abschaltregeln bleiben erforderlich.
API-Ergänzung: `start` nimmt `operation=setup` an; `jog` benötigt den privaten
Sitzungsschlüssel, eine Richtung und eine steigende Sequenznummer. Verspätete
Sitzungsschlüssel, eine Richtung und eine steigende Sequenznummer. `center`
beauftragt die Mittelstellungsfahrt nur in einer bestehenden Einrichtsitzung
und verwendet dieselbe Sequenzfolge wie `jog`. Verspätete
Tastenbefehle dürfen einen neueren Loslassbefehl nicht überholen. `remember`
speichert eine Grenze erst nach beendeter Bewegung. Einstellungen und
Verdrahtung sind weiterhin im selben Plan dokumentiert.
## Getrennte Seiten für Grab und Kamera
- **Grab** unter `/` oder `/grab`: Livebild mit Erkennungsbereich, Betriebsmodus,
Auslösung, Ereignisse und Profile. Die Knöpfe **Grab stoppen** und **Grab
zurücksetzen** beeinflussen nur diesen Bereich.
- **Kamera / Finger** unter `/kamera`: eigenes Livebild, Farbwahl,
Nachführung und manuelle Endpunkte. Farbvorschau und Einrichtung haben
getrennte Startknöpfe; beides gehört zur Kamerasitzung.
- Oben auf beiden Seiten: Navigation und **Alles stoppen** für Grab und
Kameraservo-PWM gemeinsam. Die Stromversorgung wird damit nicht getrennt.
Kamera und Grab speichern ihre bisherigen Einstellungen weiterhin getrennt.
Die Seiten laden nur ihre eigenen Abfragen und Bedienelemente. Beide verwenden
physisch dieselbe Kamera: Farbvorschau parallel ist möglich, echte Bewegung
bei aktivierter Grabautomatik wird abgelehnt, ohne den Grabzustand umzustellen.
Nach einer Kamerabewegung vor erneutem Aktivieren der Automatik Bildausschnitt,
Linie und Bereich prüfen.
Beim Verlassen der Kameraseite endet die Kamerasitzung über Stopp/Heartbeat,
spätestens nach Ausbleiben der Bedienverbindung gemäß den bisherigen Regeln.
Navigation startet keine Servos und ändert keinen gespeicherten Grabmodus.
Die alten API-Aufrufe `/api/stop` und `/api/reset` bleiben als globale
Abschaltung für ältere Clients bestehen; neue Grabknöpfe verwenden
`/api/grave/stop` beziehungsweise `/api/grave/reset`.
## Eindeutige Speicherung der Endpositionen
Zusätzlich zu den vier Endpunktkarten gibt es auf **Kamera / Finger** den
sichtbaren Bereich **Bewegungsgrenzen manuell eingeben** mit PAN Minimum,
PAN Maximum, TILT Minimum und TILT Maximum. Nur bei gestoppter Kamerasitzung
bearbeiten, dann **Bewegungs- und Einrichtgrenzen speichern** drücken. Darunter stehen
immer die tatsächlichen Serverwerte; geänderte Eingaben werden ausdrücklich
als ungespeichert markiert und bleiben beim Aktualisieren der Anzeige erhalten.
**Eingaben verwerfen** stellt die gespeicherten Werte wieder her.
Die manuelle Grenzspeicherung verändert Farbe/Drehrichtung nicht. Deren
separater Speichern-Knopf verändert umgekehrt keine Grenzen. Ein Start speichert
nichts; offene Grenzänderungen müssen zuerst gespeichert oder verworfen werden.
Die Endpunktkarten und ihre Speicherung im aktiven Einrichtmodus bleiben
zusätzlich erhalten. Manuelle Zahlenfelder sind während einer Sitzung gesperrt.
Unter **Einrichtbereich zum Anfahren der Endpunkte** stehen zusätzlich PAN/TILT
Einrichtung Minimum und Maximum. Diese Werte begrenzen die Richtungstasten,
auch wenn die bisherigen Nachführgrenzen enger sind. Bei zu wenig Weg: stoppen,
den betreffenden Einrichtwert in kleinen Schritten erweitern, speichern und
Einrichtmodus erneut starten. Der Start berechnet die Mitte aus den gespeicherten Endpunkten neu. Dann die reale
gewünschte Endposition langsam anfahren, loslassen und in der Richtungskarte
speichern. Das übernimmt den Sollwert mit 10 µs Innenabstand für die Nachführung.
Technische Eingabehülle: **500–2500 µs**, kein bestätigter mechanischer Bereich
für diese Servos/Halterung. Die Nachführgrenzen müssen im Einrichtbereich liegen;
bei einer direkten Zahleneingabe gegebenenfalls beide zusammen ändern. Speichern
löst keine Bewegung aus. Alte Konfigurationen behalten alle Endpunkte und den
bisherigen Einrichtbereich 1100–1900 µs; keine automatische Erweiterung.
Die Startmitte einer Einrichtsitzung bleibt während dieser Sitzung der Bezug
für die Links-/Rechts-/Oben-/Unten-Speicherknöpfe, auch nach dem Speichern eines
Endpunkts. Speichern löst keine Zentrierfahrt aus. Erst beim nächsten bewussten
Start wird die Mitte aus den dann gespeicherten Endpunkten neu berechnet.
+17 -2
View File
@@ -4,7 +4,7 @@
bei jedem angewendeten Update den systemd-Autostart der WebUI ein. Ohne
`--apply` zeigt es nur eine lokale Vorschau und verbindet sich nicht zum Pi.
## Auf dem Laptop ausführen
## Auf dem Laptop im Projektordner ausführen
Das folgende Beispiel verwendet den vom Nutzer bestätigten SSH-Benutzer,
Hostnamen und Projektordner. Der absolute Home-Pfad muss auf dem Pi stimmen:
@@ -102,13 +102,28 @@ der Rückfall gelungen ist. Ein unvollständiger Rückfall wird ausdrücklich ge
Abrupter Stromverlust/SIGKILL während des Updates ist nicht automatisch abgedeckt.
`data/` wird niemals vom Laptop übertragen: Datenbank, Bilder, Modelle,
Modus und Erkennungseinstellungen bleiben auf dem Pi. `.git`, `.venv`,
Modus und Erkennungseinstellungen sowie `follow.json` mit Farbe, Richtungen,
Nachführ- und Einrichtgrenzen bleiben auf dem Pi. `.git`, `.venv`,
Vorschauen, Referenzen, `.env`, Schlüssel, Versionsordner und Sicherungen werden
ebenfalls ausgeschlossen. Eigene Dateien außerhalb von `data/` bleiben im
Basisordner, werden aber nicht automatisch in die neue Version übernommen.
Das Skript löscht keine alten Pi-Dateien. Systempakete werden nicht geändert.
Nach Start gelten die bisherigen Freigabe-/Resetbedingungen der Effektsteuerung.
## Nach einem WebUI-Update
Vor dem Update die Kameraservos stoppen und ihre separate Versorgung abziehen.
Nach erfolgreichem Upload die Browserseite mit **Strg+F5** neu laden. Auf der
Kameraseite `/kamera` stehen die Bewegungs- und Einrichtgrenzen unter dem Bild.
`halloween/camera.html` wird zusammen mit dem übrigen Projekt übertragen;
es ist kein zusätzlicher Einzeldatei-Upload nötig.
Beim Laden alter `follow.json`-Dateien bleiben die Nachführgrenzen erhalten;
fehlende Einrichtgrenzen werden mit dem bisherigen Bereich 1100–1900 µs ergänzt.
Ein Update erweitert keinen Bewegungsbereich und startet keine Kamerasitzung.
Vor einem neuen Bewegungstest die gespeicherten Werte im WebUI prüfen.
## Auf dem Pi prüfen
```bash
+50 -6
View File
@@ -1,6 +1,6 @@
# Verdrahtung von Kamera und Schwenkarm
Stand: 15. Oktober 2026 · Revision 6 · Anschlussentwurf
Stand: 15. Oktober 2026 · Revision 14 · Anschlussentwurf
Dieser Plan dokumentiert Pi 5, Camera Module 3 NoIR und die beiden
Kameraservos am vorhandenen PCA9685. Er dient später auch dem Wiederaufbau.
@@ -41,6 +41,31 @@ ist mit abgewinkelten Stiften bestückt. Immer nach dem Aufdruck anschließen.
Die Polarität der grünen Schraubklemme ist in dieser Aufnahme nicht eindeutig
ablesbar; vor Versorgung am Board prüfen.
## Foto der gesteckten Anschlüsse
![PCA9685 mit Servo- und Steuersteckern](PCA9685_VERKABELT_FOTO.jpg)
Nutzerfoto vom 15. Oktober 2026, laut Nutzer ohne angeschlossene
Servoversorgung. Die sichtbare Steckrichtung der beiden Servostecker am
Kondensator-Ende passt zu Signal auf Gelb, Plus auf Rot und Minus auf Schwarz.
Die Logikstecker erscheinen entsprechend VCC/SDA/SCL/GND angeordnet, OE und
V+ bleiben frei. Die Nummern der Servoanschlüsse und die Zuordnung der Kabel
zum unteren/oberen Servo sind am Board beziehungsweise durch Nachverfolgen
zu bestätigen. Pi-Enden der Steuerleitungen sind auf diesem Modul-Foto nicht vollständig
sichtbar; das folgende Pi-Foto ergänzt sie. Dies ist eine Sichtprüfung, keine elektrische Durchgangs- oder
Funktionsprüfung. Schraubklemmenpolung und Versorgung bleiben zu prüfen.
## Foto der gesteckten Pi-Pins
![Pi 5 mit den vier Steuerleitungen](RASPI_VERKABELT_FOTO.jpg)
Nutzerfoto vom 15. Oktober 2026. Soweit auf dem Foto erkennbar: W01 Grün an
Pin 1, W02 Blau an Pin 3 und W03 (auf dem Foto violett wirkend, im Plan Braun)
an Pin 5 in der inneren Reihe; W04 Grau gegenüber Pin 5 an Pin 6 in der äußeren
Reihe. Die sichtbare Anordnung passt zum Plan. Pins 2 und 4 erscheinen frei.
Dies ergänzt die Sichtprüfung; elektrische Durchgängigkeit, Versorgungspolung,
I²C-Kommunikation und echte Servofunktion sind weiterhin nicht gemessen.
## Pi mit dem PCA9685 verbinden
![Große Pi-Pinübersicht mit Kabelfarben](RASPI_PINS.svg)
@@ -136,10 +161,10 @@ Die neue Software bietet einen eigenen Farbpunkt-Test für S1/S2, siehe
[SOFTWARE.md](SOFTWARE.md#kamera-folgt-einem-farbpunkt). Nur der bewusst
aktivierte Hardwaremodus öffnet I²C-Bus 1 / Adresse 0x40 und steuert CH0/CH1.
Die anderen Kanäle für diesen Test unbestückt lassen: die PWM-Frequenz ist
für die ganze Platine gemeinsam. Die erste Aktivierung setzt beide Servos
auf 1500 µs; vorab ohne angekoppeltes Gestänge zentrieren und montieren.
für die ganze Platine gemeinsam. Die bewusste Aktivierung setzt jede Achse
auf die Mitte ihrer gespeicherten Bewegungsgrenzen; vorab ohne angekoppeltes Gestänge zentrieren und montieren.
Der WebUI-Stopp und der eigene Kamerastopp beenden nun die Softwareausgabe
„Alles stoppen“ und „Kameraservos stoppen“ beenden die Softwareausgabe
auf CH0/CH1. Die Servoversorgung bleibt an. Bei verlorenem Ziel, Kamerabild,
Auswertung oder Bedien-Heartbeat wird die Sitzung beendet; sie startet nicht
selbstständig neu. Diese Regeln sind lokal mit künstlichem I²C-Bus geprüft,
@@ -156,10 +181,21 @@ D1 übertragen. Nur beaufsichtigter Test.
Die WebUI bietet jetzt einen manuellen Einrichtmodus für S1/S2. Langsam mit
gehaltener Richtungstaste annähern, vor dem Anschlag loslassen, Grenze speichern.
„In Mittelstellung fahren“ bringt beide Achsen im Einrichtmodus langsam
zur Mitte der gespeicherten Grenzen; „Bewegung anhalten“ bricht ab.
Auch PAN nicht gegen den Anschlag drücken. Loslassen hält die Sollposition,
der Kamerastopp beendet PWM. Es gibt keine Erkennung mechanischer Endlagen;
der Kamerastopp beendet PWM. Grab-Stopp wirkt nur auf den Grabablauf. Es gibt keine Erkennung mechanischer Endlagen;
Sollwerte sind keine gemessenen Winkel. Die gespeicherten Grenzen stehen in
`data/follow.json`; die tatsächlichen Werte nach dem Pi-Test auch im obigen
`data/follow.json`. Auf **Kamera / Finger → Bewegungsgrenzen manuell eingeben**
begrenzen die oberen Min-/Max-Felder die Nachführung. Die Felder unter
**Einrichtbereich zum Anfahren der Endpunkte** begrenzen die Richtungstasten.
Beide nur gestoppt ändern und mit **Bewegungs- und Einrichtgrenzen speichern**
übernehmen. Der Einrichtbereich beginnt bei 1100–1900 µs und ist schrittweise
innerhalb 500–2500 µs einstellbar; das bestätigt keinen mechanischen Fahrweg.
Die Startstellung ergibt sich aus den gespeicherten Bewegungsgrenzen. Endpunktkarten speichern
10 µs nach innen. Ausführliche Schritte stehen in
[SOFTWARE.md](SOFTWARE.md#endpunkte-der-kamerahalterung-manuell-einrichten).
Die tatsächlichen Werte nach dem Pi-Test auch im folgenden
Aufbauprotokoll festhalten. Keine zusätzlichen GPIOs/Endschalter angeschlossen.
## Noch nicht anschlussfertige Erweiterungen
@@ -212,3 +248,11 @@ zusätzlich durch das oben abgelegte Nutzerfoto dokumentiert.
| 15.10.2026 | 4 | Große Pinübersicht RASPI_PINS.svg mit Boardausrichtung und Nutzerkabelfarben ergänzt |
| 15.10.2026 | 5 | Nutzer meldet verdrahteten Aufbau mit Powerbank; Farbpunkt-Softwaretest mit bewusstem Hardwarestart und PWM-Stopp dokumentiert; echte Hardwareprüfung offen |
| 15.10.2026 | 6 | Manuellen Endpunktmodus ohne zusätzliche Verdrahtung dokumentiert; echte mechanische Grenzen weiter offen |
| 15.10.2026 | 7 | Foto des gesteckten Moduls ohne Servoversorgung abgelegt; sichtbare Steckrichtung plausibel, Pi-Enden/Versorgung/Funktion offen |
| 15.10.2026 | 8 | Pi-Verkabelungsfoto ergänzt; sichtbare Anordnung passt zu Pins 1/3/5/6, elektrische und funktionale Prüfung offen |
| 15.10.2026 | 9 | Getrennte Grab-/Kameraseiten dokumentiert; eigene Stoppknöpfe und explizite gemeinsame Abschaltung, Verdrahtung unverändert |
| 15.10.2026 | 10 | Endpunktkarten statt editierbarer Pulsgrenzen; Speicherkandidat und Bestätigung direkt an der Einrichtung, Verdrahtung unverändert |
| 15.10.2026 | 11 | Manuelle Min-/Max-Eingabe zusätzlich zu Endpunktkarten, eigenes Speichern/Verwerfen nur gestoppt; Verdrahtung unverändert |
| 15.10.2026 | 12 | Einrichtbereich pro Achse im WebUI einstellbar, Standard weiter 1100–1900 µs; technische Eingabehülle 500–2500 ist kein bestätigter mechanischer Weg. Verdrahtung unverändert |
| 15.10.2026 | 13 | Bewusster Start nutzt die Mitte der gespeicherten Bewegungsgrenzen je Achse; Dienststart bleibt inaktiv, Verdrahtung unverändert |
| 15.10.2026 | 14 | Mittelstellungsbutton im Einrichtmodus mit langsamer, abbrechbarer Fahrt; Verdrahtung unverändert |
+1 -1
View File
@@ -2,7 +2,7 @@
<rect width="1400" height="1000" fill="#f5f7fa"/>
<style>text{font-family:DejaVu Sans,Arial,sans-serif;fill:#172536;font-size:18px}.title{font-size:30px;font-weight:bold}.head{font-size:22px;font-weight:bold}.small{font-size:16px}.wire{fill:none;stroke-width:4;stroke-linejoin:round}</style>
<text x="40" y="48" class="title">Kamera und Schwenkarm · Anschlussentwurf</text>
<text x="40" y="82" class="">Revision 6 · 15.10.2026 · Boardfoto dokumentiert; Servokabel und Netzteil noch prüfen</text>
<text x="40" y="82" class="">Revision 14 · 15.10.2026 · Boardfoto dokumentiert; Servokabel und Netzteil noch prüfen</text>
<rect x="40" y="140" width="330" height="350" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="60" y="176" class="head">R1 · Raspberry Pi 5</text>
<text x="60" y="212" class="">Pin 1 · 3V3</text>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+510
View File
@@ -0,0 +1,510 @@
<!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: #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; }
nav a[aria-current="page"] { background:#46695b; border:2px solid #c4e0cb; 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: #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; }
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: #050808; }
.follow-canvas { cursor: crosshair; }
#follow-status { padding: .75rem; background: #111b1a; border: 1px solid #759c8f; border-radius: 8px; color: #ffca78; font-weight: 600; }
.start-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
#follow-stop { grid-column: 1 / -1; }
#follow-hardware { background: #365b49; border-color: #91bda3; }
.settings-card { margin-top: 1rem; border-top: 1px solid #5c6c67; }
.settings-card > label { display: inline-flex; align-items: center; gap: .4rem; margin: .6rem .5rem .6rem 0; }
.settings-card .hint, #follow-settings-status { color: #bfcbc6; }
.help { margin-top: .6rem; }
.help summary { color: #c5d3cc; }
.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 #759c8f; 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; }
}
</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 #759c8f;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 #759c8f;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 #759c8f;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 #759c8f;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>
+13 -2
View File
@@ -1,5 +1,6 @@
"""Ein Kameraproduzent für mehrere Livebild-Betrachter."""
from collections import deque
from io import BytesIO
import threading
import time
@@ -9,6 +10,7 @@ from PIL import Image, ImageDraw
class CameraStream:
TARGET_FPS = 15
def __init__(self, demo: bool = False):
self.demo = demo
self._condition = threading.Condition()
@@ -16,6 +18,7 @@ class CameraStream:
self._stop = threading.Event()
self._frame = None
self._number = 0
self._frame_times = deque(maxlen=45)
self._time = None
self.error = None
@@ -43,6 +46,7 @@ class CameraStream:
self._frame = frame
self._number += 1
self._time = time.monotonic()
self._frame_times.append(self._time)
self._condition.notify_all()
def _run(self):
@@ -75,15 +79,17 @@ class CameraStream:
try:
# BGR888 liefert laut Picamera2-Dokumentation RGB-Arrays für PIL.
config = camera.create_video_configuration(
main={"size": (640, 480), "format": "BGR888"}
main={"size": (640, 480), "format": "BGR888"},
controls={"FrameDurationLimits": (66667, 66667)}, queue=False
)
camera.configure(config)
camera.start()
started = True
while not self._stop.is_set():
began = time.monotonic()
pixels = camera.capture_array("main")
self._publish(self._jpeg(Image.fromarray(pixels, "RGB")))
self._stop.wait(0.2)
self._stop.wait(max(0., 1/self.TARGET_FPS-(time.monotonic()-began)))
finally:
try:
if started:
@@ -114,7 +120,12 @@ class CameraStream:
def status(self):
with self._condition:
age = None if self._time is None else round(time.monotonic() - self._time, 1)
times = self._frame_times
fps = ((len(times)-1)/(times[-1]-times[0])
if len(times)>1 and times[-1]>times[0] and age is not None and age<1 else 0.)
return {
"fps": round(fps, 1),
"target_fps": 5 if self.demo else self.TARGET_FPS,
"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,
+186 -29
View File
@@ -6,18 +6,32 @@ import base64
import json
import math
import os
import re
import secrets
import tempfile
import threading
import time
from PIL import Image
from PIL import Image, ImageChops
DEFAULT = dict(hue=85, tolerance=18, pan_min=1400, pan_max=1600,
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False)
FOLLOW_GAIN = 600 # µs/s per unit of image offset (= old behaviour at 300 µs/s)
DEFAULT = dict(hue=85, tolerance=18, follow_speed=400, search_enabled=False, pan_min=1400, pan_max=1600,
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False,
pan_setup_min=1100, pan_setup_max=1900,
tilt_setup_min=1100, tilt_setup_max=1900)
def validate(value):
if not isinstance(value, dict):
raise ValueError('Ungültige Nachführungseinstellungen')
# Preserve the previous speed when loading existing configurations.
value = dict(value)
value.setdefault('follow_speed', 80)
value.setdefault('search_enabled', False)
# Old saved configurations retain their tested setup envelope.
setup_keys = {a+"_setup_"+b for a in ("pan", "tilt") for b in ("min", "max")}
if isinstance(value, dict) and set(value) == set(DEFAULT)-setup_keys:
value = {**{k: DEFAULT[k] for k in setup_keys}, **value}
if set(value) != set(DEFAULT):
raise ValueError('Unvollständige Nachführungseinstellungen')
result = {}
@@ -33,21 +47,48 @@ def validate(value):
result[key] = v
if not 0 <= result['hue'] <= 255 or not 5 <= result['tolerance'] <= 35:
raise ValueError('Ungültige Farbe oder Farbtoleranz')
if not 40 <= result['follow_speed'] <= 800:
raise ValueError('Nachführtempo muss zwischen 40 und 800 µs/s liegen')
for axis in ('pan', 'tilt'):
if not 1100 <= result[axis+'_min'] <= 1500 <= result[axis+'_max'] <= 1900:
raise ValueError('Grenzen müssen zwischen 1100 und 1900 µs liegen und 1500 einschließen')
if not 500 <= result[axis+'_setup_min'] <= result[axis+'_min'] <= result[axis+'_max'] <= result[axis+'_setup_max'] <= 2500:
raise ValueError('Einrichtbereich: 500–2500 µs; Minimum muss kleiner oder gleich Maximum sein; Bewegungsgrenzen müssen im Einrichtbereich liegen')
return result
def midpoints(config):
"""Stored follow endpoints determine each start, rounded to whole µs."""
return {axis: (config[axis+'_min'] + config[axis+'_max'] + 1)//2
for axis in ('pan', 'tilt')}
def locate(jpeg, config):
"""Largest unambiguous saturated blob, circular hue distance (also red)."""
image = Image.open(BytesIO(jpeg)).convert('RGB').resize((160, 120))
pixels = list(image.convert('HSV').getdata())
mask = set()
for i, (h, s, v) in enumerate(pixels):
delta = abs(h-config['hue'])
if min(delta, 256-delta) <= config['tolerance'] and s >= 100 and v >= 60:
mask.add(i)
"""Largest unambiguous saturated blob, circular hue distance (also red).
Strict hue/saturation masks run first so a strongly coloured marker wins
over similar but paler surroundings (e.g. bluish paper); weaker masks only
apply when the strict ones find no unambiguous blob.
"""
image = Image.open(BytesIO(jpeg))
image.draft('RGB', (160, 120)) # JPEG decodes directly at reduced size
image = image.convert('RGB')
if image.size != (160, 120):
image = image.resize((160, 120))
planes = image.convert('HSV').split()
tolerance = config['tolerance']
for factor, min_saturation in ((.5, 140), (.75, 120), (1, 100)):
result = _find_blob(planes, config['hue'], max(5, tolerance*factor), min_saturation)
if result:
return result
return None
def _find_blob(planes, centre, tolerance, min_saturation):
hue, saturation, value = planes
hue_table = [255 if min(abs(h-centre), 256-abs(h-centre)) <= tolerance else 0 for h in range(256)]
mask_image = ImageChops.darker(
ImageChops.darker(hue.point(hue_table), saturation.point([255 if i >= min_saturation else 0 for i in range(256)])),
value.point([255 if i >= 60 else 0 for i in range(256)]))
mask = {m.start() for m in re.finditer(b'\xff', mask_image.tobytes())}
blobs = []
while mask:
seed = mask.pop()
@@ -97,7 +138,7 @@ class PCA9685:
def move(self, pan, tilt):
for channel, pulse in enumerate((pan, tilt)):
if not 1100 <= pulse <= 1900:
if not math.isfinite(pulse) or not 500 <= pulse <= 2500:
raise ValueError('Puls außerhalb der absoluten Testgrenze')
count = round(pulse * 25 / 122) # microseconds -> oscillator ticks / prescale
self.bus.write_i2c_block_data(0x40, 0x06+4*channel,
@@ -140,10 +181,15 @@ class FollowService:
self.jog_direction = None
self.jog_until = 0.
self.jog_sequence = -1
self.center_target = None
self.driver = None
self.pan = self.tilt = 1500.
self.setup_origin = midpoints(self.config)
self.pan, self.tilt = self.setup_origin['pan'], self.setup_origin['tilt']
self.last_ping = self.clock()
self.last_result = self.last_seen = None
self.result_times = deque(maxlen=45)
self.analysis_ms = None
self._reset_search()
self.number = 0
self.target = None
self.jpeg = None
@@ -154,6 +200,52 @@ class FollowService:
self.thread = None
self.quit = threading.Event()
def _reset_search(self):
self.search_started = None
self.search_path = deque()
self.last_target = None
self.reacquire_count = 0
self.waiting_for_target = False
def _wait_for_target(self):
# Keep the selected colour and the armed session, but issue no motion.
# Existing pulses hold position; halt/watchdogs still release the driver.
self.search_started = None
self.search_path.clear()
self.reacquire_count = 0
self.waiting_for_target = True
def _search_step(self, now, dt):
"""Finite sweep of stored follow limits; never use the setup envelope."""
if self.search_started is None:
self.search_started = now
# First look towards the last image position, respecting reversal.
first = {}
other = {}
for axis, coordinate in (('pan', 'x'), ('tilt', 'y')):
sign = 1 if self.last_target[coordinate] >= .5 else -1
if self.config[axis+'_reverse']: sign *= -1
first[axis] = self.config[axis+('_max' if sign > 0 else '_min')]
other[axis] = self.config[axis+('_min' if sign > 0 else '_max')]
self.search_path = deque([
(first['pan'], self.tilt), (other['pan'], self.tilt),
(other['pan'], first['tilt']), (first['pan'], first['tilt']),
(first['pan'], other['tilt']), (other['pan'], other['tilt'])])
if now-self.search_started >= 60:
self._wait_for_target()
return
while self.search_path and (self.pan, self.tilt) == self.search_path[0]:
self.search_path.popleft()
if not self.search_path:
self._wait_for_target()
return
step = min(200, self.config['follow_speed'])*dt
for axis, goal in zip(('pan', 'tilt'), self.search_path[0]):
position = getattr(self, axis)
value = position+max(-step, min(step, goal-position))
setattr(self, axis, max(self.config[axis+'_min'], min(self.config[axis+'_max'], value)))
self.driver.move(self.pan, self.tilt)
def _save(self, value):
config = validate(value)
with self.lock:
@@ -197,21 +289,33 @@ class FollowService:
if sequence <= self.jog_sequence:
return {'accepted': False}
self.jog_sequence = sequence
self.center_target = None
self.jog_direction = direction
self.jog_until = self.clock()+.35 if direction else 0.
return {'accepted': True}
def center(self, session, sequence):
"""Explicit slow recentering within an already armed setup session."""
with self.lock:
accepted = self.jog(session, None, sequence)
if accepted['accepted']:
self.center_target = midpoints(self.config)
return accepted
def remember(self, session, direction):
with self.lock:
self._require_setup(session)
if self.center_target is not None:
raise ValueError('Mittelstellungsfahrt zuerst anhalten oder abwarten')
if self.jog_direction and self.clock() < self.jog_until:
raise ValueError('Richtungstaste vor dem Speichern loslassen')
axis, sign = self._direction(direction)
# Store a slightly inward commanded limit, never an inferred physical stop.
value = round(getattr(self, axis)) + (10 if sign < 0 else -10)
key = axis+('_min' if sign < 0 else '_max')
if sign < 0 and value > 1500 or sign > 0 and value < 1500:
raise ValueError('Position liegt zu nahe an oder auf der anderen Seite der Mitte')
origin = self.setup_origin[axis]
if sign < 0 and value > origin or sign > 0 and value < origin:
raise ValueError('Für diese Grenze zuerst in die zugehörige Richtung von der Startmitte dieser Sitzung weg fahren; mindestens 10 µs Abstand zur Mitte nötig')
return self._save(dict(self.config, **{key:value}))
def capture(self):
@@ -258,7 +362,8 @@ class FollowService:
raise ValueError('Kein frisches Kamerabild')
if hardware and (self.camera.demo or not acknowledged):
raise ValueError('Echte Kamera und bestätigter freier Servotest erforderlich')
self.pan = self.tilt = 1500.
self.setup_origin = midpoints(self.config)
self.pan, self.tilt = self.setup_origin['pan'], self.setup_origin['tilt']
try:
if hardware:
self.driver = self.factory()
@@ -269,9 +374,13 @@ class FollowService:
self.active = True
self.operation = operation
self.jog_direction, self.jog_until, self.jog_sequence = None, 0., -1
self.center_target = None
self.session = secrets.token_hex(16)
self.last_ping = self.clock()
self.last_result = self.last_seen = self.clock()
self._reset_search()
self.result_times.clear()
self.analysis_ms = None
self.jpeg = None
self.target = None
self.revision += 1
@@ -286,7 +395,9 @@ class FollowService:
def halt(self, error=None):
with self.lock:
self.active = False
self._reset_search()
self.session = None
self.center_target = None
self.jog_direction = None
self.jog_until = 0.
self.revision += 1
@@ -311,45 +422,89 @@ class FollowService:
elif self.last_result is not None and now-self.last_result > 1:
self.halt('Bildauswertung ausgefallen; erneut starten')
def process(self, number, jpeg, captured, revision, target):
def process(self, number, jpeg, captured, revision, target, analysis_ms=None):
with self.lock:
now = self.clock()
if not self.active or revision != self.revision or captured is None or now-captured > 1:
return
self.watchdog()
if not self.active: return
dt = min(.25, max(0, now-(self.last_result or now)))
# Do not catch up a delayed frame with a large movement jump.
previous = self.last_result if self.last_result is not None else now
max_dt = .10 if self.operation == 'follow' else .25
dt = min(max_dt, max(0, now-previous))
self.result_times.append(now)
self.analysis_ms = analysis_ms
self.last_result, self.number, self.jpeg, self.target = now, number, jpeg, target
if self.operation == 'setup':
self.target = None
if self.center_target is not None:
for axis, goal in self.center_target.items():
position = getattr(self, axis)
setattr(self, axis, position + max(-40*dt, min(40*dt, goal-position)))
if self.driver: self.driver.move(self.pan, self.tilt)
if all(getattr(self, a) == goal for a, goal in self.center_target.items()):
self.center_target = None
return
if self.jog_direction and now < self.jog_until:
axis, sign = self._direction(self.jog_direction)
value = max(1100, min(1900, getattr(self,axis)+sign*40*dt))
value = max(self.config[axis+'_setup_min'], min(self.config[axis+'_setup_max'], getattr(self,axis)+sign*40*dt))
setattr(self,axis,value)
if self.driver: self.driver.move(self.pan,self.tilt)
else:
self.jog_direction = None
return
if self.search_started is not None and now-self.search_started >= 60:
self._wait_for_target()
return
if target:
if self.search_started is not None or self.waiting_for_target:
# Stop searching immediately; require two fresh detections
# before returning control to ordinary tracking.
self.reacquire_count += 1
if self.reacquire_count < 2: return
self.search_started = None
self.search_path.clear()
self.waiting_for_target = False
self.reacquire_count = 0
self.last_target = dict(target)
self.last_seen = now
for axis, coordinate in (('pan','x'), ('tilt','y')):
error = target[coordinate]-.5
if abs(error) < .06: continue
sign = -1 if self.config[axis+'_reverse'] else 1
value = getattr(self, axis) + sign*error*160*dt
# The tempo is a speed limit; the loop gain stays capped because
# camera/servo delay makes a higher gain swing back and forth.
speed = self.config['follow_speed']
value = getattr(self, axis) + sign*max(-speed, min(speed, error*FOLLOW_GAIN))*dt
setattr(self, axis, max(self.config[axis+'_min'], min(self.config[axis+'_max'], value)))
if self.driver: self.driver.move(self.pan, self.tilt)
# No searching or recentring when the marker disappears. End the
# hardware session after one second so old pulses do not hold forever.
elif self.driver and self.last_seen is not None and now-self.last_seen > 1:
self.halt('Farbpunkt verloren; erneut starten')
elif self.driver and self.last_seen is not None:
self.reacquire_count = 0
if self.waiting_for_target:
return
if self.config['search_enabled'] and self.last_target is not None:
if now-self.last_seen >= 1.5:
self._search_step(now, dt)
else:
self._wait_for_target()
def status(self):
with self.lock:
return {'active': self.active, 'hardware': self.driver is not None,
times = self.result_times
fps = ((len(times)-1)/(times[-1]-times[0])
if self.active and len(times)>1 and times[-1]>times[0] and self.clock()-times[-1]<1 else 0.)
return {'fps': round(fps, 1), 'analysis_ms': self.analysis_ms,
'searching': self.search_started is not None,
'reacquiring': (self.search_started is not None or self.waiting_for_target) and self.reacquire_count > 0,
'waiting_for_target': self.waiting_for_target,
'search_seconds': round(self.clock()-self.search_started, 1) if self.search_started is not None else 0,
'active': self.active, 'hardware': self.driver is not None,
'operation': self.operation, 'demo': self.camera.demo,
'error': self.error, 'config': dict(self.config),
'pan_us': round(self.pan), 'tilt_us': round(self.tilt),
'setup_origin': dict(self.setup_origin),
'centering': self.center_target is not None,
'target': self.target, 'frame_number': self.number,
'image_base64': base64.b64encode(self.jpeg).decode() if self.active and self.jpeg else None}
@@ -359,7 +514,7 @@ class FollowService:
def _run(self):
last = 0
while not self.quit.wait(.05):
while not self.quit.wait(.01):
self.watchdog()
with self.lock:
if not self.active: continue
@@ -368,8 +523,10 @@ class FollowService:
if number == last or jpeg is None: continue
last = number
try:
began = time.monotonic()
target = locate(jpeg, config) if operation == 'follow' else None
self.process(number, jpeg, captured, revision, target)
analysis_ms = round((time.monotonic()-began)*1000, 1)
self.process(number, jpeg, captured, revision, target, analysis_ms)
except Exception as exc:
with self.lock:
if revision == self.revision: self.halt(f'Nachführungsfehler: {exc}')
+50 -228
View File
@@ -27,6 +27,7 @@
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:#46695b; border:2px solid #c4e0cb; font-weight:700; }
.value { font-weight: 650; color: #ffca78; }
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
.muted { color: #bfcbc6; }
@@ -40,12 +41,44 @@
.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; } }
[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; }
.camera-wrap { max-width: 720px; margin: auto; }
.events { grid-column: auto; }
details > summary { font-size: 1.1rem; }
@media (max-width: 999px) {
header { position: static; }
main { grid-template-columns: minmax(0,1fr); }
}
@media (max-width: 580px) {
header { display: block; padding: .8rem; }
header nav { margin-top: .8rem !important; }
main { padding: .7rem; }
section { padding: .8rem; }
button, select, input { max-width: 100%; }
}
</style>
</head>
<body>
<header>
<h1>Halloween-Grab</h1>
<p>Lokale Kameraansicht und Steuerung</p>
<p>Auslösung, Ereignisse und Profile</p>
<nav class="row" aria-label="Bereich wählen" style="margin-top:1rem">
<a class="download" href="/grab" aria-current="page">Grab</a>
<a class="download" href="/kamera" >Kamera / Finger</a>
<button class="danger" id="all-stop">Alles stoppen</button>
</nav>
<p id="all-stop-status" role="status"></p>
</header>
<main>
<section>
@@ -54,7 +87,7 @@
<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>
<details style="margin-top:1rem"><summary>Automatische Auslösung einstellen</summary>
<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">
@@ -67,6 +100,7 @@
<button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button>
</div>
</details>
<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>
@@ -91,89 +125,34 @@
</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="notice"><strong>Grabablauf nur simuliert.</strong> Hand, Atemmotor, Licht und Ton bleiben simuliert. Kameraservos lassen sich auf der Seite „Kamera / Finger“ bedienen. 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>
<button class="danger" id="stop">Grab stoppen</button>
<button id="reset">Grab 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>
<details><summary>Gespeicherte Ereignisse</summary>
<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>
</details>
</section>
<section class="events">
<h2>Personenprofile</h2>
<details><summary>Personenprofile</summary>
<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>
</details>
</section>
</main>
<script>
@@ -330,8 +309,8 @@
$('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');
$('stop').onclick = () => action('/api/grave/stop');
$('reset').onclick = () => action('/api/grave/reset');
$('create-profile').onclick = async () => {
try {
await api('/api/profiles', { label: $('profile-label').value });
@@ -460,170 +439,13 @@
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() {
$('all-stop').onclick = async () => {
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();
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; }
};
refreshDetection();
setInterval(drawDetection, 250);
refresh();
+101 -10
View File
@@ -9,7 +9,7 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
Effekt-Zustandslogik. Stopp bleibt bis zum Rücksetzen bestehen; nach Start
oder Rücksetzen muss erst der Bereich frei sein und dann ein neuer Trigger
kommen. Beschädigte Einstellungen schalten auf `aus`.
- `web.py` bietet WebUI und API; `index.html` ist die Oberfläche. `run.py`
- `web.py` bietet WebUI und API; `index.html` ist die Grabseite, `camera.html` die getrennte Kameraseite. `run.py`
bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel.
Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn
`halloween.web:create_app` als Factory auf `0.0.0.0:8000` starten;
@@ -104,16 +104,38 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
- Farbwert, Toleranz, Achsrichtungen und Pulsgrenzen atomar in data/follow.json;
Beispielfoto und Analysebilder nur flüchtig. Bildauswahl nutzt Token und exakt
das fotografierte Bild; keine neuen Ereignisse, Profile oder Trainingsbilder.
- Automatische Auslösung und Follow-Sitzung gegenseitig ausschließen. Follow
hält Controller an; allgemeiner Stopp und Reset müssen zuerst Follow stoppen.
Danach bleibt bewusste Rücksetzung des Grabablaufs nötig.
- `/` und `/grab` liefern nur Grabsteuerung/-Polling; `/kamera` liefert nur
Kamera/Farbpunkt/Einrichtung mit eigenem Polling und sofortigem MJPEG-Livebild.
Beide nutzen denselben Kameraproduzenten. Keine versteckten Fremdbedienelemente.
- Farbvorschau und Grabautomatik dürfen gleichzeitig laufen. Nur echte
Kamerabewegung und automatische Grab-Auslösung gegenseitig ausschließen;
Start bei Konflikt ablehnen, keine gespeicherten Einstellungen umschalten.
- Follow verändert Controller/Modus nicht. Grab-Stopp/-Reset unter
`/api/grave/stop` und `/api/grave/reset` verändert Follow nicht. Kamera-Stopp
wirkt nur auf Follow. Explizites „Alles stoppen“ nutzt `/api/stop`; bisheriges
`/api/reset` bleibt für alte Clients als globale Abschaltung erhalten.
- Start braucht frische Kamera und explizite Hardware-/Mechanikbestätigung.
Beide Achsen starten bei 1500 µs (möglicher Sprung, vorab entkoppelt prüfen).
Defaults 1400–1600, absolute Eingabegrenzen 1100–1900 inklusive 1500,
maximal 80 µs/s, keine Suchfahrt; Richtungswechsel nur gestoppt.
Jede Achse startet beim Mittel ihrer gespeicherten Followgrenzen (halbe µs
aufrunden; möglicher Sprung, vorab entkoppelt prüfen).
Defaults Nachführung 1400–1600, Einrichtung 1100–1900,
Nachführtempo 40–800 µs/s (neu 400; alte gespeicherte Konfiguration ohne
Tempo behält 80). Tempo/Farbe/Richtung nur gestoppt speichern. Nachführung
begrenzt den Zeitschritt auf 100 ms statt große Bildlücken nachzuholen;
Einrichtung/Mittelstellungsbutton bleiben 40 µs/s. Regelverstärkung ist mit
`FOLLOW_GAIN` (600 µs/s je Bildanteil) begrenzt, Tempo nur Obergrenze: Delay
der Kamera ließ 800 µs/s im Stillstand pendeln (Simulation, Pi nicht gemessen).
- Pi-Kamera zielt auf 15 Bilder/s (FrameDurationLimits, keine Altbild-Queue),
Demo bleibt bei ca. 5. Producer-FPS und Follow-Auswertungsrate gleitend messen;
`locate()` prüft Masken von streng (Sättigung ≥140) nach locker (≥100); der
kräftigste eindeutige Fleck gewinnt, blasse ähnliche Flächen (bläuliches Papier)
verdecken ihn nicht. `locate()` dekodiert per JPEG-`draft` direkt auf 160×120 und maskiert ohne
Python-Pixelschleife; Ergebnisse müssen mit den Tests übereinstimmen.
Farberkennungsdauer separat ausgeben, keine CPU-/Sensor-FPS daraus ableiten.
MJPEG prüft neue Frames alle 20 ms, Follow-UI fragt alle 100 ms nach einer
abgeschlossenen Antwort erneut ab. Kein garantierter Pi-Durchsatz.
- Sitzung startet nie automatisch. Startender Browser erneuert geheimen
Sitzungsschlüssel; 1,5 s ohne Heartbeat, 1 s ohne Bild/Auswertung oder 1 s
ohne eindeutigen Marker im Hardwarebetrieb schaltet PWM aus. Stale Resultate
Sitzungsschlüssel; 1,5 s ohne Heartbeat oder 1 s ohne Bild/Auswertung
schaltet PWM aus. Reiner Punktverlust beendet die Sitzung nicht. Stale Resultate
und alte Heartbeats dürfen keine neu gestartete Sitzung steuern.
- PWM-Abschaltfehler sichtbar melden; keine sichere Hardwareabschaltung aus
Softwaretests ableiten. I²C-Ausfall/harter Prozessabbruch können PWM stehen
@@ -124,7 +146,76 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
erforderlich. Richtungstaste halten: maximal 40 µs/s, Lease 0,35 s und steigende
Sequenznummer gegen verspätete Befehle. Loslassen hält die Sollposition per
PWM; globaler/Kamerastopp beendet PWM.
- Einrichtung darf bisherige Follow-Grenzen überschreiten, niemals 1100–1900 µs.
- Einrichtung darf Follow-Grenzen überschreiten, begrenzt durch gespeicherte
pan_setup_min/max und tilt_setup_min/max.
Grenzspeicherung braucht passende Sitzung und beendete Bewegung; Richtungs-
umkehr beachten, 10 µs nach innen speichern, atomar in follow.json. Keine
Anschlagerkennung behaupten; nur Sollwerte, beide Achsen vor Anschlag stoppen.
- Endpunkt-UI: vier Richtungs-Karten zeigen
ausschließlich Serverwerte und den Kandidaten mit Innenabstand. Speichern
nur auf passender Seite der festen Startmitte dieser Sitzung (setup_origin), Fehler/Bestätigung im Einrichtbereich.
Farb-/Richtungsspeicherung darf Endpunkte nicht aus alten Formularfeldern
überschreiben. Start speichert nichts; Änderungen vorher explizit speichern.
Verspätete Statusantworten nach einer Speicherung verwerfen (configEpoch).
- Zusätzliche sichtbare Min-/Max-Eingabe für PAN/TILT mit eigenem Speichern und
Verwerfen. Nur gestoppt bedienbar; Entwurf und gespeicherte Werte getrennt
anzeigen. Polling darf ungespeicherte Eingaben nicht überschreiben; Start
verlangt deren Speichern/Verwerfen. Farben/Drehrichtung separat erhalten.
Einrichtgrenzen pro Achse gestoppt im WebUI einstellbar (technische Hülle
500–2500 µs, kein bestätigter mechanischer Weg). Nachführgrenzen müssen darin
liegen; Minimum <= Maximum, keine Bindung an 1500 µs. Setup langsam erweitern, keine Bewegung
beim Speichern. Alte Konfiguration ohne Setupfelder erhält 1100–1900;
keine automatische Erweiterung. PCA-Treiber prüft ebenfalls 500–2500.
- Bedienabläufe/Bezeichnungen bei Änderungen mit README.md und SOFTWARE.md
abgleichen; aktuelle Grenze im PLAN.md von historischen Zwischenständen
unterscheiden. Anschlussänderungen in VERDRAHTUNG.md/SVG synchron halten.
- `setup_origin` wird beim expliziten Start aus gespeicherten Followgrenzen
berechnet und im Status geliefert. Endpunktspeichern ändert ihn nicht und
bewegt keine Achse. Erneuter Start berechnet neu; Dienststart bleibt inaktiv.
WebUI zeigt zusätzlich die nächste Startmitte aus gespeicherter Konfiguration.
- `center(session, sequence)` nur in freigegebener Setup-Sitzung: aktuellen
Endpunktmittelwert als flüchtiges Ziel setzen, im Prozesszyklus beide Achsen
mit höchstens 40 µs/s ohne Überschwingen annähern. Gleiche Sequenzfolge wie
Jog; neuere Jog-/Loslassbefehle brechen ab, alte Centerbefehle dürfen nichts
reaktivieren. Kamera/Heartbeat/Auswertungswatchdog gelten weiterhin.
`centering` im Status; Remember während Fahrt ablehnen, UI-Speichern sperren.
Der feste Sitzungsbezug setup_origin und gespeicherte Konfiguration bleiben.
Expliziter „Bewegung anhalten“-Knopf sendet auch ohne Jog-Timer einen Stopp.
- `search_enabled` wird als bool persistiert, fehlendes Feld migriert zu false.
Nur echte Follow-Sitzung und zuvor erkanntes Ziel erlauben Suche nach 1,5 s
Verlust. Ein Durchlauf auf aktueller TILT-Höhe und beiden TILT-Endpunkten,
erste PAN-/TILT-Richtung aus letztem Bildpunkt und Achsumkehr. Jeder Sollwert
auf Follow-Min/Max begrenzen, niemals Setupgrenzen. Tempo min(follow_speed,200),
vorhandener 100-ms-Zeitschritt bleibt. Kein Endloslauf: Durchlaufende oder 60 s
seit Suchbeginn beendet nur die Suchfahrt, danach auf denselben Farbpunkt
warten; bestehende Watchdogs haben Vorrang.
- Erste Wiedererkennung hält Suchbewegung an, zwei aufeinanderfolgende Resultate
übernehmen Tracking. Fehlresultat setzt Bestätigungszähler zurück. Neuer
Verlust darf neuen Durchlauf beginnen. Start/Halt löschen gesamten Suchzustand;
kein Fund seit Start: an der Startposition warten. Preview/Setup suchen nie.
- Status `searching`, `reacquiring`, `search_seconds`, `waiting_for_target` im UI anzeigen. Suchoption
nur gestoppt speichern, ungespeicherte Änderung blockiert Start. Bestehende
Grenzen und Tempo erhalten. Suchfeldabdeckung und reale Wiedererkennung
erst nach Pi-Test bestätigen; die Farberkennung ist keine Objektidentität.
- Responsive Bedienoberfläche: beide Seiten bis 1800 px breit, ab 1000 px
Bild und Bedienung nebeneinander; schmal einspaltig ohne horizontales Scrollen.
Kamera: Status/Start/Stopp rechts, Farbe/Tempo/Suche aufklappbar; Endpunkte
und manuelle Einrichtung gemeinsam darunter, geöffnet zweispaltig.
Grab: Auslöseeinstellungen, Ereignisse und Profile aufklappbar. IDs, API und
Canvas-Koordinaten unverändert; Bild und Canvas immer im selben 4:3-Rahmen.
Desktopkopf mit Alles-Stopp bleibt sichtbar; mobil normale Seitenrolle.
- Gewählte Farbe bleibt in follow.json bis zur bewussten Farbwahl, auch nach
Punktverlust, Stopp und Neustart. Kein Foto/Objektprofil speichern. Nach
beendetem Suchdurchlauf oder 60 s sowie ohne Suchoption bleibt die gesunde
Hardware-Sitzung in `waiting_for_target` aktiv, ohne neue Bewegungsbefehle;
vorhandene PWM hält Position. Zwei aufeinanderfolgende Funde setzen Tracking
fort. Keine automatischen weiteren Suchrunden ohne erneuten Fund.
Stopp/Fehler/Neustart beenden weiterhin die Freigabe und lassen die Farbe stehen.
+25 -7
View File
@@ -19,6 +19,7 @@ from .follow import FollowService
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
CAMERA_PAGE = (Path(__file__).parent / "camera.html").read_text(encoding="utf-8")
PROFILE_CLEANUP_INTERVAL_SECONDS = 3600
LOGGER = logging.getLogger(__name__)
@@ -101,10 +102,15 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
response.headers["Referrer-Policy"] = "no-referrer"
return response
@app.get("/grab", response_class=HTMLResponse)
@app.get("/", response_class=HTMLResponse)
async def index():
return PAGE
@app.get("/kamera", response_class=HTMLResponse)
async def camera_page():
return CAMERA_PAGE
@app.get("/api/status")
async def status():
return {"controller": controller.status(), "camera": camera.status(),
@@ -120,7 +126,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
async def configure_detection(payload: dict):
if follow.active and payload.get("enabled"):
if follow.driver is not None and payload.get("enabled"):
raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen")
try:
return detection.configure(payload)
@@ -131,7 +137,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.get("/api/follow")
async def follow_status():
return follow.status()
return {**follow.status(), "camera": camera.status()}
@app.post("/api/follow/{action}", dependencies=[Depends(require_ui)])
async def follow_action(action: str, payload: dict):
@@ -144,17 +150,18 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
if action == "select":
return follow.select(payload.get("token"), payload.get("x"), payload.get("y"))
if action == "start":
if detection.get_config()["enabled"]:
raise ValueError("Zuerst automatische Auslösung ausschalten und speichern")
if type(payload.get("hardware", False)) is not bool:
raise ValueError("Ungültige Servofreigabe")
controller.stop()
if payload.get("hardware", False) and detection.get_config()["enabled"]:
raise HTTPException(status_code=409, detail="Für echte Kamerabewegung zuerst auf der Grab-Seite die automatische Auslösung ausschalten und speichern")
session = follow.begin(payload.get("hardware", False),
payload.get("acknowledged") is True,
payload.get("operation", "follow"))
return {"session": session}
if action == "jog":
return follow.jog(payload.get("session"), payload.get("direction"), payload.get("sequence"))
if action == "center":
return follow.center(payload.get("session"), payload.get("sequence"))
if action == "remember":
return follow.remember(payload.get("session"), payload.get("direction"))
if action == "heartbeat":
@@ -261,6 +268,17 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
) from exc
return {"accepted": accepted, "reason": reason, "controller": controller.status()}
@app.post("/api/grave/stop", dependencies=[Depends(require_ui)])
async def grave_stop():
controller.stop()
return controller.status()
@app.post("/api/grave/reset", dependencies=[Depends(require_ui)])
async def grave_reset():
controller.reset()
detection.reset()
return controller.status()
@app.post("/api/stop", dependencies=[Depends(require_ui)])
async def stop():
follow.halt()
@@ -297,7 +315,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
if camera.error:
return
if frame is None or number == last_number:
await asyncio.sleep(0.1)
await asyncio.sleep(0.02)
continue
last_number = number
yield (
@@ -305,7 +323,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
+ str(len(frame)).encode("ascii")
+ b"\r\n\r\n" + frame + b"\r\n"
)
await asyncio.sleep(0.1)
await asyncio.sleep(0.02)
return StreamingResponse(
frames(), media_type="multipart/x-mixed-replace; boundary=frame"
+50
View File
@@ -74,3 +74,53 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
- Follow-Einrichttests prüfen Hold/Release, ablaufende Bewegungslease, verspätete
Tastenbefehle, falsche/alte Sitzung, Grenzen jenseits gespeicherter Followwerte,
absolute Pulsgrenzen, Richtungsumkehr und persistente Grenze mit Innenabstand.
- `test_pages.py` prüft getrennte Seiten/Poller und unabhängige Grab-/Kamera-
Bedienung. Vorschau darf parallel zur Automatik laufen; echte Bewegung bleibt
gesperrt. Globale Abschaltung muss weiterhin beide Bereiche stoppen.
- `node tests/test_endpoints_ui.js` prüft gespeicherte Werte vs. Speicherkandidaten,
Freigabe der richtigen Seite, Richtungsumkehr und Schutz vor ungefragtem
Speichern beim Start beziehungsweise alten Min-/Max-Formularwerten.
- Endpunkt-UI-Test zusätzlich: eigene numerische Grenzspeicherung, Erhalt der
Farb-/Richtungsparameter, Ablehnung von Nachführgrenzen außerhalb des Einrichtbereichs und Startblockade
bei offenem Eingabeentwurf. Erweiterung des Einrichtbereichs explizit speichern.
- Followtest: alte Konfiguration migrieren ohne Erweiterung; Einrichtgrenzen
dauerhaft speichern, Jog dort begrenzen, Endpunkt mit Innenabstand übernehmen
und Nachführung auf gespeicherte Endpunkte begrenzen. Keine echte Hardware.
- Reine Dokumentationspflege: lokale Links/Anker, konsistente Revisionsangaben
und git diff --check prüfen. Frühere Testzahlen als damaligen Prüfstand
kennzeichnen; daraus keinen neuen Testlauf oder Pi-Test ableiten.
- Startmitten: asymmetrische persistierte Endpunkte, beide Achsen getrennt,
halbe µs aufgerundet, Richtungsumkehr, Bereiche außerhalb 1500 und gesperrte
Achse prüfen. Beide Startmodi nutzen dieselbe Berechnung; Dienstkonstruktion
bewegt nichts. Endpunktspeichern nutzt festen Sitzungsbezug ohne Bewegung,
erneuter Start berechnet neu. JS prüft dieselbe Speicherfreigabe.
- Mittelstellungsbutton: gezielte Service-/API-/JS-Tests für langsame Bewegung
beider Achsen zu den zuletzt gespeicherten Endpunkten, Ziel ohne Überschwingen,
unveränderte Konfiguration, Sitzungs-/Modussperre, Stopp und alte Sequenzen,
Richtungsübernahme und Watchdog-Abbruch. Echte Servos bleiben ungetestet.
- Nachführtempo: Altdateien mit 80 µs/s ohne Endpunktänderung migrieren;
80/400/800 bei 15/20 Auswertungen pro Sekunde, begrenztes Nachholen bei
Bildlücken und FPS-/Laufzeitanzeige mit künstlicher Uhr prüfen. JS prüft
ungespeichertes Tempo beim Start und Erhalt beim Endpunktspeichern.
- `test_camera.py` prüft gemessene Producer-FPS und veraltete Frames mit
künstlicher Uhr. Kein Nachweis der Picamera2-Konfiguration oder Pi-Leistung.
- Suchtests: Opt-in/Altdateimigration, kein Suchen vor Erstfund oder in Vorschau,
Schwenks mit Achsumkehr innerhalb asymmetrischer Follow-Endpunkte, Tempo,
gesperrte Achse, endlicher Durchlauf und 60-s-Limit. Zwei aufeinanderfolgende
Funde, Zwischenverlust, erneute Suche, expliziter Stopp sowie alle Watchdogs
mit künstlicher Uhr/Treiber prüfen. JS: Suchoption speichern/bei Grenzänderung
erhalten, Start mit ungespeicherter Option ablehnen. Keine echte Suchfahrt.
- Zielverlust-Wartezustand: drei Minuten ohne Punkt bei gesunder Sitzung ohne
Bewegungsbefehle, Wiederaufnahme nach zwei Funden, nach Suchende kein weiterer
Durchlauf bis erneutem Fund. Farbe bleibt nach Halt/Dienstneukonstruktion
erhalten, neue Farbwahl ersetzt sie. Fehlende Bilder/Heartbeat/Auswertung
beenden weiterhin Sitzung/PWM; reine Abwesenheit des Punkts nicht.
+19
View File
@@ -0,0 +1,19 @@
"""Measured producer rate without camera hardware or wall-clock sleeps."""
import unittest
from unittest.mock import patch
from halloween.camera import CameraStream
class CameraRateTest(unittest.TestCase):
def test_measured_rate_and_stale_frames(self):
camera = CameraStream()
with patch('halloween.camera.time.monotonic') as clock:
clock.return_value = 10.
self.assertEqual(camera.status()['fps'], 0)
for i in range(60):
clock.return_value = 10+i/15
camera._publish(b'test')
self.assertEqual(camera.status()['fps'], 15.)
clock.return_value += 2
self.assertEqual(camera.status()['fps'], 0)
self.assertEqual(CameraStream(demo=True).status()['target_fps'], 5)
+115
View File
@@ -0,0 +1,115 @@
// Verify endpoint presentation and that starting never saves stray form limits.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync(require('node:path').join(__dirname,'../halloween/camera.html'),'utf8');
const state = html.slice(html.indexOf(' function endpointState('),html.indexOf(' async function pollFollow()'));
const begin = html.slice(html.indexOf(' async function beginFollow('),html.indexOf(" $('follow-preview').onclick"));
const save = html.slice(html.indexOf(' async function saveFollow()'),html.indexOf(" for (const [id,hue]"));
const config={search_enabled:false,follow_speed:80,hue:85,tolerance:18,pan_min:1400,pan_max:1600,tilt_min:1400,tilt_max:1600,pan_reverse:false,tilt_reverse:false,pan_setup_min:1100,pan_setup_max:1900,tilt_setup_min:1100,tilt_setup_max:1900};
const nodes={'follow-speed':{value:80}}, calls=[];
const ctx=vm.createContext({
$:id => nodes[id] || (nodes[id]={checked:false,value:18}),
setupReady:true, manualLimitsDirty:false, configEpoch:0, followConfig:{...config}, followLatest:null,
releaseJog:()=>{}, jogSequence:0, followSession:null, followSample:null, followEpoch:0,
api:async (url,body) => {calls.push({url,body}); return url.endsWith('/start') ? {session:'test'} : body;},
fillFollow:c=>{ctx.followConfig=c;}
});
vm.runInContext(state+begin+save,ctx);
(async()=>{
ctx.sample={config:{...config},pan_us:1100,tilt_us:1500};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-value-left'].textContent,'Gespeichert: 1400 µs');
assert.equal(nodes['setup-save-left'].disabled,false);
assert.match(nodes['setup-hint-left'].textContent,/1110/);
assert.equal(nodes['setup-save-right'].disabled,true);
assert.equal(nodes['setup-save-up'].disabled,true);
ctx.sample={config:{...config,pan_reverse:true},pan_us:1100,tilt_us:1900};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true);
assert.equal(nodes['setup-save-right'].disabled,false);
assert.equal(nodes['setup-save-down'].disabled,false);
ctx.sample={config:{...config,pan_min:1728,pan_max:1800},pan_us:1718,tilt_us:1500,setup_origin:{pan:1750,tilt:1500}};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,false); // above 1500 is valid
assert.equal(nodes['setup-save-right'].disabled,true);
ctx.sample.pan_us=1751;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true); // original session centre
ctx.sample.centering=true;
ctx.sample.pan_us=1718;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true);
assert.match(nodes['setup-hint-left'].textContent,/Mittelstellungsfahrt/);
ctx.sample.centering=false;
ctx.setupReady=false;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-right'].disabled,true);
assert.match(nodes['setup-hint-right'].textContent,/Einrichtmodus/);
await vm.runInContext("beginFollow(false,'setup')",ctx);
assert.deepEqual(calls.map(c=>c.url),['/api/follow/start']);
assert.equal(ctx.followSession,'test');
nodes['follow-pan-reverse'].checked=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
assert.equal(calls.length,1);
nodes['follow-pan-reverse'].checked=false;
nodes['follow-speed'].value=400;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
nodes['follow-speed'].value=80;
nodes['follow-search'].checked=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
nodes['follow-speed'].value=400;
nodes['follow-pan-reverse'].checked=true;
// A settings save only changes colour/direction/speed, never endpoint values.
nodes['follow-pan-min']={value:500}; nodes['follow-pan-max']={value:500};
await vm.runInContext('saveFollow()',ctx);
assert.equal(calls[1].body.pan_min,1400);
assert.equal(calls[1].body.pan_max,1600);
assert.equal(calls[1].body.pan_reverse,true);
assert.equal(calls[1].body.follow_speed,400);
assert.equal(calls[1].body.search_enabled,true);
assert.ok(html.includes('id="follow-pan-min"'));
assert.ok(html.includes('Diese Position als Links speichern'));
ctx.manualLimitsDirty=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/noch nicht gespeichert/);
ctx.manualLimitsDirty=false;
const manual=html.slice(html.indexOf(' function fillManualLimits('),html.indexOf(' // Manual limit form bindings.'));
vm.runInContext(manual,ctx);
vm.runInContext('fillManualLimits(followConfig)',ctx);
nodes['follow-pan-min'].value=1200; nodes['follow-pan-max'].value=1800;
nodes['follow-tilt-min']={value:1300}; nodes['follow-tilt-max']={value:1700};
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.pan_min,1200);
assert.equal(calls.at(-1).body.tilt_max,1700);
assert.equal(calls.at(-1).body.pan_reverse,true);
assert.match(nodes['manual-limits-status'].textContent,/gespeichert/);
assert.equal(calls.at(-1).body.follow_speed,400);
assert.equal(calls.at(-1).body.search_enabled,true);
nodes['follow-tilt-min'].value=1700; nodes['follow-tilt-max'].value=1800;
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.tilt_min,1700); // range need not contain 1500
const count=calls.length;
nodes['follow-pan-max'].value=2500;
await assert.rejects(vm.runInContext('saveManualLimits()',ctx),/500–2500/);
assert.equal(calls.length,count);
nodes['follow-pan-setup-max'].value=2500;
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.pan_max,2500);
assert.equal(calls.at(-1).body.pan_setup_max,2500);
nodes['follow-pan-setup-max'].value=2501;
await assert.rejects(vm.runInContext('saveManualLimits()',ctx),/500–2500/);
const centerCode=html.slice(html.indexOf(' async function centerSetup()'),html.indexOf(" $('setup-center').onclick"));
vm.runInContext(centerCode,ctx);
ctx.setupReady=false;
await assert.rejects(vm.runInContext('centerSetup()',ctx),/Einrichtmodus/);
ctx.setupReady=true; ctx.followSession='test'; ctx.jogSequence=5;
ctx.api=async (url,body)=>{calls.push({url,body});return {accepted:true};};
ctx.sendJog=async direction=>{calls.push({url:'/api/follow/jog',body:{direction,sequence:++ctx.jogSequence}});};
await vm.runInContext('centerSetup()',ctx);
assert.equal(calls.at(-1).url,'/api/follow/center');
assert.equal(calls.at(-1).body.sequence,6);
await vm.runInContext('stopSetupMotion()',ctx);
assert.equal(calls.at(-1).body.direction,null);
assert.equal(calls.at(-1).body.sequence,7);
console.log('Endpoint UI tests passed');
})().catch(e=>{console.error(e);process.exit(1);});
+317 -6
View File
@@ -56,6 +56,18 @@ class FollowTest(unittest.TestCase):
self.assertIsNone(locate(frame('red'), DEFAULT))
self.assertIsNotNone(locate(frame('red'), dict(DEFAULT,hue=0)))
def test_similar_coloured_paper_does_not_hide_saturated_marker(self):
image = Image.new('RGB', (640,480), '#181818')
draw = ImageDraw.Draw(image)
draw.rectangle((200,0,560,330), fill=(125,158,204)) # bluish paper, hue ~152, S ~95
draw.rectangle((250,250,520,330), fill=(120,153,198)) # slightly stronger paper shade
draw.ellipse((300,30,350,80), fill=(68,73,177))
data = BytesIO(); image.save(data, 'JPEG'); data = data.getvalue()
for tolerance in (10, 18, 35):
target = locate(data, dict(DEFAULT, hue=165, tolerance=tolerance))
self.assertIsNotNone(target)
self.assertLess(target['y'], .2)
def test_photo_selection_and_no_persistent_image(self):
shot = self.follow.capture()
config = self.follow.select(shot['token'],.74,.45)
@@ -77,9 +89,9 @@ class FollowTest(unittest.TestCase):
def test_rate_limits_reverse_deadband_and_stop(self):
self.start(); driver=self.drivers[0]
self.step({'x':.9,'y':.5})
self.assertAlmostEqual(self.follow.pan,1512.8)
self.assertAlmostEqual(self.follow.pan,1524)
self.assertEqual(self.follow.tilt,1500)
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),16)
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),40)
for _ in range(30): self.step({'x':1.,'y':1.})
self.assertEqual(self.follow.pan,1600)
self.follow.halt(); self.assertTrue(driver.closed)
@@ -90,6 +102,209 @@ class FollowTest(unittest.TestCase):
self.start(); self.step({'x':.9,'y':.5})
self.assertLess(self.follow.pan,1500)
def test_legacy_speed_migration_preserves_endpoints(self):
import json
legacy = dict(DEFAULT, pan_min=1234, pan_max=1789)
del legacy['follow_speed']
Path(self.folder.name, 'follow.json').write_text(json.dumps(legacy))
restored = FollowService(self.camera, Path(self.folder.name))
self.assertEqual(restored.config, dict(legacy, follow_speed=80))
ancient = {k:v for k,v in legacy.items() if '_setup_' not in k}
self.assertEqual(validate(ancient), restored.config)
for speed in (39, 801, float('nan'), True):
with self.assertRaises(ValueError): validate(dict(DEFAULT, follow_speed=speed))
def test_speed_is_time_based_and_delayed_frame_is_capped(self):
config = dict(DEFAULT, pan_min=500, pan_max=2500,
pan_setup_min=500, pan_setup_max=2500)
for speed in (80, 400, 800):
for fps in (15, 20):
self.follow.halt()
self.follow.configure(dict(config, follow_speed=speed))
self.start()
for number in range(fps):
self.now += 1/fps; self.camera.captured = self.now
self.follow.heartbeat(self.follow.session)
self.follow.process(number, self.camera.jpeg, self.now,
self.follow.revision, {'x':1., 'y':.5}, 3.2)
self.assertAlmostEqual(self.follow.pan, 1500+min(speed, 300))
self.assertEqual(self.follow.status()['fps'], fps)
self.assertEqual(self.follow.status()['analysis_ms'], 3.2)
before = self.follow.pan
self.now += .5; self.camera.captured = self.now
self.follow.process(99, self.camera.jpeg, self.now,
self.follow.revision, {'x':1., 'y':.5})
self.assertAlmostEqual(self.follow.pan-before, min(speed, 300)*.1)
self.follow.halt()
self.assertEqual(self.follow.status()['fps'], 0)
def test_search_is_opt_in_and_requires_prior_target_and_real_session(self):
legacy = dict(DEFAULT); del legacy['search_enabled']
self.assertFalse(validate(legacy)['search_enabled'])
with self.assertRaises(ValueError): validate(dict(DEFAULT, search_enabled=1))
self.follow.configure(dict(DEFAULT, search_enabled=True))
self.assertTrue(FollowService(self.camera,Path(self.folder.name)).config['search_enabled'])
self.start()
for _ in range(6): self.step(None)
self.assertTrue(self.follow.active) # no blind search on start
self.assertTrue(self.follow.waiting_for_target)
self.follow.halt()
self.follow.begin(False)
self.step({'x':.7,'y':.7})
for _ in range(10): self.step(None)
self.assertFalse(self.follow.status()['searching'])
self.assertEqual(len(self.drivers),1) # preview creates no driver
def test_search_sweep_limits_speed_and_finite_completion(self):
for reverse in (False, True):
self.follow.halt()
self.follow.configure(dict(DEFAULT,search_enabled=True,pan_reverse=reverse,
tilt_reverse=reverse,pan_min=1420,pan_max=1580,tilt_min=1430,tilt_max=1570,
follow_speed=800))
self.start(); self.step({'x':.8,'y':.8})
driver=self.drivers[-1]; before=self.follow.pan
self.step(None); self.step(None)
self.assertEqual(self.follow.pan,before) # brief occlusion: hold
for _ in range(6): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.assertGreater((self.follow.pan-before)*(-1 if reverse else 1),0)
start=len(driver.moves)-1
for _ in range(200):
if not self.follow.active: break
self.step(None)
self.assertTrue(self.follow.active)
self.assertFalse(driver.closed)
self.assertTrue(self.follow.waiting_for_target)
for pan,tilt in driver.moves:
self.assertTrue(1420 <= pan <= 1580)
self.assertTrue(1430 <= tilt <= 1570)
for a,b in zip(driver.moves[start:],driver.moves[start+1:]):
self.assertLessEqual(abs(a[0]-b[0]),20.0001)
self.assertLessEqual(abs(a[1]-b[1]),20.0001)
self.assertEqual({p for p,t in driver.moves} & {1420,1580},{1420,1580})
self.assertEqual({t for p,t in driver.moves} & {1430,1570},{1430,1570})
def test_search_reacquisition_holds_then_resumes_and_new_loss_searches_again(self):
self.follow.configure(dict(DEFAULT,search_enabled=True))
self.start(); self.step({'x':.7,'y':.5})
for _ in range(8): self.step(None)
before=(self.follow.pan,self.follow.tilt)
self.step({'x':.3,'y':.5})
self.assertEqual((self.follow.pan,self.follow.tilt),before)
self.assertTrue(self.follow.status()['reacquiring'])
self.step(None) # isolated detection must not end search
self.assertFalse(self.follow.status()['reacquiring'])
self.step({'x':.5,'y':.5}); self.step({'x':.5,'y':.5})
self.assertFalse(self.follow.status()['searching'])
self.assertTrue(self.follow.active)
for _ in range(8): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.follow.halt()
self.assertFalse(self.follow.status()['searching'])
self.start()
self.assertIsNone(self.follow.last_target)
def test_search_timeout_locked_axes_and_failure_watchdogs(self):
self.follow.configure(dict(DEFAULT,search_enabled=True,follow_speed=40,
pan_min=500,pan_max=2500,pan_setup_min=500,pan_setup_max=2500,
tilt_min=1500,tilt_max=1500))
self.start(); self.step({'x':.9,'y':.8})
for _ in range(310):
if not self.follow.active: break
self.step(None)
self.assertTrue(self.follow.active)
self.assertTrue(self.follow.waiting_for_target)
self.assertFalse(self.follow.status()['searching'])
self.follow.halt()
self.assertTrue(all(t==1500 for p,t in self.drivers[-1].moves))
for reason in ('camera','heartbeat','inference'):
self.camera.captured=self.now
self.start(); self.step({'x':.9,'y':.5})
for _ in range(8): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.now+=2
if reason!='camera': self.camera.captured=self.now
if reason=='inference': self.follow.heartbeat(self.follow.session)
self.follow.watchdog()
self.assertFalse(self.follow.active,reason)
self.assertTrue(self.drivers[-1].closed,reason)
def test_missing_target_waits_indefinitely_and_reacquires_without_restarting(self):
self.follow.configure(dict(DEFAULT,hue=179))
session=self.start()
self.step({'x':.7,'y':.5})
driver=self.drivers[-1]; moves=len(driver.moves)
for _ in range(900): self.step(None) # three minutes, healthy connection
self.assertTrue(self.follow.active)
self.assertEqual(self.follow.session,session)
self.assertEqual(len(driver.moves),moves)
self.assertTrue(self.follow.status()['waiting_for_target'])
self.step({'x':.3,'y':.5})
self.assertEqual(len(driver.moves),moves)
self.step({'x':.3,'y':.5})
self.assertFalse(self.follow.waiting_for_target)
self.assertEqual(len(driver.moves),moves+1)
self.assertEqual(self.follow.config['hue'],179)
self.follow.halt()
restored=FollowService(self.camera,Path(self.folder.name))
self.assertFalse(restored.active)
self.assertEqual(restored.config['hue'],179)
self.follow.configure(dict(self.follow.config,hue=0))
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config['hue'],0)
def test_finished_search_waits_without_repeating_and_reacquires(self):
self.follow.configure(dict(DEFAULT,search_enabled=True))
self.start(); self.step({'x':.7,'y':.5})
for _ in range(500): self.step(None)
self.assertTrue(self.follow.waiting_for_target)
count=len(self.drivers[-1].moves)
for _ in range(100): self.step(None)
self.assertEqual(len(self.drivers[-1].moves),count)
self.step({'x':.3,'y':.5}); self.step({'x':.3,'y':.5})
self.assertFalse(self.follow.waiting_for_target)
self.assertTrue(self.follow.active)
self.assertGreater(len(self.drivers[-1].moves),count)
self.now+=2; self.camera.captured=self.now
self.follow.watchdog()
self.assertFalse(self.follow.active)
self.assertTrue(self.drivers[-1].closed)
def test_start_uses_persisted_independent_endpoint_midpoints(self):
config = dict(DEFAULT, pan_min=1000, pan_max=1800,
pan_setup_min=500, pan_setup_max=2500,
tilt_min=1700, tilt_max=2001, tilt_setup_max=2300,
pan_reverse=True, tilt_reverse=True)
self.follow.configure(config)
for operation in ('setup', 'follow'):
driver = Driver()
service = FollowService(self.camera, Path(self.folder.name),
lambda: driver, lambda: self.now)
self.assertFalse(service.active)
self.assertEqual(driver.moves, []) # restart does not move anything
service.begin(True, True, operation)
self.assertEqual(driver.moves, [(1400,1851)])
self.assertEqual(service.status()['setup_origin'], {'pan':1400,'tilt':1851})
service.halt()
self.follow.configure(dict(config,pan_min=1200,pan_max=1200))
self.start()
self.assertEqual(self.drivers[-1].moves, [(1200,1851)])
def test_remember_uses_fixed_session_origin_and_next_start_recalculates(self):
self.follow.configure(dict(DEFAULT,pan_min=1700,pan_max=1800))
session = self.follow.begin(True,True,'setup')
self.assertEqual(self.follow.pan,1750)
for seq in range(1,5):
self.follow.jog(session,'left',seq); self.step(None)
self.follow.jog(session,None,5)
count = len(self.drivers[-1].moves)
with self.assertRaises(ValueError): self.follow.remember(session,'right')
saved = self.follow.remember(session,'left')
self.assertEqual(saved['pan_min'],1728)
self.assertEqual(self.follow.status()['setup_origin']['pan'],1750)
self.assertEqual(len(self.drivers[-1].moves),count)
self.follow.halt(); self.start()
self.assertEqual(self.drivers[-1].moves,[(1764,1500)])
def test_stale_results_cannot_move_new_session(self):
self.start(); revision=self.follow.revision
self.follow.halt(); self.start(); driver=self.drivers[-1]
@@ -97,7 +312,7 @@ class FollowTest(unittest.TestCase):
self.assertEqual(driver.moves,[(1500,1500)])
def test_lost_marker_heartbeat_and_camera_latch_off(self):
for reason in ('marker','heartbeat','camera','inference'):
for reason in ('heartbeat','camera','inference'):
self.follow.halt(); self.camera.captured=self.now
self.start(); driver=self.drivers[-1]
if reason=='marker':
@@ -112,13 +327,41 @@ class FollowTest(unittest.TestCase):
self.assertIsNotNone(self.follow.error)
def test_invalid_limits_and_corrupt_saved_config(self):
for config in (dict(DEFAULT,pan_min=1600), dict(DEFAULT,pan_max=2000),
for config in (dict(DEFAULT,pan_min=1601), dict(DEFAULT,pan_max=2000),
dict(DEFAULT,hue=float('inf')), dict(DEFAULT,pan_reverse='false')):
with self.assertRaises(ValueError): validate(config)
Path(self.folder.name,'follow.json').write_text('bad')
follow=FollowService(self.camera,Path(self.folder.name))
with self.assertRaises(ValueError): follow.begin()
def test_custom_setup_envelope_persistence_migration_and_follow_limits(self):
old = {k:v for k,v in DEFAULT.items() if '_setup_' not in k}
self.assertEqual(validate(old), DEFAULT)
config = dict(DEFAULT, pan_setup_min=700, pan_setup_max=2300,
tilt_setup_min=1000, tilt_setup_max=2000)
self.follow.configure(config)
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config, config)
session = self.follow.begin(True,True,'setup')
with self.assertRaises(ValueError): self.follow.configure(DEFAULT)
for seq in range(1,130):
self.follow.jog(session,'right',seq); self.step(None)
self.assertEqual(self.follow.pan,2300)
self.assertEqual(self.follow.tilt,1500)
self.follow.jog(session,None,130)
self.assertEqual(self.follow.remember(session,'right')['pan_max'],2290)
for seq in range(131,350):
self.follow.jog(session,'left',seq); self.step(None)
self.assertEqual(self.follow.pan,700)
self.follow.jog(session,None,350)
self.assertEqual(self.follow.remember(session,'left')['pan_min'],710)
self.follow.halt(); self.start()
for _ in range(100): self.step({'x':1,'y':1})
self.assertEqual(self.follow.pan,2290)
self.assertEqual(self.follow.tilt,1600)
for bad in (dict(config,pan_setup_min=499), dict(config,tilt_setup_max=2501),
dict(config,pan_min=600), dict(config,tilt_max=2100)):
with self.assertRaises(ValueError): validate(bad)
def test_close_error_is_visible_and_restart_is_explicit(self):
self.start()
def bad_close(): raise OSError('I2C ausgefallen')
@@ -184,6 +427,73 @@ class FollowTest(unittest.TestCase):
self.follow.jog(session,None,80)
self.assertEqual(self.follow.remember(session,'up')['tilt_max'],1890)
def test_center_moves_slowly_to_current_saved_limits_without_saving(self):
self.follow.configure(dict(DEFAULT,pan_min=1200,pan_max=1700,
tilt_min=1400,tilt_max=1800))
session = self.follow.begin(True,True,'setup')
for seq in range(1,11):
self.follow.jog(session,'right',seq); self.step(None)
self.follow.jog(session,None,11)
self.follow.remember(session,'right')
for seq in range(12,22):
self.follow.jog(session,'up',seq); self.step(None)
self.follow.jog(session,None,22)
saved = self.follow.path.read_bytes()
before = (self.follow.pan,self.follow.tilt)
self.assertTrue(self.follow.center(session,23)['accepted'])
self.assertEqual((self.follow.pan,self.follow.tilt),before) # no sudden jump
with self.assertRaises(ValueError): self.follow.remember(session,'left')
self.step(None)
self.assertAlmostEqual(self.follow.pan,before[0]-8)
self.assertAlmostEqual(self.follow.tilt,before[1]+8)
for _ in range(40): self.step(None)
self.assertEqual((self.follow.pan,self.follow.tilt),(1360,1600))
self.assertFalse(self.follow.status()['centering'])
self.assertEqual(self.follow.path.read_bytes(),saved)
self.assertEqual(self.follow.setup_origin,{'pan':1450,'tilt':1600})
def test_center_cancellation_stale_commands_watchdog_and_session(self):
with self.assertRaises(ValueError): self.follow.center('none',1)
session = self.start()
with self.assertRaises(ValueError): self.follow.center(session,1)
self.follow.halt(); session = self.follow.begin(True,True,'setup')
with self.assertRaises(ValueError): self.follow.center('wrong',1)
self.follow.jog(session,'right',1); self.step(None)
self.follow.center(session,2)
self.follow.jog(session,None,3)
stopped = self.follow.pan
self.assertFalse(self.follow.center(session,2)['accepted'])
self.step(None); self.assertEqual(self.follow.pan,stopped)
self.follow.center(session,4)
self.follow.jog(session,'right',5); self.step(None)
self.assertGreater(self.follow.pan,stopped)
self.assertFalse(self.follow.status()['centering'])
self.follow.center(session,6)
self.now+=2; self.camera.captured=self.now
self.follow.watchdog()
self.assertFalse(self.follow.active)
self.assertFalse(self.follow.status()['centering'])
self.assertTrue(self.drivers[-1].closed)
with self.assertRaises(ValueError): self.follow.center(session,7)
def test_center_api_requires_header_and_setup_session(self):
async def scenario():
app=create_app(demo=True,state_dir=Path(self.folder.name))
app.state.follow.camera=self.camera
app.state.follow.clock=lambda:self.now
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
self.assertEqual((await c.post('/api/follow/center',json={})).status_code,403)
self.assertEqual((await c.post('/api/follow/center',headers=h,json={})).status_code,422)
start=await c.post('/api/follow/start',headers=h,json={'operation':'setup'})
session=start.json()['session']
result=await c.post('/api/follow/center',headers=h,json={'session':session,'sequence':1})
self.assertEqual(result.status_code,200)
self.assertTrue(result.json()['accepted'])
await c.post('/api/follow/stop',headers=h,json={})
self.assertFalse(app.state.follow.status()['centering'])
asyncio.run(scenario())
def test_setup_cannot_be_controlled_by_wrong_session_or_follow_mode(self):
session=self.start()
with self.assertRaises(ValueError): self.follow.jog(session,'up',1)
@@ -220,7 +530,7 @@ class FollowTest(unittest.TestCase):
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
self.assertEqual((await c.post('/api/follow/start',json={})).status_code,403)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={'hardware':False})).status_code,200)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True})).status_code,200)
cfg=app.state.detection.get_config(); cfg['enabled']=True
self.assertEqual((await c.post('/api/detection/config',headers=h,json=cfg)).status_code,409)
await c.post('/api/stop',headers=h)
@@ -229,7 +539,8 @@ class FollowTest(unittest.TestCase):
await c.post('/api/reset',headers=h)
self.assertFalse(app.state.follow.active)
await c.post('/api/detection/config',headers=h,json=cfg)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={})).status_code,422)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={})).status_code,200)
self.assertFalse(app.state.controller.stopped)
asyncio.run(scenario())
if __name__=='__main__': unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Separate operating pages and independent controls; no real hardware."""
import asyncio
from io import BytesIO
from pathlib import Path
import tempfile
import time
import unittest
import httpx
from PIL import Image
from halloween.web import create_app
class PagesTest(unittest.TestCase):
def test_pages_contain_only_their_own_controls_and_pollers(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app=create_app(demo=True,state_dir=Path(folder))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
root=(await c.get('/')).text
grave=(await c.get('/grab')).text
camera=(await c.get('/kamera')).text
self.assertEqual(root,grave)
for page in (grave,camera):
self.assertIn('href="/grab"',page)
self.assertIn('href="/kamera"',page)
self.assertIn('Alles stoppen',page)
self.assertIn('id="events-list"',grave)
self.assertNotIn('id="follow-panel"',grave)
self.assertNotIn('/api/follow',grave)
self.assertIn('id="setup-start"',camera)
self.assertIn('src="/stream.mjpg"',camera)
self.assertNotIn('id="detection-enabled"',camera)
for endpoint in ('/api/events','/api/profiles','/api/detection'):
self.assertNotIn(endpoint,camera)
asyncio.run(scenario())
def test_preview_and_grave_controls_are_independent_hardware_is_interlocked(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app=create_app(demo=True,state_dir=Path(folder))
jpeg=BytesIO(); Image.new('RGB',(64,48),'green').save(jpeg,'JPEG')
class Camera:
demo=False
error=None
def latest_timed(self): return 1,jpeg.getvalue(),time.monotonic()
class Driver:
closed=False
def move(self,*args): pass
def close(self): self.closed=True
driver=Driver()
app.state.follow.camera=Camera()
app.state.follow.factory=lambda:driver
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
cfg=app.state.detection.get_config(); cfg['enabled']=True
await c.post('/api/detection/config',headers=h,json=cfg)
start=await c.post('/api/follow/start',headers=h,json={})
self.assertEqual(start.status_code,200)
self.assertFalse(app.state.controller.stopped)
self.assertTrue(app.state.detection.get_config()['enabled'])
await c.post('/api/follow/stop',headers=h,json={})
blocked=await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True})
self.assertEqual(blocked.status_code,409)
self.assertFalse(app.state.follow.active)
cfg['enabled']=False
await c.post('/api/detection/config',headers=h,json=cfg)
started=await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True,'operation':'setup'})
self.assertEqual(started.status_code,200)
session=started.json()['session']
self.assertFalse(app.state.controller.stopped)
self.assertEqual((await c.post('/api/grave/stop')).status_code,403)
await c.post('/api/grave/stop',headers=h)
self.assertTrue(app.state.controller.stopped)
self.assertTrue(app.state.follow.active)
await c.post('/api/grave/reset',headers=h)
app.state.follow.heartbeat(session)
self.assertFalse(driver.closed)
await c.post('/api/mode',headers=h,json={'mode':'sanft'})
self.assertTrue(app.state.follow.active)
self.assertEqual(app.state.controller.mode,'sanft')
cfg['enabled']=True
self.assertEqual((await c.post('/api/detection/config',headers=h,json=cfg)).status_code,409)
await c.post('/api/stop',headers=h)
self.assertFalse(app.state.follow.active)
self.assertTrue(driver.closed)
self.assertTrue(app.state.controller.stopped)
asyncio.run(scenario())
if __name__=='__main__': unittest.main()
+5
View File
@@ -52,3 +52,8 @@ Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren.
- Slave-Verbindungen verwenden BatchMode=yes, damit bei Verlust der gemeinsamen
Verbindung keine weiteren Passwortdialoge entstehen. Ohne Schlüssel sind SSH
und sudo weiterhin zwei getrennte Anmeldungen. Vorschau erzeugt keinen Socket.
- WebUI-Updates enthalten beide HTML-Seiten unter halloween/ samt Backend.
follow.json bleibt im gemeinsamen data/-Ordner; keine Beispielkonfiguration
darüberkopieren. Bedienhinweis: Kameraversorgung vor Update abziehen und
nachher Browser hart neu laden; Dienststart startet keine Kamerasitzung.