diff --git a/AGENTS.md b/AGENTS.md index dd7054b..29aceab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/PCA9685_FOTO.jpg b/PCA9685_FOTO.jpg new file mode 100644 index 0000000..7bbb7b3 Binary files /dev/null and b/PCA9685_FOTO.jpg differ diff --git a/PLAN.md b/PLAN.md index f3d000f..25da93d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/RASPI_PINS.svg b/RASPI_PINS.svg new file mode 100644 index 0000000..e097ae4 --- /dev/null +++ b/RASPI_PINS.svg @@ -0,0 +1,79 @@ + diff --git a/README.md b/README.md index 59319b0..5aab64c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SOFTWARE.md b/SOFTWARE.md index 2269ecd..15d6a2d 100644 --- a/SOFTWARE.md +++ b/SOFTWARE.md @@ -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. diff --git a/VERDRAHTUNG.md b/VERDRAHTUNG.md new file mode 100644 index 0000000..1ac6093 --- /dev/null +++ b/VERDRAHTUNG.md @@ -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. + + + +## 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 + + + +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 + + + +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 | diff --git a/VERDRAHTUNG.svg b/VERDRAHTUNG.svg new file mode 100644 index 0000000..ad928eb --- /dev/null +++ b/VERDRAHTUNG.svg @@ -0,0 +1,67 @@ + diff --git a/halloween/follow.py b/halloween/follow.py new file mode 100644 index 0000000..c44ba10 --- /dev/null +++ b/halloween/follow.py @@ -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) diff --git a/halloween/index.html b/halloween/index.html index 4c8fb3d..07e75f0 100644 --- a/halloween/index.html +++ b/halloween/index.html @@ -91,7 +91,7 @@ -
Aktoren nur simuliert. Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.
+Grabablauf nur simuliert. 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.
Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.
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.
+Eigene Farbe: Beispielfoto aufnehmen und dort auf die Mitte des Farbpunkts tippen. Das Foto wird nicht gespeichert.
+ +Farbtest lädt …
+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.
+Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.
+ +Noch keine Grenze in dieser Sitzung gespeichert.
+ +Sollposition: PAN 1500 / TILT 1500 µs
+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.
+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.
+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.
+Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.
@@ -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(); diff --git a/halloween/instructions.md b/halloween/instructions.md index fe5bcb3..2d12467 100644 --- a/halloween/instructions.md +++ b/halloween/instructions.md @@ -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. diff --git a/halloween/web.py b/halloween/web.py index 48b64e4..e0f7c35 100644 --- a/halloween/web.py +++ b/halloween/web.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 5d9ffe5..a3b7558 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi>=0.115,<1 uvicorn>=0.30,<1 Pillow>=10,<13 +smbus2>=0.4.3,<1 diff --git a/tests/instructions.md b/tests/instructions.md index 29a5607..36d5843 100644 --- a/tests/instructions.md +++ b/tests/instructions.md @@ -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. diff --git a/tests/test_follow.py b/tests/test_follow.py new file mode 100644 index 0000000..a219f8d --- /dev/null +++ b/tests/test_follow.py @@ -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() diff --git a/tests/test_web.py b/tests/test_web.py index 3325e11..c84da40 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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