Private
Public Access
added point mode
This commit is contained in:
1 parent
044ad2afbe
commit
fce8f62534
16 files changed
+1547
-10
No files matched your search
@@ -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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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.
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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 |
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,3 +1,4 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
Pillow>=10,<13
|
||||
smbus2>=0.4.3,<1
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user