added point mode

This commit is contained in:
marcinator_2 committed 2026-10-15 20:31:02 +02:00
1 parent 044ad2afbe
commit fce8f62534
16 files changed
+1547 -10

No files matched your search

+29 -2
View File
@@ -43,8 +43,10 @@ Versorgung und Sicherheit im Plan.
(Beschriftung auf Nutzerfoto bestätigt), eventuell Display,
LEDs, Servos und Lötwerkzeug. 3D-Drucker laut Nutzer vorhanden;
Modell, Bauraum und Druckmaterial noch unbekannt.
PCA9685-Modul mit 16 Kanälen laut Nutzer vorhanden; konkrete Platine
und Anschlüsse noch nicht geprüft, kein Neukauf nötig.
PCA9685-Modul mit 16 Kanälen per Nutzerfoto bestätigt; `PCA9685_FOTO.jpg`
liegt im Projekt. Beschriftung: Gelb=PWM, Rot=V+, Schwarz=GND; Logikpins
GND/OE/SCL/SDA/VCC/V+. Hersteller, Klemmenpolung und Funktion noch prüfen;
kein Neukauf nötig.
Buchse/Buchse-Steuerleitungen laut Nutzer reichlich vorhanden;
kein Neukauf nötig, nicht für Servolast verwenden.
Weitere Teile dürfen vorgeschlagen werden.
@@ -60,6 +62,31 @@ Versorgung und Sicherheit im Plan.
Standard-auf-Mini-Kamerakabel für Pi 5 einplanen; vorhandenes Kabel prüfen.
- GPIOs steuern geeignete Treiber; Motoren, Servos und größere LED-Lasten
erhalten eine passend dimensionierte Versorgung.
- Kamera-Schwenkarm laut Nutzer vorhanden, Kameraservos laut Nutzer Amazon
B07KPS9845; genaue Variante und elektrische Daten noch prüfen. Dauerhafter
Anschlussplan: `VERDRAHTUNG.md` mit `VERDRAHTUNG.svg` und großer
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
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.
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.
- 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.
- 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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+65
View File
@@ -1,5 +1,29 @@
# Halloween – Projektregeln
## Verdrahtungsplan vom 15. Oktober 2026
- Erledigt: dauerhafte Anschlussliste [VERDRAHTUNG.md](VERDRAHTUNG.md) und
[Zeichnung](VERDRAHTUNG.svg) für Pi 5, Kamera und zwei Kameraservos angelegt.
Kabelkennungen W01–W13; geplante Belegung CH0=PAN, CH1=TILT.
- Nutzerstand: Schwenkarm gekauft, Kameraadapter wird gedruckt; Nutzer nennt
Amazon B07KPS9845 als Servos. Genaue Servodaten nicht unabhängig bestätigt.
- Dokumentarisch geprüft: Pi-/PCA9685-Verbindung gegen Herstellerreferenzen,
getrennte Logik-/Servoversorgung und gemeinsame Masse beschrieben.
Keine praktische Verdrahtungs- oder Bewegungsprüfung erfolgt.
- Per Nutzerfoto bestätigt und lokal abgelegt: PCA9685 mit 16 Kanälen;
gelbe Reihe PWM, rote V+, schwarze GND; Plan um das Boardfoto ergänzt.
- Revision 4: große Pi-Pinübersicht [RASPI_PINS.svg](RASPI_PINS.svg) mit
eindeutiger Draufsicht und vergrößerten ersten drei Pinpaaren ergänzt.
- Revision 3: W01 Grün, W02 Blau, W03 Braun, W04 Grau passend zu den
Nutzerkabeln in Anschlussliste und Zeichnung dokumentiert.
- Offen: Klemmenpolung und Kanalposition am Board, Servotyp/Kabelfarben, geeignetes
Servonetzteil und Polung, benutzter Kameraport, I²C-Adresse, Winkelgrenzen
und Lasttest. Entwurf noch keine bestätigte Steckanleitung für D1.
- Offen: echte Kameraservosteuerung und sichere Start-/Fehlerabschaltung;
Handservo, Relais/Motor und Beleuchtung später im selben Plan ergänzen.
- Pflege: nach jeder tatsächlichen Anschlussänderung Liste, Zeichnung und
Prüfprotokoll gemeinsam aktualisieren; Kabel an beiden Enden beschriften.
## Ziel und aktueller Auftrag
Interaktives atmendes Grab für Halloween im Vorgarten der Schwester bauen.
@@ -331,3 +355,44 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen.
Aufräumen bei Erfolg/Fehler. Ohne Schlüssel sind SSH und sudo zwei Anmeldungen.
- Lokal verifiziert: 15 Update-/Transport-Simulationstests bestanden, keine
Verbindung/Socketerzeugung bei Vorschau. Echte Passwortanzahl am Pi noch offen.
## Farbpunktnachführung vom 15. Oktober 2026
- Nutzerauftrag: Kameramodus zum Verfolgen eines Fingers beziehungsweise farbigen
Punkts, eventuell mit Beispielfoto. Umgesetzt als Farbpunkt-Test, keine
Finger-/Handerkennung und kein trainiertes Modell.
- Erledigt: Rot/Grün oder Farbwahl im flüchtigen Kamerafoto; eigener synchroner
Bildbereich, Vorschau ohne I²C; bewusst gestartete CH0/CH1-Ausgabe mit enger
Pulsbegrenzung, Totzone, Richtungsumkehr und begrenzter Geschwindigkeit.
- Erledigt: kein gleichzeitiger Automatikbetrieb; Controller bleibt gestoppt.
Stop/Reset, Heartbeatverlust, Bild-/Auswertungsfehler und Marker-Verlust
beenden Hardware-Sitzung; Konfiguration bleibt erhalten, Aktivierung nicht.
- Nutzer meldet Verdrahtung fertig; derzeit separate Powerbank, die bereits
zwei Servos betrieben hat. Modell/Stromreserve und Leitungsstärke offen.
- Verifiziert lokal: synthetische Bild-/Regelungs-/Fehler-/API-Tests und
künstlicher I²C-Bus; Browserdemo prüft Farbwahl, Vorschau, Stopp, Beispielfoto.
Vollsuite: 84 Tests, davon 69 bestanden und 15 wegen fehlendem optionalem
OpenCV/Modell übersprungen; beide bisherigen JS-UI-Tests und JS-Syntaxprüfung
bestanden. Keine echte Bewegung, kein Upload am Pi erfolgt.
- Offen am Pi: Upload, I²C/Berechtigung/Adresse, entkoppelte 1500-µs-Zentrierung,
Drehrichtungen und Grenzen, Stromreserve, Nachführung mit echter NoIR-Kamera.
- Offen: unabhängige OE-/Hardwareabschaltung. PWM-Aus bei funktionierendem I²C
ist keine Stromtrennung und schützt nicht vor hartem Prozess-/Pi-Ausfall.
## Manueller Kameraeinrichtmodus vom 15. Oktober 2026
- Nutzerauftrag: Endpunkte ermitteln, insbesondere Neigung manuell stoppen;
keine Endschalter vorhanden. Keine automatische Fahrt gegen Anschläge umgesetzt.
- Erledigt: eigene Einrichtsitzung, große Richtungstasten mit Haltefunktion,
sichtbare Sollwerte und gespeicherte Grenzen; Richtungsumkehr konsistent.
40 µs/s, Bewegungslease 0,35 s, Sequenzen gegen verspätete Befehle; Loslassen
hält Position, Kamerastopp beendet PWM. Grenzspeicherung mit 10 µs Innenabstand.
- Lokal geprüft: synthetische Hardware-/Lease-/Persistenztests; Browserdemo
mit gedrückter Taste, anschließend unveränderter Sollposition und Speicherung.
Keine echte Bewegung, kein Upload und keine Bestätigung realer Endpunkte.
- Offen am Pi: Mittelstellung, Drehrichtungen, sichere mechanische Grenzen und
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.
+79
View File
@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1100" viewBox="0 0 1500 1100"><rect width="1500" height="1100" fill="white"/><style>text{font-family:DejaVu Sans,Arial,sans-serif;fill:#142233;font-size:28px}.big{font-size:40px;font-weight:bold}.num{font-size:46px;font-weight:bold;text-anchor:middle}.mid{font-size:30px;text-anchor:middle}.small{font-size:22px}</style>
<text x="45" y="60" class="big">Pi 5 · Diese vier Pins brauchst du</text>
<text x="45" y="107" class="">Von oben auf die Bauteile schauen. USB- und Netzwerkbuchsen nach rechts.</text>
<rect x="85" y="145" width="1330" height="245" rx="12" fill="#eaf4e8" stroke="#142233" stroke-width="3"/>
<text x="105" y="185" class="small">PLATINENRAND</text>
<rect x="185" y="200" width="980" height="85" rx="12" fill="#e2e4e7" stroke="#142233" stroke-width="3"/>
<circle cx="210" cy="224" r="8" fill="#3b4149"/>
<circle cx="210" cy="261" r="8" fill="#3b4149"/>
<circle cx="258" cy="224" r="8" fill="#3b4149"/>
<circle cx="258" cy="261" r="8" fill="#3b4149"/>
<circle cx="306" cy="224" r="8" fill="#3b4149"/>
<circle cx="306" cy="261" r="8" fill="#3b4149"/>
<circle cx="354" cy="224" r="8" fill="#3b4149"/>
<circle cx="354" cy="261" r="8" fill="#3b4149"/>
<circle cx="402" cy="224" r="8" fill="#3b4149"/>
<circle cx="402" cy="261" r="8" fill="#3b4149"/>
<circle cx="450" cy="224" r="8" fill="#3b4149"/>
<circle cx="450" cy="261" r="8" fill="#3b4149"/>
<circle cx="498" cy="224" r="8" fill="#3b4149"/>
<circle cx="498" cy="261" r="8" fill="#3b4149"/>
<circle cx="546" cy="224" r="8" fill="#3b4149"/>
<circle cx="546" cy="261" r="8" fill="#3b4149"/>
<circle cx="594" cy="224" r="8" fill="#3b4149"/>
<circle cx="594" cy="261" r="8" fill="#3b4149"/>
<circle cx="642" cy="224" r="8" fill="#3b4149"/>
<circle cx="642" cy="261" r="8" fill="#3b4149"/>
<circle cx="690" cy="224" r="8" fill="#3b4149"/>
<circle cx="690" cy="261" r="8" fill="#3b4149"/>
<circle cx="738" cy="224" r="8" fill="#3b4149"/>
<circle cx="738" cy="261" r="8" fill="#3b4149"/>
<circle cx="786" cy="224" r="8" fill="#3b4149"/>
<circle cx="786" cy="261" r="8" fill="#3b4149"/>
<circle cx="834" cy="224" r="8" fill="#3b4149"/>
<circle cx="834" cy="261" r="8" fill="#3b4149"/>
<circle cx="882" cy="224" r="8" fill="#3b4149"/>
<circle cx="882" cy="261" r="8" fill="#3b4149"/>
<circle cx="930" cy="224" r="8" fill="#3b4149"/>
<circle cx="930" cy="261" r="8" fill="#3b4149"/>
<circle cx="978" cy="224" r="8" fill="#3b4149"/>
<circle cx="978" cy="261" r="8" fill="#3b4149"/>
<circle cx="1026" cy="224" r="8" fill="#3b4149"/>
<circle cx="1026" cy="261" r="8" fill="#3b4149"/>
<circle cx="1074" cy="224" r="8" fill="#3b4149"/>
<circle cx="1074" cy="261" r="8" fill="#3b4149"/>
<circle cx="1122" cy="224" r="8" fill="#3b4149"/>
<circle cx="1122" cy="261" r="8" fill="#3b4149"/>
<rect x="190" y="203" width="130" height="76" rx="12" fill="none" stroke="#bb3434" stroke-width="3"/>
<text x="210" y="333" class="small">Hier anfangen: Ende gegenüber den USB-Buchsen</text>
<rect x="1240" y="200" width="160" height="145" rx="12" fill="#d7dce2" stroke="#142233" stroke-width="3"/>
<text x="1320" y="247" class="mid">USB /</text>
<text x="1320" y="291" class="mid">LAN</text>
<rect x="115" y="356" width="140" height="48" rx="12" fill="#d7dce2" stroke="#142233" stroke-width="3"/>
<text x="130" y="388" class="small">USB-C</text>
<text x="45" y="465" class="big">VERGRÖSSERT: die ersten drei Pinpaare</text>
<text x="45" y="516" class="small">Äußere Reihe: direkt am Platinenrand</text>
<text x="45" y="555" class="small">Pins 2 und 4 bleiben frei (5 V).</text>
<text x="45" y="816" class="small">Innere Reihe: zur Platinenmitte</text>
<circle cx="520" cy="580" r="46" fill="#ffffff" stroke="#142233" stroke-width="4"/>
<text x="520" y="596" class="num">2</text>
<text x="520" y="665" class="mid">FREI · 5 V</text>
<circle cx="860" cy="580" r="46" fill="#ffffff" stroke="#142233" stroke-width="4"/>
<text x="860" y="596" class="num">4</text>
<text x="860" y="665" class="mid">FREI · 5 V</text>
<circle cx="1200" cy="580" r="46" fill="#7a7f87" stroke="#142233" stroke-width="4"/>
<text x="1200" y="596" class="num">6</text>
<text x="1200" y="665" class="mid">W04 · GRAU → GND</text>
<circle cx="520" cy="790" r="46" fill="#20885c" stroke="#142233" stroke-width="4"/>
<text x="520" y="806" class="num" style="fill:white">1</text>
<text x="520" y="877" class="mid">W01 · GRÜN → VCC</text>
<circle cx="860" cy="790" r="46" fill="#316dc8" stroke="#142233" stroke-width="4"/>
<text x="860" y="806" class="num" style="fill:white">3</text>
<text x="860" y="877" class="mid">W02 · BLAU → SDA</text>
<circle cx="1200" cy="790" r="46" fill="#80502e" stroke="#142233" stroke-width="4"/>
<text x="1200" y="806" class="num" style="fill:white">5</text>
<text x="1200" y="877" class="mid">W03 · BRAUN → SCL</text>
<text x="45" y="950" class="">W01: 3,3 V W02: GPIO2 / SDA W03: GPIO3 / SCL W04: Masse</text>
<text x="45" y="1008" class="small">Alle Nummern sind physische Pins. Zum Stecken beide Netzteile abziehen.</text>
<text x="45" y="1055" class="small">Schema, nicht maßstäblich · 15.10.2026 · Ergänzung zu VERDRAHTUNG.md</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+4 -1
View File
@@ -10,7 +10,9 @@ erkennt NanoDet lokal Personen und löst beim Überqueren einer einstellbaren
Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und
Aktivierung stehen in [SOFTWARE.md](SOFTWARE.md#personenerkennung-und-linienauslösung).
Das echte Kamera-Livebild ist vom Nutzer bestätigt. Personenerkennung am Pi,
GPIO-Steuerung, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen.
GPIO-Steuerung der Grabaktoren, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen.
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.
## Lokal ohne Hardware starten
@@ -27,6 +29,7 @@ manuelle Start für direkten Heimnetzzugriff steht in [SOFTWARE.md](SOFTWARE.md)
## Dokumentation
- [Verdrahtung von Kamera und Schwenkarm](VERDRAHTUNG.md) · [Anschlusszeichnung](VERDRAHTUNG.svg)
- [Software, Pi-Einrichtung und lokale Prüfungen](SOFTWARE.md)
- [Projektdateien auf den Pi übertragen](UPLOAD.md)
- [Arbeitsplan, Hardwarestand und offene Entscheidungen](PLAN.md)
+151 -3
View File
@@ -2,8 +2,9 @@
Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
**Simulation** des Effektablaufs. Hand, Atemmotor, Lampen und Lautsprecher
bleiben simuliert. Der separate Farbpunkt-Test kann die beiden Kameraservos
am PCA9685 nach bewusstem Start tatsächlich bewegen. Eine lokale SQLite-Datenbank speichert
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
am Pi installiert und kalibriert werden. Bei automatischer Auslösung wird
ein Bild des grünen Bereichs lokal gespeichert. Profilzuordnung per Kamera,
@@ -258,7 +259,7 @@ Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der
bestehenden Datenbank gespeichert, mit lokalem Bild des grünen Bereichs aus
dem auslösenden Frame. Tracking-IDs werden nicht gespeichert; Profil-Wiedererkennung
und Altersentscheidung fehlen.
Aktoren bleiben ausschließlich simuliert.
Die Aktoren des Grabablaufs bleiben simuliert; Kameraservos haben einen eigenen Testmodus.
### Nahbereich testen: Bewegung in Pfeilrichtung
@@ -338,3 +339,150 @@ HALLOWEEN_TEST_MODEL="$PWD/data/models/object_detection_nanodet_2022nov.onnx" .v
Ohne diese Variable wird nur der optionale Modell-Smoke-Test übersprungen;
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
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
vom Tageslichtbild abweichen; unter dem späteren Licht neu auswählen.
### Vorbereitung am Pi
Die Abhängigkeit `smbus2` steht in `requirements.txt` und wird beim normalen
versionierten Upload in die neue Umgebung installiert. Bei manueller
Installation dieselbe Datei in der verwendeten Umgebung installieren.
I²C muss am Pi aktiviert sein: `sudo raspi-config` → **Interface Options →
I2C**. Der Dienstbenutzer benötigt Zugriff auf `/dev/i2c-1` (Gruppe `i2c`).
Fehlende Berechtigung nicht mit einem Webserver als root umgehen.
Der Treiber verwendet Bus 1, Adresse `0x40`, 50 Hz (nominal), PAN=CH0,
TILT=CH1. Die Frequenz gilt für die ganze PCA9685-Platine. Nur eine WebUI-Instanz/ein Worker darf
den Treiber steuern; in diesem Test darf
sie ausschließlich die beiden Kameraservos versorgen. Alle anderen Kanäle
bleiben unbestückt. Verdrahtung siehe [VERDRAHTUNG.md](VERDRAHTUNG.md).
Aktuell laut Nutzer: Aufbau verdrahtet, separate Powerbank als Servoversorgung;
diese hat bereits zwei Servos betrieben. Modell, 5-V-Ausgangsstrom,
Spannungseinbrüche, Leitungsstärke und Dauerbetrieb noch nicht geprüft.
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.
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
Auswertungsbilder bleiben ausschließlich im Arbeitsspeicher; gespeichert
werden nur Farbwert, Toleranz, Richtungen und Pulsgrenzen in `data/follow.json`.
3. **Nur Farbvorschau starten**. Im eigenen Bild markiert ein Kreis den
ausgewerteten Farbpunkt; das Kreuz ist die gewünschte Bildmitte. Dabei
erfolgt keinerlei Zugriff auf den PCA9685. Zu kleine oder großflächige
Farbflächen und zwei ähnlich große Kandidaten ergeben kein eindeutiges Ziel.
4. **Kameraservos stoppen**. Änderungen an Farbe und Grenzen sind nur bei
gestoppter Nachführung erlaubt.
### Erste echte Bewegung
Der Start der echten Nachführung setzt beide Servos sofort auf **1500 µs**.
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
Grenzen 1400–1600 µs sind ein enger Testbereich, keine für die vorhandene
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
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.
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
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
einer Sekunde. Fehler verlangen einen neuen bewussten Start. Einstellungen
bleiben erhalten, aktive Nachführung und Hardwarefreigabe werden nie gespeichert.
Im `--demo`-Betrieb wird echte Servoansteuerung grundsätzlich abgelehnt.
**PWM aus trennt nicht die Versorgung.** Ein festhängender Pi, verlorener
I²C-Bus oder ein harter Prozessabbruch kann die letzte Ausgabe im PCA9685
stehen lassen; es gibt noch keine unabhängige OE-/Hardwareabschaltung.
Servoversorgung bei unerwartetem Verhalten sofort abziehen und diesen
Versuchsmodus nur beaufsichtigt verwenden. Ein Neustart setzt eine vor einem
harten Abbruch verbliebene PWM-Ausgabe nicht verlässlich zurück.
API: `GET /api/follow` liefert Status und synchrones Auswertungsbild.
`POST /api/follow/{config,capture,select,start,heartbeat,stop}` benötigt den
bestehenden UI-Header. Nur die startende Seite erhält den flüchtigen
Heartbeat-Schlüssel. Farbtests erzeugen weder Ereignisse noch Profile.
Lokal geprüft: synthetische Farbbilder, Begrenzung/Richtung, Sitzungsablauf,
Fehlerfälle, API-Sperren und simulierte I²C-Registerzugriffe. Browserprüfung
mit Demo-Kamera: Farbwahl, Vorschau, Stopp und Beispielfoto. Keine Aussage
über echte Servobewegung, Stromreserve oder Nachführqualität am Pi.
Technische Referenzen: [Pillow HSV](https://pillow.readthedocs.io/en/stable/handbook/concepts.html),
[NXP PCA9685](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf),
[smbus2](https://smbus2.readthedocs.io/en/latest/api.html).
## 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-
Betrieb heißt der Knopf **Einrichtmodus als Demo starten** und bewegt nur
virtuelle Werte; es gibt keinen I²C-Zugriff.
- **Links**, **Rechts**, **Hoch** oder **Runter** gedrückt halten. Immer nur
eine Achse wird mit maximal 40 µs/s verstellt. Mit der Tastatur funktioniert
das über fokussierte Richtungstaste plus Leertaste/Enter. Loslassen,
Fokusverlust oder abgebrochene Zeigergeste beendet die Verstellung.
- Vor dem mechanischen Anschlag loslassen, auch links/rechts. Bei fehlendem
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.
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.
Es gibt keine automatische Anschlagfahrt und keine Endschalterauswertung.
Angezeigte Werte sind **Sollwerte**, keine gemessene Position. Ohne Encoder,
Strommessung oder Endschalter kann die Software eine Blockade nicht erkennen.
Der 10-µs-Abstand ist ein kleiner zusätzlicher Puffer, keine mechanische
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
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.
+214
View File
@@ -0,0 +1,214 @@
# Verdrahtung von Kamera und Schwenkarm
Stand: 15. Oktober 2026 · Revision 6 · Anschlussentwurf
Dieser Plan dokumentiert Pi 5, Camera Module 3 NoIR und die beiden
Kameraservos am vorhandenen PCA9685. Er dient später auch dem Wiederaufbau.
Die Kanalnamen sind ab jetzt fest geplant; die tatsächlich gesteckten Kabel
werden erst nach Abgleich mit der Platine als geprüft eingetragen.
**Vor dem Anschließen fehlen noch die Ausgangsdaten des vorgesehenen
Servonetzteils und die Prüfung der Servokabel.** Das PCA9685-Foto liegt jetzt vor. Die Zeichnung zeigt
elektrische Verbindungen, keine maßstäbliche Anordnung der Steckkontakte.
![Anschlussentwurf Kamera und Schwenkarm](VERDRAHTUNG.svg)
## Geräte und feste Kennungen
| Kennung | Bauteil | Stand |
| --- | --- | --- |
| R1 | Raspberry Pi 5 | Vorhanden; eigenes USB-C-Netzteil |
| C1 | Raspberry Pi Camera Module 3 NoIR | Vorhanden; echtes Livebild bestätigt |
| D1 | PCA9685 mit 16 Kanälen | Foto bestätigt PCA9685-Modul mit 16 Kanälen; Hersteller nicht identifiziert |
| S1 | Unterer Servo, links und rechts schwenken, PAN | Nutzer nennt Amazon B07KPS9845 für beide Kameraservos |
| S2 | Oberer Servo, hoch und runter neigen, TILT | Gleicher Nutzerlink; Typenschild/Stecker noch prüfen |
| P1 | Separate Versorgung der Kameraservos | Aktuell laut Nutzer separate Powerbank; Modell/Stromreserve offen. Planungsgröße bleibt 5 V DC / 3 A |
Der Amazon-Link ließ sich nicht direkt auslesen. Deshalb sind exakte
Servovariante, zulässige Spannung, Spitzenstrom und Winkelbereich noch nicht
als Herstellerdaten bestätigt. 5 V und 3 A sind eine vorläufige Auslegung,
keine gemessene Freigabe. Vor Betrieb Typ/Spannung prüfen; beim gleichzeitigen
Anlaufen beider Servos Spannung und Verhalten kontrollieren.
## Foto der vorhandenen Platine
![Vorhandenes PCA9685-Modul](PCA9685_FOTO.jpg)
Foto vom Nutzer, 15. Oktober 2026. Aufdruck: PCA9685, 16 Kanäle,
12-Bit-PWM. Die Logikanschlüsse sind GND, OE, SCL, SDA, VCC und V+
beschriftet. Die Platine hat zwei entsprechende Anschlussseiten; eine davon
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.
## Pi mit dem PCA9685 verbinden
![Große Pi-Pinübersicht mit Kabelfarben](RASPI_PINS.svg)
Den Pi mit Bauteilseite nach oben legen, USB- und Netzwerkbuchsen nach rechts.
Die 40-polige Leiste liegt dann oben. Am linken Ende sind außen am
Platinenrand die Pins 2, 4, 6; direkt darunter zur Platinenmitte 1, 3, 5.
W01 Grün an 1, W02 Blau an 3, W03 Braun an 5, W04 Grau an 6.
Pins 2 und 4 bleiben frei. Zum Stecken beide Netzteile abziehen.
**Die Pinnummern sind physische Positionen auf der 40-poligen Pi-Stiftleiste.**
GPIO2 und GPIO3 sind zusätzliche Signalnamen, keine Steckplatznummern.
Pin 1 am echten Board beziehungsweise am offiziellen Pinbild identifizieren;
nicht anhand der Blickrichtung eines fremden Fotos zählen.
| Kabelkennung | Kabelfarbe | Von R1 | Nach D1 | Funktion |
| --- | --- | --- | --- | --- |
| W01 | Grün | Pin 1, 3V3 | VCC | 3,3-V-Logikversorgung |
| W02 | Blau | Pin 3, GPIO2 / SDA1 | SDA | Steuerdaten |
| W03 | Braun | Pin 5, GPIO3 / SCL1 | SCL | Steuertakt |
| W04 | Grau | Pin 6, GND | GND | Gemeinsame Signalmasse |
VCC und V+ sind verschiedene Anschlüsse. D1-VCC bekommt ausschließlich
3,3 V vom Pi. Pi-Pin 2 und Pin 4 (5 V) bleiben für diesen Aufbau frei.
Die Versorgung der Servos wird nicht mit dem Plus der Pi-Versorgung verbunden.
## Servoenergie und Stecker
| Kabelkennung | Von | Nach | Funktion |
| --- | --- | --- | --- |
| W05 | P1, DC-Plus | D1, V+ der Versorgungsklemme | Servoenergie, geplant 5 V |
| W06 | P1, DC-Minus | D1, GND der Versorgungsklemme | Laststrom-Rückleitung |
| W07 | D1, Kanal 0: PWM / Signal | S1: Signal | PAN |
| W08 | D1, Kanal 0: V+ | S1: Plus | PAN-Versorgung |
| W09 | D1, Kanal 0: GND | S1: Minus / GND | PAN-Rückleitung |
| W10 | D1, Kanal 1: PWM / Signal | S2: Signal | TILT |
| W11 | D1, Kanal 1: V+ | S2: Plus | TILT-Versorgung |
| W12 | D1, Kanal 1: GND | S2: Minus / GND | TILT-Rückleitung |
W07–W09 beziehungsweise W10–W12 gehören jeweils zum dreipoligen Servokabel.
Typische Farben sind Braun/Schwarz für GND, Rot für Plus und Orange/Gelb/Weiß
für Signal. Die Reihen am fotografierten Modul sind beschriftet:
**Gelb = PWM (Signal), Rot = V+ (Servo-Plus), Schwarz = GND (Minus).**
Die Zuordnung der Servokabel selbst noch prüfen; nicht allein nach Farben stecken.
Die Kanalnummern 0 und 1 sind auf dem Foto nicht sicher lesbar: Position vor
dem Stecken direkt an der Platine abgleichen.
Bei der üblichen PCA9685-Servoplatine sind die GND-Anschlüsse und die
V+-Anschlüsse der Kanäle jeweils verbunden. Das ist an D1 noch zu prüfen.
P1-Minus und Pi-GND sind damit gemeinsam verbunden; der Servolaststrom
fließt über W06 zum Netzteil und nicht über das dünne W04 zum Pi zurück.
Für W05/W06 geeignete Stromleitungen und feste Klemmen verwenden, keine
lose gesteckten dünnen Steuer-Jumper oder ein Steckbrett als Lastverteilung.
Ein Schalter in der geeigneten DC-Zuleitung kann die Servoversorgung lokal
abschalten; Auswahl und Belastbarkeit richten sich nach P1 und Leitungen.
**Die spätere 6-V-Versorgung des Handservos nicht auf diese V+-Schiene
legen.** Die Kanal-Pluspins sind keine unabhängig einstellbaren Spannungen.
Der Handservo benötigt bei unterschiedlicher Spannung eine getrennte
Lastversorgung und eine gesonderte Anschlussplanung.
## Kamerakabel
| Kabelkennung | Verbindung | Beim Wiederaufbau beachten |
| --- | --- | --- |
| W13 | R1 CAM/DISP-Anschluss ↔ C1 CSI-Anschluss | Vorhandenes Standard-auf-Mini-Kabel; benutzten Pi-Port und Kontaktseiten noch per Foto festhalten |
Pi und Servoversorgung vor dem Lösen oder Einsetzen des Kamerakabels
ausschalten und abstecken. Am Pi 5 gibt es zwei CAM/DISP-Anschlüsse;
hier ist noch keiner als tatsächlicher Anschluss dokumentiert.
Die Schleife muss die ganze erlaubte Schwenk- und Neigebewegung mitmachen,
ohne Zug, scharfe Knicke oder Kontakt mit dem Servogestänge.
## Erstprüfung und Betrieb
1. Beide Versorgungen abstecken. Anschlüsse, Polung und Kabelkennungen
mit dem geprüften Boardfoto vergleichen. D1 isoliert befestigen.
2. Servos zunächst abstecken. Ausgangsspannung und Polarität von P1 messen;
VCC und V+ auf Verwechslung prüfen.
3. Pi mit eigener Versorgung starten, P1 bleibt aus. I²C muss später aktiviert
und D1 erkannt werden. Adresse 0x40 ist beim Referenzmodul der Standard,
am vorhandenen Board jedoch noch zu bestätigen.
4. Erst mit einem vorbereiteten Servotest einen Servo ohne angekoppeltes
Gestänge zentrieren. Nicht sofort den ganzen beworbenen Winkel abfahren.
Zweiten Servo getrennt prüfen, dann die Halterung vorsichtig montieren.
5. Erlaubte Winkel und Kabelbewegung für PAN/TILT einzeln bestimmen und unten
eintragen. Schließlich beide zusammen auf Versorgungseinbrüche testen.
6. Vor Umstecken, Pi-Neustart oder Softwarewechsel P1 ausschalten. Bei
Brummen, Anschlag oder Kabelzug sofort P1 abschalten.
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.
Der WebUI-Stopp und der eigene Kamerastopp beenden nun 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,
aber nicht an der tatsächlichen Halterung.
OE (Ausgangsfreigabe) ist noch nicht angeschlossen. Es gibt keine unabhängige
Hardwareabschaltung; bei I²C-Ausfall oder hartem Prozess-/Pi-Abbruch kann der
letzte Puls stehen bleiben. Vor Neustarts und bei unerwartetem Verhalten
Servoversorgung abziehen. Beim Adafruit-Referenzboard aktiviert OE=Low die
Ausgänge, OE=High sperrt sie; dessen interne Beschaltung nicht ungeprüft auf
D1 übertragen. Nur beaufsichtigter Test.
## Endpunkte ohne Endschalter einrichten
Die WebUI bietet jetzt einen manuellen Einrichtmodus für S1/S2. Langsam mit
gehaltener Richtungstaste annähern, vor dem Anschlag loslassen, Grenze speichern.
Auch PAN nicht gegen den Anschlag drücken. Loslassen hält die Sollposition,
der Kamerastopp beendet PWM. 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
Aufbauprotokoll festhalten. Keine zusätzlichen GPIOs/Endschalter angeschlossen.
## Noch nicht anschlussfertige Erweiterungen
Handservo, Atemmotor/Relais, LEDs, IR-Beleuchtung und ein möglicher zweiter Pi
erhalten hier eigene Anschlusslisten, sobald konkrete Geräte und Versorgung
feststehen. Für diese Teile sind noch keine Pins oder Kanäle vergeben.
Die 230-V-Seite der Atemrotation bleibt der Elektrofachkraft vorbehalten.
Dieser Kameraentwurf ist kein vollständiger Verdrahtungsplan dieser Aktoren.
## Protokoll für den tatsächlichen Aufbau
| Prüfung | Ergebnis |
| --- | --- |
| D1 Hersteller / Platinenrevision / Foto | PCA9685-Foto hinterlegt; Hersteller und genaue Revision offen |
| D1 Reihenfolge Signal / V+ / GND | Foto: gelbe Reihe PWM, rote Reihe V+, schwarze Reihe GND |
| S1/S2 Beschriftung und Kabelfarben | Offen |
| P1 Modell, Ausgangsspannung, Strom, Steckerpolung | Offen |
| I²C-Adresse | Offen |
| R1 verwendeter Kameraport und Kontaktseitenfoto | Offen |
| PAN Mittelstellung / erlaubter Bereich / Richtung | Offen |
| TILT Mittelstellung / erlaubter Bereich / Richtung | Offen |
| Versorgung bei gleichzeitiger Bewegung geprüft | Offen |
| Start, Fehler und lokale Abschaltung geprüft | Offen |
Kabel an beiden Enden mit W01 usw. und Servostecker zusätzlich mit
„PAN / CH0“ beziehungsweise „TILT / CH1“ beschriften. Nach Änderungen
Anschlussliste, Zeichnung, Prüftabelle und Änderungsverlauf gemeinsam pflegen.
Ein Foto des fertig gesteckten Aufbaus ergänzt den Plan; es ersetzt die
Anschlussliste nicht.
## Quellen
- [Vom Nutzer genannte Kameraservos, Amazon B07KPS9845](https://www.amazon.de/dp/B07KPS9845)
- [Adafruit PCA9685: Pi-Anbindung und separate Servoversorgung](https://learn.adafruit.com/16-channel-pwm-servo-driver/python-circuitpython)
- [Adafruit PCA9685: VCC, V+, OE und Kanäle](https://learn.adafruit.com/16-channel-pwm-servo-driver/pinouts)
- [Raspberry Pi: GPIO-Dokumentation und Pinbild](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio)
Die Adafruit-Unterlagen sind die elektrische Referenz, keine Identifikation
der vorhandenen D1-Platine. Deren sichtbare Anschlussbeschriftung ist jetzt
zusätzlich durch das oben abgelegte Nutzerfoto dokumentiert.
## Änderungen
| Datum | Revision | Änderung |
| --- | --- | --- |
| 15.10.2026 | 1 | Kameraentwurf angelegt; PAN=CH0, TILT=CH1 geplant; Board, Versorgung und echte Verdrahtung offen |
| 15.10.2026 | 2 | Echtes Boardfoto abgelegt; Anschlussbeschriftung und gelbe/rote/schwarze Reihen erfasst; Netzteil und praktische Prüfung bleiben offen |
| 15.10.2026 | 3 | W01 Grün, W02 Blau, W03 Braun, W04 Grau nach Nutzerkabeln in Liste und Zeichnung eingetragen |
| 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 |
+67
View File
@@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
<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>
<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>
<text x="60" y="272" class="">Pin 3 · GPIO2 / SDA1</text>
<text x="60" y="332" class="">Pin 5 · GPIO3 / SCL1</text>
<text x="60" y="392" class="">Pin 6 · GND</text>
<text x="60" y="447" class="small">Eigenes USB-C-Netzteil</text>
<text x="60" y="475" class="small">Pinzahlen = physische Positionen</text>
<rect x="650" y="140" width="300" height="620" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="670" y="176" class="head">D1 · PCA9685</text>
<polyline class="wire" points="370,206 650,206" stroke="#20885c"/>
<text x="440" y="196" class="">W01 · Grün</text>
<text x="670" y="212" class="">VCC · 3,3 V</text>
<polyline class="wire" points="370,266 650,266" stroke="#316dc8"/>
<text x="440" y="256" class="">W02 · Blau</text>
<text x="670" y="272" class="">SDA</text>
<polyline class="wire" points="370,326 650,326" stroke="#80502e"/>
<text x="440" y="316" class="">W03 · Braun</text>
<text x="670" y="332" class="">SCL</text>
<polyline class="wire" points="370,386 650,386" stroke="#7a7f87"/>
<text x="440" y="376" class="">W04 · Grau</text>
<text x="670" y="392" class="">GND</text>
<text x="670" y="445" class="">CH0 · PAN</text>
<text x="670" y="485" class="">CH1 · TILT</text>
<text x="670" y="540" class="small">OE · Beschaltung noch offen</text>
<text x="670" y="573" class="small">Keine unabhängige Abschaltung</text>
<text x="670" y="650" class="">V+ · Servoenergie</text>
<text x="670" y="710" class="">GND · Rückleitung</text>
<rect x="1080" y="140" width="280" height="180" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="1100" y="176" class="head">S1 · PAN</text>
<text x="1100" y="210" class="">Unterer Servo</text>
<text x="1100" y="246" class="">Links / rechts</text>
<text x="1100" y="286" class="small">Stecker: PAN / CH0</text>
<rect x="1080" y="370" width="280" height="180" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="1100" y="406" class="head">S2 · TILT</text>
<text x="1100" y="440" class="">Oberer Servo</text>
<text x="1100" y="476" class="">Hoch / runter</text>
<text x="1100" y="516" class="small">Stecker: TILT / CH1</text>
<polyline class="wire" points="950,440 990,440 990,230 1080,230" stroke="#7b4ba7"/>
<text x="968" y="186" class="small">W07–09</text>
<polyline class="wire" points="950,480 1025,480 1025,460 1080,460" stroke="#7b4ba7"/>
<text x="968" y="576" class="small">W10–12</text>
<text x="1080" y="617" class="small">D1 laut Foto:</text>
<text x="1080" y="646" class="small">Gelbe Reihe: PWM → Signal</text>
<text x="1080" y="675" class="small">Rote Reihe: V+ → Plus</text>
<text x="1080" y="704" class="small">Schwarze Reihe: GND → Minus</text>
<rect x="40" y="550" width="330" height="210" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="60" y="586" class="head">P1 · Servonetzteil</text>
<text x="60" y="621" class="">Vorschlag: 5 V DC / 3 A</text>
<text x="60" y="650" class="small">Auslegung noch prüfen</text>
<text x="60" y="693" class="">DC-Plus</text>
<text x="60" y="736" class="">DC-Minus / GND</text>
<polyline class="wire" points="370,687 490,687 490,644 650,644" stroke="#c13b35"/>
<text x="510" y="630" class="small">W05 · Plus</text>
<polyline class="wire" points="370,730 560,730 560,704 650,704" stroke="#333333"/>
<text x="405" y="753" class="small">W06 · Minus</text>
<rect x="40" y="800" width="1320" height="153" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
<text x="60" y="836" class="head">C1 · Camera Module 3 NoIR</text>
<text x="60" y="868" class="">W13: Pi CAM/DISP ↔ Kamerakabel ↔ Kamera · Tatsächlichen Pi-Port und Kontaktseiten noch eintragen</text>
<text x="60" y="904" class="small">VCC ≠ V+ | Servostrom separat | Gemeinsame Masse | Keine 6 V vom Handservo auf diese V+-Schiene</text>
<text x="40" y="982" class="small">Symbolplan: keine räumliche Pinanordnung. Steckerreihenfolge und Erstprüfung stehen in VERDRAHTUNG.md.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+380
View File
@@ -0,0 +1,380 @@
"""Local colour-marker preview and deliberately armed camera servo test."""
from collections import deque
from io import BytesIO
from pathlib import Path
import base64
import json
import math
import os
import secrets
import tempfile
import threading
import time
from PIL import Image
DEFAULT = dict(hue=85, tolerance=18, pan_min=1400, pan_max=1600,
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False)
def validate(value):
if set(value) != set(DEFAULT):
raise ValueError('Unvollständige Nachführungseinstellungen')
result = {}
for key, default in DEFAULT.items():
v = value[key]
if isinstance(default, bool):
if type(v) is not bool:
raise ValueError(f'{key}: Ja/Nein erwartet')
else:
if isinstance(v, bool) or not isinstance(v, (float, int)) or not math.isfinite(v):
raise ValueError(f'{key}: gültige Zahl erwartet')
v = int(v)
result[key] = v
if not 0 <= result['hue'] <= 255 or not 5 <= result['tolerance'] <= 35:
raise ValueError('Ungültige Farbe oder Farbtoleranz')
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')
return result
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)
blobs = []
while mask:
seed = mask.pop()
queue, blob = deque([seed]), [seed]
while queue:
i = queue.popleft()
x, y = i % 160, i // 160
for n in ((i-1 if x else -1), (i+1 if x < 159 else -1),
(i-160 if y else -1), (i+160 if y < 119 else -1)):
if n in mask:
mask.remove(n); queue.append(n); blob.append(n)
if len(blob) >= 12:
blobs.append(blob)
blobs.sort(key=len, reverse=True)
if not blobs or len(blobs[0]) > 160*120*.35:
return None
if len(blobs) > 1 and len(blobs[1]) >= len(blobs[0])*.6:
return None
blob = blobs[0]
return {'x': sum(i % 160 for i in blob)/len(blob)/160,
'y': sum(i // 160 for i in blob)/len(blob)/120,
'radius': math.sqrt(len(blob)/math.pi)/160}
class PCA9685:
"""Dedicated camera board, bus 1/address 0x40, channels 0 and 1 only.
Register protocol: https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf
No I2C access until an explicit hardware start. No bus scanning/reset broadcast.
"""
def __init__(self, bus_factory=None):
if bus_factory is None:
from smbus2 import SMBus
bus_factory = SMBus
self.bus = bus_factory(1)
try:
self.off()
# Sleep, internal oscillator, auto-increment. 25 MHz / (4096*122).
self.bus.write_byte_data(0x40, 0x00, 0x30)
self.bus.write_byte_data(0x40, 0xFE, 121)
self.bus.write_byte_data(0x40, 0x01, 0x04) # non-inverted totem-pole
self.bus.write_byte_data(0x40, 0x00, 0x20)
time.sleep(.005)
except Exception:
self.close()
raise
def move(self, pan, tilt):
for channel, pulse in enumerate((pan, tilt)):
if not 1100 <= pulse <= 1900:
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,
[0, 0, count & 255, count >> 8])
def off(self):
failure = None
# Do not use a block write before auto-increment has been initialized.
for channel in (0, 1):
try:
self.bus.write_byte_data(0x40, 0x09+channel*4, 0x10)
except Exception as exc:
failure = exc
if failure:
raise failure
def close(self):
try:
self.off()
finally:
self.bus.close()
class FollowService:
def __init__(self, camera, directory, driver_factory=PCA9685, clock=time.monotonic):
self.camera, self.clock, self.factory = camera, clock, driver_factory
self.lock = threading.RLock()
self.path = Path(directory)/'follow.json'
self.config = dict(DEFAULT)
self.error = None
self.config_fault = False
try:
if self.path.exists():
self.config = validate(json.loads(self.path.read_text()))
except (OSError, ValueError, TypeError):
self.error = 'Nachführungseinstellungen beschädigt; neu speichern'
self.config_fault = True
self.active = False
self.operation = 'follow'
self.jog_direction = None
self.jog_until = 0.
self.jog_sequence = -1
self.driver = None
self.pan = self.tilt = 1500.
self.last_ping = self.clock()
self.last_result = self.last_seen = None
self.number = 0
self.target = None
self.jpeg = None
self.sample = None
self.sample_token = None
self.session = None
self.revision = 0
self.thread = None
self.quit = threading.Event()
def _save(self, value):
config = validate(value)
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=self.path.parent, prefix='.follow-')
try:
with os.fdopen(fd, 'w') as f:
json.dump(config, f)
os.replace(name, self.path)
finally:
if os.path.exists(name): os.unlink(name)
self.config, self.error, self.config_fault = config, None, False
self.revision += 1
return dict(config)
def configure(self, value):
with self.lock:
if self.active:
raise ValueError('Vor Einstellungsänderungen Nachführung stoppen')
return self._save(value)
def _direction(self, direction):
choices = {'left': ('pan', -1), 'right': ('pan', 1),
'up': ('tilt', -1), 'down': ('tilt', 1)}
if direction not in choices:
raise ValueError('Ungültige Richtung')
axis, sign = choices[direction]
return axis, -sign if self.config[axis+'_reverse'] else sign
def _require_setup(self, session):
self.watchdog()
if not self.active or self.operation != 'setup' or session != self.session:
raise ValueError('Einrichtsitzung beendet oder nicht freigegeben')
def jog(self, session, direction, sequence):
with self.lock:
self._require_setup(session)
if type(sequence) is not int or not 0 <= sequence <= 1_000_000_000:
raise ValueError('Ungültige Tastensequenz')
if direction is not None: self._direction(direction)
if sequence <= self.jog_sequence:
return {'accepted': False}
self.jog_sequence = sequence
self.jog_direction = direction
self.jog_until = self.clock()+.35 if direction else 0.
return {'accepted': True}
def remember(self, session, direction):
with self.lock:
self._require_setup(session)
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')
return self._save(dict(self.config, **{key:value}))
def capture(self):
with self.lock:
if self.active:
raise ValueError('Vor dem Farbfoto Nachführung stoppen')
_, jpeg, captured = self.camera.latest_timed()
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
raise ValueError('Kein frisches Kamerabild')
self.sample, self.sample_token = jpeg, secrets.token_hex(16)
return {'image_base64': base64.b64encode(jpeg).decode(), 'token': self.sample_token}
def select(self, token, x, y):
with self.lock:
if not self.sample or token != self.sample_token:
raise ValueError('Beispielfoto erneut aufnehmen')
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) and
math.isfinite(v) and 0 <= v <= 1 for v in (x, y)):
raise ValueError('Punkt außerhalb des Bildes')
image = Image.open(BytesIO(self.sample)).convert('HSV')
px, py = int(x*(image.width-1)), int(y*(image.height-1))
colors = list(image.crop((max(0,px-2), max(0,py-2),
min(image.width,px+3), min(image.height,py+3))).getdata())
colors = [(h,s,v) for h,s,v in colors if s >= 100 and v >= 60]
if len(colors) < 5:
raise ValueError('Bitte einen hellen, kräftig farbigen Punkt wählen')
# Circular mean avoids averaging red at both ends into cyan.
angle = math.atan2(sum(math.sin(h*2*math.pi/256) for h,_,_ in colors),
sum(math.cos(h*2*math.pi/256) for h,_,_ in colors))
config = dict(self.config, hue=round(angle*256/(2*math.pi)) % 256)
return self.configure(config)
def begin(self, hardware=False, acknowledged=False, operation='follow'):
with self.lock:
if operation not in ('follow', 'setup'):
raise ValueError('Unbekannter Kameramodus')
if self.active:
raise ValueError('Kameramodus läuft bereits')
if self.config_fault:
raise ValueError(self.error)
self.error = None
_, jpeg, captured = self.camera.latest_timed()
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
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.
try:
if hardware:
self.driver = self.factory()
self.driver.move(self.pan, self.tilt)
except Exception as exc:
self.halt(f'Servostart fehlgeschlagen: {exc}')
raise ValueError(self.error) from exc
self.active = True
self.operation = operation
self.jog_direction, self.jog_until, self.jog_sequence = None, 0., -1
self.session = secrets.token_hex(16)
self.last_ping = self.clock()
self.last_result = self.last_seen = self.clock()
self.jpeg = None
self.target = None
self.revision += 1
return self.session
def heartbeat(self, session):
with self.lock:
if not self.active or session != self.session:
raise ValueError('Nachführungssitzung beendet')
self.last_ping = self.clock()
def halt(self, error=None):
with self.lock:
self.active = False
self.session = None
self.jog_direction = None
self.jog_until = 0.
self.revision += 1
self.target = None
if self.driver:
driver, self.driver = self.driver, None
try:
driver.close()
except Exception as exc:
error = f'PWM-Abschaltung nicht bestätigt: {exc}; Servoversorgung ausschalten'
if error: self.error = error
def watchdog(self):
with self.lock:
if not self.active: return
now = self.clock()
_, _, captured = self.camera.latest_timed()
if self.camera.error or captured is None or now-captured > 1:
self.halt('Kamerabild ausgefallen; erneut starten')
elif now-self.last_ping > 1.5:
self.halt('Bedienverbindung verloren; erneut starten')
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):
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)))
self.last_result, self.number, self.jpeg, self.target = now, number, jpeg, target
if self.operation == 'setup':
self.target = None
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))
setattr(self,axis,value)
if self.driver: self.driver.move(self.pan,self.tilt)
else:
self.jog_direction = None
return
if 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
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')
def status(self):
with self.lock:
return {'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),
'target': self.target, 'frame_number': self.number,
'image_base64': base64.b64encode(self.jpeg).decode() if self.active and self.jpeg else None}
def start(self):
self.thread = threading.Thread(target=self._run, daemon=True, name='colour-follow')
self.thread.start()
def _run(self):
last = 0
while not self.quit.wait(.05):
self.watchdog()
with self.lock:
if not self.active: continue
revision, config, operation = self.revision, dict(self.config), self.operation
number, jpeg, captured = self.camera.latest_timed()
if number == last or jpeg is None: continue
last = number
try:
target = locate(jpeg, config) if operation == 'follow' else None
self.process(number, jpeg, captured, revision, target)
except Exception as exc:
with self.lock:
if revision == self.revision: self.halt(f'Nachführungsfehler: {exc}')
def stop(self):
self.quit.set()
self.halt()
if self.thread: self.thread.join(timeout=2)
+222 -1
View File
@@ -91,7 +91,7 @@
</select>
<button id="save-mode">Übernehmen</button>
</div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. 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 können im separaten Farbtest wirklich bewegt werden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
<div class="row">
<button id="zone-clear">Bereich frei (manueller Test)</button>
@@ -103,6 +103,63 @@
</div>
<p id="message" role="status" aria-live="polite"></p>
</section>
<section class="events" id="follow-panel">
<h2>Kamera folgt einem Farbpunkt · Testmodus</h2>
<p>Halte einen kräftig roten oder grünen Aufkleber vor die Kamera. Ein Punkt am Finger geht auch. Kein Training und kein Internet nötig. Zuerst die automatische Auslösung ausschalten und speichern.</p>
<div class="row">
<button id="follow-green">Grün wählen</button><button id="follow-red">Rot wählen</button>
<button id="follow-capture">Beispielfoto aufnehmen</button>
</div>
<p>Eigene Farbe: Beispielfoto aufnehmen und dort auf die Mitte des Farbpunkts tippen. Das Foto wird nicht gespeichert.</p>
<canvas id="follow-canvas" width="640" height="480" style="width:100%;max-width:720px;background:#050808;cursor:crosshair" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
<details>
<summary>Servogrenzen und Richtung einstellen</summary>
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt beide Servos auf 1500 µs; dabei kann die Halterung springen. Beim ersten Test Servohörner/Gestänge lösen, Mittelstellung prüfen und danach spannungsfrei wieder montieren. Grenzen nur schrittweise erweitern.</p>
<div class="row">
<label>PAN Minimum <input id="follow-pan-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
<label>PAN Maximum <input id="follow-pan-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
</div>
<div class="row" style="margin-top:.6rem">
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
<label><input id="follow-tilt-reverse" type="checkbox"> TILT umkehren</label>
</div>
<label>Farbtoleranz <input id="follow-tolerance" type="number" min="5" max="35" value="18" style="width:6em"></label>
<button id="follow-save">Einstellungen speichern</button>
</details>
<p><label><input id="follow-ack" type="checkbox"> Mittelstellung und Bewegungsraum geprüft, Kamerakabel hat Spiel; Servoversorgung kann ich sofort abziehen.</label></p>
<div class="row">
<button id="follow-preview">Nur Farbvorschau starten</button>
<button id="follow-hardware">Echte Kameranachführung starten</button>
<button id="follow-stop" class="danger">Kameraservos stoppen</button>
</div>
<div style="border:2px solid #759c8f;border-radius:8px;padding:1rem;margin-top:1rem">
<h2>Endpunkte einrichten · manuell</h2>
<p>Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.</p>
<button id="setup-start">Einrichtmodus starten</button>
<p id="setup-note" role="status">Noch keine Grenze in dieser Sitzung gespeichert.</p>
<p id="setup-limits" class="muted"></p>
<p id="setup-position" class="value" role="status">Sollposition: PAN 1500 / TILT 1500 µs</p>
<div style="display:grid;grid-template-columns:repeat(3,minmax(80px,150px));gap:.7rem;max-width:480px">
<span></span><button id="setup-up" style="font-size:1.3rem;touch-action:none;user-select:none">▲ Hoch halten</button><span></span>
<button id="setup-left" style="font-size:1.3rem;touch-action:none;user-select:none">◀ Links halten</button>
<button id="setup-release" class="danger">Bewegung anhalten</button>
<button id="setup-right" style="font-size:1.3rem;touch-action:none;user-select:none">Rechts halten ▶</button>
<span></span><button id="setup-down" style="font-size:1.3rem;touch-action:none;user-select:none">▼ Runter halten</button><span></span>
</div>
<p>Taste gedrückt halten: langsame Bewegung. Loslassen: Zielposition bleibt stehen. Bei fehlendem Tastensignal endet die Bewegung spätestens nach 0,35 s, sofern Pi und Treiber reagieren.</p>
<div class="row">
<button id="setup-save-left">Links-Grenze speichern</button>
<button id="setup-save-right">Rechts-Grenze speichern</button>
<button id="setup-save-up">Oben-Grenze speichern</button>
<button id="setup-save-down">Unten-Grenze speichern</button>
</div>
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gelten 1100–1900 µs, auch außerhalb der bisherigen Farbtest-Grenzen. Falls das nicht reicht: stoppen, nichts erzwingen. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet wieder bei 1500 µs.</p>
</div>
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Bei Punktverlust keine Suchbewegung; nach einer Sekunde wird die Servositzung beendet. Bei verlorener Bedienverbindung oder Kamerafehler werden die PWM-Signale abgeschaltet, soweit die Verbindung zum Treiber funktioniert. Die Stromversorgung bleibt eingeschaltet. Bei einem Hardwarefehler Servoversorgung abziehen.</p>
</section>
<section class="events">
<h2>Gespeicherte Ereignisse</h2>
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
@@ -403,6 +460,170 @@
detectionConfig = c; $('detection-enabled').checked = c.enabled;
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; });
// Camera marker test has its own frame/overlay, never the trigger geometry.
let followConfig = null, followSession = null, followSample = null, followBusy = false;
let followEpoch = 0;
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
function fillFollow(c) {
followConfig = c;
for (const axis of ['pan','tilt']) {
$( `follow-${axis}-min`).value = c[axis+'_min'];
$( `follow-${axis}-max`).value = c[axis+'_max'];
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
}
$('follow-tolerance').value = c.tolerance;
}
async function showFollowImage(b64, target, epoch) {
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
await image.decode();
if (epoch !== followEpoch) return;
followCtx.drawImage(image,0,0,640,480);
followCtx.strokeStyle = '#fff'; followCtx.lineWidth = 2;
followCtx.beginPath(); followCtx.moveTo(305,240); followCtx.lineTo(335,240);
followCtx.moveTo(320,225); followCtx.lineTo(320,255); followCtx.stroke();
if (target) {
followCtx.strokeStyle = '#ffca78'; followCtx.lineWidth = 5;
followCtx.beginPath(); followCtx.arc(target.x*640,target.y*480,Math.max(15,target.radius*640),0,Math.PI*2); followCtx.stroke();
}
}
async function followAction(fn) {
if (followBusy) return;
followBusy = true;
try { await fn(); }
catch (e) { $('follow-status').textContent = e.message; }
finally { followBusy = false; }
}
async function saveFollow() {
const c = {...followConfig};
for (const axis of ['pan','tilt']) {
c[axis+'_min'] = Number($(`follow-${axis}-min`).value);
c[axis+'_max'] = Number($(`follow-${axis}-max`).value);
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
}
c.tolerance = Number($('follow-tolerance').value);
fillFollow(await api('/api/follow/config',c));
}
for (const [id,hue] of [['follow-green',85],['follow-red',0]]) {
$(id).onclick = () => followAction(async () => {
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
fillFollow(await api('/api/follow/config',{...followConfig,hue}));
$('follow-status').textContent = 'Farbe gespeichert. Vorschau starten.';
});
}
$('follow-save').onclick = () => followAction(async () => {
await saveFollow(); $('follow-status').textContent = 'Grenzen und Richtung gespeichert';
});
$('follow-capture').onclick = () => followAction(async () => {
const photo = await api('/api/follow/capture');
followSample = photo.token; followEpoch++;
await showFollowImage(photo.image_base64,null,followEpoch);
$('follow-status').textContent = 'Standbild: Jetzt auf die Mitte des Farbpunkts tippen.';
});
followCanvas.onclick = e => followAction(async () => {
if (!followSample) return;
const rect = followCanvas.getBoundingClientRect();
const config = await api('/api/follow/select',{token:followSample,
x:(e.clientX-rect.left)/rect.width,y:(e.clientY-rect.top)/rect.height});
fillFollow(config); $('follow-status').textContent = 'Eigene Farbe übernommen. Vorschau starten.';
});
async function beginFollow(hardware, operation = 'follow') {
if (hardware && !$('follow-ack').checked) throw new Error('Bitte zuerst Mittelstellung und freien Bewegungsraum prüfen und bestätigen.');
await saveFollow();
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
releaseJog(); jogSequence = 0; followSession = result.session; followSample = null; followEpoch++;
$('follow-status').textContent = hardware ? 'Echte Servos aktiv' : 'Farbvorschau ohne Servobewegung';
}
$('follow-preview').onclick = () => followAction(() => beginFollow(false));
$('follow-hardware').onclick = () => followAction(() => beginFollow(true));
$('follow-stop').onclick = async () => {
releaseJog(); setupReady = false; followSession = null; followEpoch++;
try { await api('/api/follow/stop'); $('follow-status').textContent = 'Kameraservos gestoppt'; }
catch(e) { $('follow-status').textContent = 'Stopp nicht bestätigt – Servoversorgung abziehen. '+e.message; }
};
document.addEventListener('visibilitychange', () => {
if (document.hidden && followSession) $('follow-stop').click();
});
// Hold-to-jog: separate short motion lease; sequence numbers reject late requests.
let jogTimer = null, jogSequence = 0, setupReady = false, followDemo = false;
async function sendJog(direction) {
if (!followSession) return;
return api('/api/follow/jog',{session:followSession,direction,sequence:++jogSequence});
}
function releaseJog() {
if (jogTimer === null) return;
clearInterval(jogTimer); jogTimer = null;
sendJog(null).catch(e => { $('follow-status').textContent = 'Tastenstopp nicht bestätigt: '+e.message; });
}
function pressJog(direction) {
if (!setupReady || !followSession || jogTimer !== null) return;
const send = () => sendJog(direction).catch(e => {
releaseJog(); $('follow-status').textContent = e.message;
});
jogTimer = setInterval(send,100); send();
}
for (const direction of ['left','right','up','down']) {
const button = $('setup-'+direction);
button.disabled = true;
button.onpointerdown = e => {
if (e.button !== 0) return;
e.preventDefault(); button.setPointerCapture(e.pointerId); pressJog(direction);
};
button.onpointerup = button.onpointercancel = button.onlostpointercapture = releaseJog;
button.onkeydown = e => {
if ([' ','Enter'].includes(e.key)) { e.preventDefault(); if (!e.repeat) pressJog(direction); }
};
button.onkeyup = e => { if ([' ','Enter'].includes(e.key)) { e.preventDefault(); releaseJog(); } };
button.onblur = releaseJog;
$('setup-save-'+direction).disabled = true;
$('setup-save-'+direction).onclick = () => followAction(async () => {
releaseJog();
await sendJog(null);
fillFollow(await api('/api/follow/remember',{session:followSession,direction}));
$('setup-note').textContent = 'Grenze gespeichert – mit 10 µs Abstand nach innen.';
});
}
$('setup-release').onclick = releaseJog;
document.addEventListener('pointerup',releaseJog);
document.addEventListener('pointercancel',releaseJog);
window.addEventListener('blur',releaseJog);
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'));
async function pollFollow() {
try {
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
const r = await fetch('/api/follow',{cache:'no-store'});
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
const f = await r.json();
followDemo = f.demo;
$('setup-start').textContent = followDemo ? 'Einrichtmodus als Demo starten' : 'Einrichtmodus starten';
setupReady = f.active && f.operation === 'setup' && !!followSession;
if (!setupReady) releaseJog();
for (const direction of ['left','right','up','down']) {
$('setup-'+direction).disabled = !setupReady;
$('setup-save-'+direction).disabled = !setupReady;
}
$('setup-limits').textContent = 'Gespeicherte Grenzen: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs';
$('setup-position').textContent = 'Sollposition: PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
if (!followConfig) {
fillFollow(f.config);
$('follow-status').textContent = 'Bereit. Farbe wählen oder Beispielfoto aufnehmen.';
}
if (!f.active) followSession = null;
if (f.active || f.error) $('follow-status').textContent = f.error ||
(f.operation === 'setup' ? (f.hardware ? 'Einrichtmodus · echte Servos' : 'Einrichtmodus · nur Demo')
: (f.hardware ? 'Echte Servos aktiv' : 'Vorschau ohne Bewegung'))+' · '+
(f.operation === 'setup' ? 'Richtungstaste gedrückt halten' : (f.target ? 'Punkt erkannt' : 'Kein eindeutiger Farbpunkt'))+' · PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
if (f.active && f.image_base64 && !followSample) await showFollowImage(f.image_base64,f.target,followEpoch);
} catch(e) {
if (followSession) {
releaseJog(); setupReady = false; followSession = null;
$('follow-status').textContent = 'Verbindung verloren; Nachführung wird beendet. '+e.message;
}
}
setTimeout(pollFollow,300);
}
pollFollow();
refreshDetection();
setInterval(drawDetection, 250);
refresh();
+36 -1
View File
@@ -31,7 +31,7 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
Internetfreigabe ist nicht beauftragt. Zugangsschutz bleibt offen.
- Laufzeitdaten liegen standardmäßig unter `data/` und werden weder
hochgeladen noch in Git aufgenommen.
- Echte GPIO-/Aktorsteuerung erst nach geklärter Mechanik, Versorgung,
- Echte GPIO-/Grabaktorsteuerung erst nach geklärter Mechanik, Versorgung,
sicherem Stopp und den Hardwaretests aus `PLAN.md` ergänzen.
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
@@ -93,3 +93,38 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
bestätigtes Bildlöschen; Ereignis/Profilzuordnung bleiben bestehen. Löschen
betrifft den aktiven Bestand, keine Upload-Sicherungen. Keine automatische
Alterslöschung von Bildern und keine Änderung der Auto-Profil-Frist.
## Kamera folgt einem Farbpunkt
- `follow.py` bietet lokale Pillow-HSV-Erkennung ohne OpenCV/Training und einen
optional bewusst gestarteten PCA9685-Kameratest. Vorschau und Demo greifen
niemals auf I²C zu; Demo lehnt Hardwarestart ab. Echte Ausgabe nur CH0/CH1,
Bus 1 / 0x40, nominal 50 Hz. Platine für diesen Test ausschließlich dafür nutzen.
- 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.
- 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.
- 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
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
lassen. Keine unabhängige OE-Sperre; nur beaufsichtigter Test.
- `operation=setup` ist ein eigener manueller Einrichtmodus derselben exklusiven
Kamerasitzung. Kein Farbzielsuchen/-verluststopp; Kamera und Heartbeat bleiben
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.
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.
+53 -1
View File
@@ -1,4 +1,4 @@
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
"""Lokale WebUI und MJPEG-Livebild; Grabaktoren simuliert, Kameraservos separat freigebbar."""
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -15,6 +15,7 @@ from .camera import CameraStream
from .controller import Controller, MODES
from .event_store import EventStore
from .detection import DetectionService
from .follow import FollowService
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
@@ -46,6 +47,9 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
events = EventStore(directory)
detection = DetectionService(camera, controller, events, directory)
follow = FollowService(camera, directory)
follow_api_lock = asyncio.Lock()
@asynccontextmanager
async def lifespan(app: FastAPI):
async def periodic_profile_cleanup():
@@ -59,16 +63,19 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
async def detection_watchdog():
while True:
detection.watchdog()
follow.watchdog()
await asyncio.sleep(0.1)
camera.start()
detection.start()
follow.start()
watchdog_task = asyncio.create_task(detection_watchdog())
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
try:
yield
finally:
controller.stop()
follow.stop()
watchdog_task.cancel()
with suppress(asyncio.CancelledError):
await watchdog_task
@@ -83,6 +90,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
app.state.controller = controller
app.state.events = events
app.state.detection = detection
app.state.follow = follow
@app.middleware("http")
async def security_headers(request: Request, call_next):
@@ -112,6 +120,8 @@ 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"):
raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen")
try:
return detection.configure(payload)
except (ValueError, TypeError, KeyError, IndexError) as exc:
@@ -119,6 +129,46 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
@app.get("/api/follow")
async def follow_status():
return follow.status()
@app.post("/api/follow/{action}", dependencies=[Depends(require_ui)])
async def follow_action(action: str, payload: dict):
async with follow_api_lock:
try:
if action == "config":
return follow.configure(payload)
if action == "capture":
return follow.capture()
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()
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 == "remember":
return follow.remember(payload.get("session"), payload.get("direction"))
if action == "heartbeat":
follow.heartbeat(payload.get("session"))
return {"ok": True}
if action == "stop":
follow.halt()
return follow.status()
raise HTTPException(status_code=404, detail="Unbekannte Aktion")
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except OSError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.get("/api/events")
async def recent_events(limit: int = Query(20, ge=1, le=100)):
try:
@@ -213,11 +263,13 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.post("/api/stop", dependencies=[Depends(require_ui)])
async def stop():
follow.halt()
controller.stop()
return controller.status()
@app.post("/api/reset", dependencies=[Depends(require_ui)])
async def reset():
follow.halt()
controller.reset()
detection.reset()
return controller.status()
+1
View File
@@ -1,3 +1,4 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
Pillow>=10,<13
smbus2>=0.4.3,<1
+10
View File
@@ -64,3 +64,13 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
privaten Socket für alle Schritte, BatchMode auf Slaves, TTY für sudo sowie
Aufräumen bei Erfolg/Verbindungs-/Transfer-/Aktivierungsfehlern und pure Vorschau.
Dies bestätigt keine reale SSH-/Passwortanmeldung am Pi.
- `test_follow.py` prüft synthetische Farbpunkte (auch Rot um die HSV-Grenze),
Mehrdeutigkeit, Bildauswahl, Persistenz ohne Fotos, Pulsgrenzen/Geschwindigkeit,
Richtungswechsel, alte Resultate/Sitzungen, Fehler-/Abbruchpfade, API-Interlock
und I²C-Protokoll mit künstlichem Bus. Kein echter Pi oder Servo wird angesteuert.
- 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.
+235
View File
@@ -0,0 +1,235 @@
import asyncio
from io import BytesIO
from pathlib import Path
import tempfile
import unittest
import httpx
from PIL import Image, ImageDraw
from halloween.follow import DEFAULT, FollowService, PCA9685, locate, validate
from halloween.web import create_app
def frame(color='lime', double=False):
image = Image.new('RGB', (640,480), '#181818')
draw = ImageDraw.Draw(image)
draw.ellipse((440,180,510,250), fill=color)
if double: draw.ellipse((100,180,170,250), fill=color)
data = BytesIO(); image.save(data, 'JPEG'); return data.getvalue()
class Camera:
demo = False
error = None
def __init__(self, clock): self.clock, self.jpeg, self.captured = clock, frame(), clock()
def latest_timed(self): return 1, self.jpeg, self.captured
class Driver:
def __init__(self): self.moves, self.closed = [], False
def move(self, pan, tilt): self.moves.append((pan,tilt))
def close(self): self.closed = True
class FollowTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory(); self.addCleanup(self.folder.cleanup)
self.now = 10.
self.camera = Camera(lambda:self.now)
self.drivers = []
def factory():
driver = Driver(); self.drivers.append(driver); return driver
self.follow = FollowService(self.camera, Path(self.folder.name), factory, lambda:self.now)
def start(self): return self.follow.begin(True, True)
def step(self, target):
self.now += .2; self.camera.captured = self.now
self.follow.heartbeat(self.follow.session)
self.follow.process(2,self.camera.jpeg,self.now,self.follow.revision,target)
def test_marker_and_ambiguity(self):
target = locate(frame(), DEFAULT)
self.assertGreater(target['x'], .7)
self.assertIsNone(locate(frame(double=True), DEFAULT))
self.assertIsNone(locate(frame('gray'), DEFAULT))
self.assertIsNone(locate(frame('red'), DEFAULT))
self.assertIsNotNone(locate(frame('red'), dict(DEFAULT,hue=0)))
def test_photo_selection_and_no_persistent_image(self):
shot = self.follow.capture()
config = self.follow.select(shot['token'],.74,.45)
self.assertLess(abs(config['hue']-85),3)
self.assertEqual([p.name for p in Path(self.folder.name).iterdir()], ['follow.json'])
with self.assertRaises(ValueError): self.follow.select('old',.74,.45)
with self.assertRaises(ValueError): self.follow.select(shot['token'],float('nan'),.5)
with self.assertRaises(ValueError): self.follow.select(shot['token'],.2,.2)
def test_no_hardware_without_explicit_start_and_no_restart_resume(self):
self.follow.begin(False)
self.step({'x':.9,'y':.9})
self.assertEqual(self.drivers, [])
self.follow.halt()
with self.assertRaises(ValueError): self.follow.begin(True,False)
self.assertFalse(FollowService(self.camera,Path(self.folder.name)).active)
self.assertEqual(self.drivers, [])
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.assertEqual(self.follow.tilt,1500)
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),16)
for _ in range(30): self.step({'x':1.,'y':1.})
self.assertEqual(self.follow.pan,1600)
self.follow.halt(); self.assertTrue(driver.closed)
count=len(driver.moves)
self.follow.process(99,self.camera.jpeg,self.now,self.follow.revision,{'x':0,'y':0})
self.assertEqual(len(driver.moves),count)
self.follow.configure(dict(DEFAULT,pan_reverse=True))
self.start(); self.step({'x':.9,'y':.5})
self.assertLess(self.follow.pan,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]
self.follow.process(2,self.camera.jpeg,self.now,revision,{'x':1,'y':1})
self.assertEqual(driver.moves,[(1500,1500)])
def test_lost_marker_heartbeat_and_camera_latch_off(self):
for reason in ('marker','heartbeat','camera','inference'):
self.follow.halt(); self.camera.captured=self.now
self.start(); driver=self.drivers[-1]
if reason=='marker':
for _ in range(6): self.step(None)
else:
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(driver.closed, reason)
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),
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_close_error_is_visible_and_restart_is_explicit(self):
self.start()
def bad_close(): raise OSError('I2C ausgefallen')
self.drivers[0].close=bad_close
self.follow.halt()
self.assertFalse(self.follow.active)
self.assertIn('Servoversorgung ausschalten',self.follow.error)
def test_failed_start_disables_channels_and_demo_rejects_hardware(self):
driver=Driver()
def bad_move(*args): raise OSError('I2C fehlgeschlagen')
driver.move=bad_move
self.follow.factory=lambda:driver
with self.assertRaises(ValueError): self.start()
self.assertTrue(driver.closed)
self.assertFalse(self.follow.active)
self.camera.demo=True
with self.assertRaises(ValueError): self.start()
def test_old_heartbeat_cannot_renew_new_session(self):
old=self.start(); self.follow.halt(); current=self.start()
self.assertNotEqual(old,current)
with self.assertRaises(ValueError): self.follow.heartbeat(old)
self.now+=2; self.camera.captured=self.now
self.follow.watchdog()
self.assertFalse(self.follow.active)
def test_setup_hold_release_expiry_and_out_of_order_requests(self):
session=self.follow.begin(True,True,'setup')
driver=self.drivers[-1]
self.follow.jog(session,'right',1)
self.step(None)
self.assertAlmostEqual(self.follow.pan,1508)
self.step(None) # no renewal: 0.35 s motion lease has expired
self.assertAlmostEqual(self.follow.pan,1508)
self.follow.jog(session,'right',2)
self.follow.jog(session,None,3)
self.assertFalse(self.follow.jog(session,'right',2)['accepted'])
self.step(None)
self.assertAlmostEqual(self.follow.pan,1508)
for _ in range(8): self.step(None)
self.assertTrue(self.follow.active) # setup needs no colour marker
self.follow.halt()
with self.assertRaises(ValueError): self.follow.jog(session,'up',4)
self.assertTrue(driver.closed)
def test_setup_limits_reverse_and_remember_are_persistent(self):
self.follow.configure(dict(DEFAULT,pan_min=1500,pan_max=1500,tilt_reverse=True))
session=self.follow.begin(True,True,'setup')
for seq in range(1,5):
self.follow.jog(session,'right',seq); self.step(None)
self.assertGreater(self.follow.pan,1500) # setup intentionally exceeds saved limits
with self.assertRaises(ValueError): self.follow.remember(session,'right')
self.follow.jog(session,None,5)
c=self.follow.remember(session,'right')
self.assertEqual(c['pan_max'],1522)
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config['pan_max'],1522)
self.follow.jog(session,'up',6); self.step(None)
self.assertGreater(self.follow.tilt,1500)
for seq in range(7,80):
self.follow.jog(session,'up',seq); self.step(None)
self.assertEqual(self.follow.tilt,1900)
self.follow.jog(session,None,80)
self.assertEqual(self.follow.remember(session,'up')['tilt_max'],1890)
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)
with self.assertRaises(ValueError): self.follow.remember(session,'up')
self.follow.halt(); session=self.follow.begin(True,True,'setup')
for sequence in (True,-1,1.5):
with self.assertRaises(ValueError): self.follow.jog(session,'up',sequence)
with self.assertRaises(ValueError): self.follow.jog('wrong','up',1)
with self.assertRaises(ValueError): self.follow.remember('wrong','up')
self.now+=2; self.camera.captured=self.now
with self.assertRaises(ValueError): self.follow.jog(session,'up',1)
self.assertFalse(self.follow.active)
def test_register_protocol(self):
class Bus:
def __init__(self,n): self.writes=[]; self.closed=False
def write_byte_data(self,a,r,v): self.writes.append((a,r,v))
def write_i2c_block_data(self,a,r,v): self.writes.append((a,r,v))
def close(self): self.closed=True
driver=PCA9685(Bus)
self.assertEqual(driver.bus.writes[:2],[(0x40,0x09,0x10),(0x40,0x0d,0x10)])
self.assertIn((0x40,0xfe,121),driver.bus.writes)
driver.move(1500,1500)
self.assertEqual(driver.bus.writes[-2:],[(0x40,6,[0,0,51,1]),(0x40,10,[0,0,51,1])])
driver.close(); self.assertTrue(driver.bus.closed)
def test_api_guard_interlock_global_stop_and_reset(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
app.state.follow.factory=lambda:Driver()
app.state.camera.demo=True
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)
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)
self.assertFalse(app.state.follow.active)
await c.post('/api/follow/start',headers=h,json={'hardware':False})
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)
asyncio.run(scenario())
if __name__=='__main__': unittest.main()
+1 -1
View File
@@ -20,7 +20,7 @@ class WebTest(unittest.TestCase):
page = await client.get("/")
self.assertEqual(page.status_code, 200)
self.assertIn("/stream.mjpg", page.text)
self.assertIn("Aktoren nur simuliert", page.text)
self.assertIn("Grabablauf nur simuliert", page.text)
for _ in range(30):
if (await client.get("/api/status")).json()["camera"]["ready"]:
break