addet test set

This commit is contained in:
marcinator_2 committed 2026-10-14 17:40:31 +02:00
1 parent d370f992ce
commit b0f35bf3cc
21 files changed
+1796 -183

No files matched your search

+31 -3
View File
@@ -153,6 +153,13 @@ Versorgung und Sicherheit im Plan.
- Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten - Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten
Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen. Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen.
- Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten. - Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten.
- Neu beauftragt: bei jeder akzeptierten automatischen Auslösung ein JPEG
des grünen Bereichs aus dem Trigger-Frame lokal unter data/event-images/
speichern; Vorschau/Ansehen und bestätigtes Bildlöschen in der Ereignisliste.
Nutzerwahl: bis manuelle Löschung behalten, keine automatische Bilderfrist.
Ereignis/Profilzuordnung bleiben beim Bildlöschen; Upload-Sicherungen separat.
Keine Bilder bei manuellen Testauslösungen oder nachträglich für alte Ereignisse.
Lokale synthetische Tests bestanden; echter Pi-Speicher-/Anzeigetest noch offen.
- Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen; - Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen;
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen. Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
- Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus. - Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus.
@@ -166,7 +173,8 @@ Versorgung und Sicherheit im Plan.
- Nutzer wünscht nun direkten Zugriff im Heimnetz. Manueller Start über - Nutzer wünscht nun direkten Zugriff im Heimnetz. Manueller Start über
Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben. Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben.
Aktuell ohne Login; keine öffentliche Internetfreigabe beauftragt. Aktuell ohne Login; keine öffentliche Internetfreigabe beauftragt.
`run.py` und systemd bleiben unverändert bei Loopback. `run.py` bleibt bei Loopback; systemd nutzt die beim Upload gewählte
Bind-Adresse, initial Loopback.
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde - Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera. nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit - Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit
@@ -180,6 +188,17 @@ Versorgung und Sicherheit im Plan.
aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und
Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile. Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile.
Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen. Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen.
- Zusätzlicher Testmodus „Bewegung in Pfeilrichtung“ ist implementiert:
Optical Flow im gewählten Bereich, kein Personenmodell/keine Überquerung nötig.
Auswahl und Aktivierung gespeichert; Personenerkennung bleibt der Standard.
Zwei gerichtete Beobachtungen bestätigen; zwei Sekunden Bildruhe und zehn
Sekunden Sperrzeit. Bildruhe ist keine Personenfreiheit. Nur Simulation,
separates motion_direction_test-Ereignis ohne Profil, mit lokalem Auslösebild.
Nutzer-Screenshot bestätigt Testmodus mit Pi-Bild und 5 Auswertungen/s,
aber bisher keine Auslösung. Kleine/langsame Nahbereichbewegung war im
synthetischen Regressionstest zu schwach für die alte Schwelle; korrigiert.
UI zeigt Bildänderung/Richtungsbestätigung und automatische Bereitschaft
getrennt von manuellen Tests. Echte Auslösung am Pi bleibt zu prüfen.
- SQLite speichert Testereignisse und Profilkennungen unter `data/`; - SQLite speichert Testereignisse und Profilkennungen unter `data/`;
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera
@@ -192,8 +211,17 @@ Versorgung und Sicherheit im Plan.
- Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als - Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`. `marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen. Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim - Versionierter Upload richtet systemd-Autostart bei jedem `--apply` ein.
Upload eingerichtet; auf einem echten Pi noch nicht geprüft. Neue Version samt eigener `.venv` unter `releases/`, atomarer `current`-Link,
gemeinsames `data/` über `HALLOWEEN_STATE_DIR`. Nach Dienststopp Daten sichern;
bei Start-/HTTP-Fehlern Code, Unit und Daten zurücksetzen. `--web-host` wählt
Loopback oder ausdrücklich Heimnetz; gespeicherte Wahl bleibt erhalten.
Update mit genau einem sudo-Aufruf für Umschaltung und Rückfall; Paketsetup
unprivilegiert, Python-/Unitprüfung per runuser als Pi-Benutzer. Passwort und
sudoers/Zeitstempel bleiben unverändert; echte Passwortabfrage noch prüfen.
Alte Versionen/Sicherungen nicht automatisch löschen. Lokale Simulationstests
bestanden; Nutzer bestätigt erfolgreichen Upload. Autostart nach tatsächlichem
Reboot und automatischer Rückfall am Gerät noch ungeprüft.
- Nutzer-Ausgaben bestätigen Raspbian GNU/Linux 13 (trixie), Python 3.13.5 - Nutzer-Ausgaben bestätigen Raspbian GNU/Linux 13 (trixie), Python 3.13.5
und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft. und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft.
+93 -3
View File
@@ -153,6 +153,13 @@ Versorgung und Sicherheit im Plan.
- Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten - Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten
Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen. Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen.
- Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten. - Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten.
- Neu beauftragt: bei jeder akzeptierten automatischen Auslösung ein JPEG
des grünen Bereichs aus dem Trigger-Frame lokal unter data/event-images/
speichern; Vorschau/Ansehen und bestätigtes Bildlöschen in der Ereignisliste.
Nutzerwahl: bis manuelle Löschung behalten, keine automatische Bilderfrist.
Ereignis/Profilzuordnung bleiben beim Bildlöschen; Upload-Sicherungen separat.
Keine Bilder bei manuellen Testauslösungen oder nachträglich für alte Ereignisse.
Lokale synthetische Tests bestanden; echter Pi-Speicher-/Anzeigetest noch offen.
- Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen; - Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen;
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen. Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
- Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus. - Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus.
@@ -166,7 +173,8 @@ Versorgung und Sicherheit im Plan.
- Nutzer wünscht nun direkten Zugriff im Heimnetz. Manueller Start über - Nutzer wünscht nun direkten Zugriff im Heimnetz. Manueller Start über
Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben. Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben.
Aktuell ohne Login; keine öffentliche Internetfreigabe beauftragt. Aktuell ohne Login; keine öffentliche Internetfreigabe beauftragt.
`run.py` und systemd bleiben unverändert bei Loopback. `run.py` bleibt bei Loopback; systemd nutzt die beim Upload gewählte
Bind-Adresse, initial Loopback.
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde - Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera. nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit - Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit
@@ -180,6 +188,17 @@ Versorgung und Sicherheit im Plan.
aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und
Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile. Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile.
Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen. Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen.
- Zusätzlicher Testmodus „Bewegung in Pfeilrichtung“ ist implementiert:
Optical Flow im gewählten Bereich, kein Personenmodell/keine Überquerung nötig.
Auswahl und Aktivierung gespeichert; Personenerkennung bleibt der Standard.
Zwei gerichtete Beobachtungen bestätigen; zwei Sekunden Bildruhe und zehn
Sekunden Sperrzeit. Bildruhe ist keine Personenfreiheit. Nur Simulation,
separates motion_direction_test-Ereignis ohne Profil, mit lokalem Auslösebild.
Nutzer-Screenshot bestätigt Testmodus mit Pi-Bild und 5 Auswertungen/s,
aber bisher keine Auslösung. Kleine/langsame Nahbereichbewegung war im
synthetischen Regressionstest zu schwach für die alte Schwelle; korrigiert.
UI zeigt Bildänderung/Richtungsbestätigung und automatische Bereitschaft
getrennt von manuellen Tests. Echte Auslösung am Pi bleibt zu prüfen.
- SQLite speichert Testereignisse und Profilkennungen unter `data/`; - SQLite speichert Testereignisse und Profilkennungen unter `data/`;
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera
@@ -192,8 +211,17 @@ Versorgung und Sicherheit im Plan.
- Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als - Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`. `marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen. Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim - Versionierter Upload richtet systemd-Autostart bei jedem `--apply` ein.
Upload eingerichtet; auf einem echten Pi noch nicht geprüft. Neue Version samt eigener `.venv` unter `releases/`, atomarer `current`-Link,
gemeinsames `data/` über `HALLOWEEN_STATE_DIR`. Nach Dienststopp Daten sichern;
bei Start-/HTTP-Fehlern Code, Unit und Daten zurücksetzen. `--web-host` wählt
Loopback oder ausdrücklich Heimnetz; gespeicherte Wahl bleibt erhalten.
Update mit genau einem sudo-Aufruf für Umschaltung und Rückfall; Paketsetup
unprivilegiert, Python-/Unitprüfung per runuser als Pi-Benutzer. Passwort und
sudoers/Zeitstempel bleiben unverändert; echte Passwortabfrage noch prüfen.
Alte Versionen/Sicherungen nicht automatisch löschen. Lokale Simulationstests
bestanden; Nutzer bestätigt erfolgreichen Upload. Autostart nach tatsächlichem
Reboot und automatischer Rückfall am Gerät noch ungeprüft.
## Prüfung ## Prüfung
@@ -227,3 +255,65 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen.
nur Wheels und brechen andernfalls ab. Kein weiterer Quellbuild vorgesehen. nur Wheels und brechen andernfalls ab. Kein weiterer Quellbuild vorgesehen.
- Offen: installierte OpenCV-Version/-Herkunft und echte Modellinferenz am Pi - Offen: installierte OpenCV-Version/-Herkunft und echte Modellinferenz am Pi
prüfen. Dell-Netzteil laut Nutzer passend, Ausgangsprofile nicht verifiziert. prüfen. Dell-Netzteil laut Nutzer passend, Ausgangsprofile nicht verifiziert.
## Updateauftrag vom 14. Oktober 2026
- Erledigt: Versionsordner, eigene Python-Umgebung, gemeinsames data/,
systemd-Neustart und Autostart, HTTP-Prüfung und automatischer Rückfall
einschließlich Datensicherung; Anleitung in UPLOAD.md.
- Lokal verifiziert: simulierte Dienst-/Installationsfehler und erfolgreiche
Umschaltung, Erhalt von Einstellungen/Daten und Rückfall zur Altinstallation.
- Nutzer bestätigt: bisheriger Updateaufruf hat funktioniert.
- Offen am Pi: tatsächlichen Neustart/Autostart und Rückfall testen;
keine Hardwareprüfung durch den Agenten erfolgt.
- Betriebsgrenze: keine automatische Wiederherstellung nach Stromverlust während
des Updates; Speicherbedarf und Aufräumen alter Sicherungen manuell prüfen.
## Nahbereichstest vom 14. Oktober 2026
- Erledigt: auswählbarer Bewegungs-Test in Pfeilrichtung ohne Personenmodell;
keine Überquerung nötig, getrennte Testereignisse, gespeicherte Auswahl.
- Lokal verifiziert: synthetische Richtungs-/Störungs-/Sperrtests, modellfreier
Worker und vorhandene Softwaretests. Browser-Demo: Auswahl, Speicherung und
Wiederherstellung nach Neuladen in UI-Struktur; Screenshot nicht verfügbar.
- Offen: neue Version auf Pi übertragen und tatsächliche Nahbereichbewegung
kalibrieren/prüfen. Bewegungstest ist kein Nachweis für Personenauslösung
oder menschenfreie Bereiche im späteren Gartenbetrieb.
### Rückmeldung: Nahbereich löst nicht aus
- Nutzer-Screenshot: Pi-Livebild, Testmodus aktiviert, 5 Auswertungen/s und
Automatik bereit. Nutzer bewegt Hand/Kopf nach rechts passend zum Bildpfeil;
keine Auslösung gemeldet. Manuelles Bereich-frei beeinflusste nur die separate
Controlleranzeige, nicht die automatische Freigabe.
- Lokal reproduziert: kleiner/langsamer Stimulus im großen Bereich scheitert an
alter Bildänderungsschwelle. Schwellen reduziert, Richtung und Doppelbestätigung
beibehalten. Derselbe Stimulus löst nun ohne manuelles Freigeben aus; Gegenrichtung
wird verworfen. Diagnoserückmeldung und Automatikstatus in der UI ergänzt.
- Offen am Pi: erneuter Upload und tatsächlicher Nahbereichstest. Synthetische
Tests und JS-Rendererprüfung sind keine Bestätigung einer echten Auslösung.
## Automatische Auslösebilder vom 14. Oktober 2026
- Erledigt: lokal gespeichertes JPEG pro automatischem akzeptierten Trigger,
auf grünen Bereich begrenzt; Bildvorschau/Ansehen/Löschen in Ereignisliste.
Aufbewahrung laut Nutzer bis manueller Löschung. Ereignis/Profil bleiben.
- Lokal geprüft: identischer Trigger-Frame, Bereichsbeschnitt, Dateirechte,
Persistenz, Migration, Fehlerbereinigung, Bild-API und manuelles Bildlöschen
ausschließlich mit synthetischen Daten.
- Offen: neue Version hochladen, echte Auslösung und Bildvorschau am Pi prüfen.
Bestehende Ereignisse bleiben bildlos; Sicherungsbilder gesondert verwalten.
- Liveprüfung vor diesem Auftrag: Pi-Ereignisliste zeigte drei automatische
Bewegungs-Testauslösungen (13:07:54, 13:08:26, 13:08:53) ohne Bilder; daraus
folgt kein Nachweis für neue Bildspeicherung oder Erkennungsqualität.
## Einmalige sudo-Abfrage beim Update
- Erledigt: ein sudo-Aufruf nach Vorbereitung der Python-Umgebung. Umschaltung,
systemd und Rückfall laufen im selben privilegierten Teil; Python und
Unit-Renderer als Pi-Benutzer über runuser. Besitzer von Link/Konfiguration/
neuen Sicherungen bleiben beim Pi-Benutzer. Keine Passwortablage/sudoersänderung.
- Lokal verifiziert: 12 Update-Simulationstests einschließlich Ablehnung jedes
zweiten sudo-Aufrufs, erfolgreichem Rückfall und abgelehnter sudo-Anmeldung.
- Offen am Pi: aktualisierten Upload ausführen und tatsächliche Anzahl der
Passwortabfragen sowie Dienst/Python-Benutzer prüfen; kein echter PAM-Test.
+127 -20
View File
@@ -5,8 +5,9 @@ Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren, **Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
am Pi installiert und kalibriert werden. Profilzuordnung per Kamera, Clips am Pi installiert und kalibriert werden. Bei automatischer Auslösung wird
und Maskierung sind noch nicht umgesetzt. ein Bild des grünen Bereichs lokal gespeichert. Profilzuordnung per Kamera,
Clips und zusätzliche frei zeichnbare Masken sind noch nicht umgesetzt.
Die 30-Tage-Löschung ist für künftige Auto-Profile vorbereitet. Daher vorerst nur Die 30-Tage-Löschung ist für künftige Auto-Profile vorbereitet. Daher vorerst nur
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
richten. richten.
@@ -30,19 +31,28 @@ direkt im WLAN sichtbar.
### WebUI beim Hochfahren starten ### WebUI beim Hochfahren starten
Nach der Vorbereitung kann `tools/install_pi_service.sh` auf dem Pi einen Das versionierte Uploadskript richtet den systemd-Autostart jetzt bei jedem
systemd-Dienst einrichten. Erst eine Vorschau ansehen, dann anwenden: Update mit `--apply` ein und prüft den gestarteten Webdienst. Anleitung,
Rückfall und Datensicherung stehen in [UPLOAD.md](UPLOAD.md). Der Updateablauf
verwendet einen einzigen sudo-Aufruf für Umschaltung und möglichen Rückfall;
das Passwort wird nicht gespeichert. Python-Prüfungen laufen als Pi-Benutzer.
Der Dienst nutzt `current/.venv`, startet die WebUI beim Hochfahren und verweist
für alle Laufzeitdaten auf den gemeinsamen Basisordner `data/`. Mit
`--web-host 0.0.0.0` beim Upload wird direkter Heimnetzzugriff aktiviert;
ohne Angabe gilt die gespeicherte Wahl, anfänglich Loopback.
Auf dem Pi prüfen:
```bash ```bash
bash tools/install_pi_service.sh
bash tools/install_pi_service.sh --apply
systemctl status halloween-grab.service systemctl status halloween-grab.service
systemctl is-enabled halloween-grab.service
``` ```
Der Dienst läuft unter dem aktuellen Pi-Benutzer, startet `run.py` beim Der einzelne Installer bleibt für manuelle Einrichtung verfügbar:
Hochfahren und wird bei einem Fehler neu gestartet. Das Skript benötigt für `bash tools/install_pi_service.sh --project-dir /home/BENUTZER/halloween --apply`.
die Einrichtung `sudo` und überschreibt keine fremde Unit gleichen Namens. Er prüft keinen vollständigen Versionsrückfall; Updates deshalb vom Laptop
Nach Code-Updates kann derselbe `--apply`-Aufruf den Dienst neu starten. über das Uploadskript durchführen. Autostart ist am echten Pi noch zu prüfen.
Zum Deaktivieren: `sudo systemctl disable --now halloween-grab.service`. Zum Deaktivieren: `sudo systemctl disable --now halloween-grab.service`.
Bei Startproblemen: `sudo journalctl -u halloween-grab.service -n 80 --no-pager`. Bei Startproblemen: `sudo journalctl -u halloween-grab.service -n 80 --no-pager`.
Ein Browserfenster auf dem Laptop öffnet sich dadurch nicht automatisch. Ein Browserfenster auf dem Laptop öffnet sich dadurch nicht automatisch.
@@ -75,7 +85,8 @@ SSH-Tunnel nötig. `0.0.0.0` ist die Bind-Adresse, keine Browseradresse.
Die WebUI hat noch keinen Login: Geräte im erreichbaren Heimnetz können Die WebUI hat noch keinen Login: Geräte im erreichbaren Heimnetz können
Kamera und Bedienoberfläche nutzen. Eine öffentliche Internetfreigabe ist Kamera und Bedienoberfläche nutzen. Eine öffentliche Internetfreigabe ist
damit nicht beauftragt. Direkter Browserzugriff im Heimnetz ist noch nicht damit nicht beauftragt. Direkter Browserzugriff im Heimnetz ist noch nicht
bestätigt. `run.py` und der bisherige systemd-Dienst bleiben bei Loopback. bestätigt. `run.py` bleibt bei Loopback; der neue systemd-Dienst unterstützt
die beim Upload gewählte Bind-Adresse.
## Ohne Kamera testen ## Ohne Kamera testen
@@ -103,8 +114,9 @@ ohne Zuordnung erhalten. Die Daten bleiben bei einem Neustart und bei
weiteren Uploads bestehen, weil das Uploadskript `data/` auslässt. weiteren Uploads bestehen, weil das Uploadskript `data/` auslässt.
Der Nutzer wünscht später automatische Wiedererkennung per Kamera. Diese Der Nutzer wünscht später automatische Wiedererkennung per Kamera. Diese
Version erstellt noch keine Profile aus Kamerabildern und speichert keine Version erstellt noch keine Profile aus Kamerabildern. Gesichtsmerkmale und
Bilder, Gesichtsmerkmale oder Clips aus Kamerabildern in der Datenbank. Clips werden nicht aufgenommen; Auslösebilder liegen als separate lokale JPEGs
mit einem Verweis in der Ereignisdatenbank.
Für künftig automatisch erstellte Profile sind Merkmalsvektoren und eine Für künftig automatisch erstellte Profile sind Merkmalsvektoren und eine
30-Tage-Frist ab letzter Sichtung vorbereitet. Beim Start, stündlich und 30-Tage-Frist ab letzter Sichtung vorbereitet. Beim Start, stündlich und
beim Lesen der Ereignisse/Profile werden abgelaufene Auto-Profile mitsamt beim Lesen der Ereignisse/Profile werden abgelaufene Auto-Profile mitsamt
@@ -128,17 +140,51 @@ SSH-Tunnel und GPIOs müssen am echten Pi bzw. Aufstellort geprüft werden.
Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf),
[FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/). [FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/).
## Bilder zu automatischen Auslösungen
Bei jeder akzeptierten automatischen Auslösung (Linienüberquerung oder
Bewegungs-Test) wird der **grüne Auslösebereich** des tatsächlich ausgewerteten
Kamerabildes als JPEG unter `data/event-images/` gespeichert. Außerhalb des
gewählten Rechtecks wird nichts gespeichert. Der Bereich muss vor Aktivierung
auf den vorgesehenen privaten Testbereich beschränkt sein. Keine zusätzliche
Ton-/Clipaufnahme, keine Gesichtsmerkmale und keine Profilautomatik.
In **Gespeicherte Ereignisse** erscheinen eine kleine Vorschau und **„Bild
ansehen“** (öffnet das Bild in einem eigenen Tab). Die Liste aktualisiert sich
alle 15 Sekunden; **„Ereignisse aktualisieren“** lädt sie sofort neu.
Bisherige Ereignisse und manuelle Testauslösungen haben kein gespeichertes Bild.
Auf ausdrückliche Nutzerwahl bleiben Bilder **bis zur manuellen Löschung**
erhalten. **„Bild löschen“** verlangt eine Bestätigung und entfernt das JPEG
im aktiven Datenbestand. Ereignis, Zeitpunkt und Profilzuordnung bleiben erhalten.
Es gibt keine automatische Alterslöschung dieser Bilder. Speicherbelegung am Pi
beobachten; die vorhandenen Upload-Sicherungen können ältere Bilder enthalten
und werden durch diesen Knopf nicht verändert.
JPEGs haben Dateirechte 0600, der Bildordner 0700. Abruf nur über die Ereignis-ID,
keine statische Freigabe des gesamten `data/`-Ordners. `GET /api/events` liefert
`image_url` oder `null`; `GET /api/events/{id}/image` liefert JPEG mit `no-store`,
`DELETE` benötigt den vorhandenen UI-Header. Diese Schranke ersetzt keinen Login:
Wie das Livebild sind Bilder im erreichbaren Heimnetz zugänglich. Keine öffentliche
Freigabe. Upload und Versionswechsel bewahren `data/` einschließlich Bilder.
Schreib-/Datenbankfehler werden angezeigt und sperren die Automatik bis zum
bewussten Rücksetzen. Teilweise fehlgeschlagene Speichervorgänge räumen temporäre
Bilder auf. Lokale Tests prüfen beschnittene Bilder, Trigger-Frame, Neustart,
Alt-Datenbankmigration, Abruf/Löschen und Fehlerfälle mit synthetischen Bildern.
Das Speichern und Anzeigen nach echter Pi-Auslösung ist noch zu prüfen.
## Personenerkennung und Linienauslösung ## Personenerkennung und Linienauslösung
Nach Übertragung des aktuellen Codes im Projektordner auf dem Pi installieren: Nach einem versionierten Update im aktiven Versionsordner auf dem Pi installieren:
```bash ```bash
cd ~/halloween cd ~/halloween/current
sudo apt update sudo apt update
sudo apt install python3-opencv python3-numpy sudo apt install python3-opencv python3-numpy
python3 -m venv --system-site-packages .venv
.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)" .venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)"
.venv/bin/python tools/install_detection_model.py HALLOWEEN_STATE_DIR=/home/marcinator_2/halloween/data \
.venv/bin/python tools/install_detection_model.py
``` ```
Das Skript lädt ausschließlich das unquantisierte Modell Das Skript lädt ausschließlich das unquantisierte Modell
@@ -192,7 +238,8 @@ Kamera-/Modellfehler und mehr als zwei Sekunden ohne gültige Auswertung
sperren die aktivierte Automatik und stoppen den simulierten Ablauf. Nach sperren die aktivierte Automatik und stoppen den simulierten Ablauf. Nach
Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich
freihalten. Modellprobleme bei deaktivierter Automatik verhindern keine freihalten. Modellprobleme bei deaktivierter Automatik verhindern keine
manuellen Tests. Demo braucht weder OpenCV noch Modell; ihre synthetischen manuellen Tests. Der Demo-Livefeed und manuelle Tests brauchen weder OpenCV
noch Modell; der Bewegungs-Test benötigt auch im Demo-Modus OpenCV. Synthetische
Bilder sind kein Test der echten Personenerkennung. Bilder sind kein Test der echten Personenerkennung.
Die Auswertung arbeitet im Hintergrund mit dem jeweils neuesten Bild, Ziel Die Auswertung arbeitet im Hintergrund mit dem jeweils neuesten Bild, Ziel
@@ -207,10 +254,70 @@ und Rechteck. `POST` benötigt wie andere Schreibaufrufe `X-Halloween-UI: 1`.
`/api/status` enthält zusätzlich `detection`. Der Header ersetzt keinen Login. `/api/status` enthält zusätzlich `detection`. Der Header ersetzt keinen Login.
Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der
bestehenden Datenbank gespeichert. Weder Kamerabilder noch Tracking-IDs werden bestehenden Datenbank gespeichert, mit lokalem Bild des grünen Bereichs aus
automatisch gespeichert; Profil-Wiedererkennung und Altersentscheidung fehlen. dem auslösenden Frame. Tracking-IDs werden nicht gespeichert; Profil-Wiedererkennung
und Altersentscheidung fehlen.
Aktoren bleiben ausschließlich simuliert. Aktoren bleiben ausschließlich simuliert.
### Nahbereich testen: Bewegung in Pfeilrichtung
In der WebUI unter **Automatische Auslösung → Erkennung wählen** den Modus
**„Test: Bewegung in Pfeilrichtung“** auswählen, „Automatisch auslösen“
anhaken und „Linie und Bereich speichern“ drücken. Der Modus wird lokal
in `data/detection.json` gespeichert. Bisherige Einstellungen ohne Methodenfeld
werden als Personenerkennung übernommen; diese bleibt der Standard.
Dieser Test benötigt OpenCV und NumPy aus den oben beschriebenen Pi-Systempaketen,
aber kein NanoDet-Modell. Wenn OpenCV fehlt, auf dem Pi ausführen:
```bash
sudo apt install python3-opencv python3-numpy
sudo systemctl restart halloween-grab.service
```
Zuerst die Kamera fest abstellen und das Bild im grünen Bereich mindestens zwei
Sekunden ruhig halten. Dann eine Hand, den Kopf oder einen Gegenstand sichtbar
in Pfeilrichtung bewegen. Eine Person muss weder vollständig sichtbar sein noch
wird eine Linienüberquerung verlangt: Die Linie legt in diesem Modus nur den
Pfeil fest. Für Bewegung nach links/rechts die Linie entsprechend senkrecht
stellen; mit „Richtung umkehren“ die Pfeilrichtung wechseln.
Es müssen zwei aufeinanderfolgende Auswertungen gerichtete Bildbewegung im
gewählten Bereich bestätigen. Der Test wertet auch kleine, langsame Änderungen
im Nahbereich aus. Unter der Statuszeile stehen der geänderte Bildanteil,
die Richtungsbewertung und „Bestätigung 0/2“, „1/2“ oder „2/2“. So lassen sich
zu kleine Änderungen von fehlender Richtung unterscheiden. Eine Auslösung
setzt außerdem die vorherige automatische Freigabe voraus.
Die Betriebsanzeige nutzt bei eingeschalteter Automatik deren Freigabe
(„Bereit (Automatik)“ oder „Ruhiges Bild abwarten (Automatik)“). Der Knopf
„Bereich frei (manueller Test)“ ist dafür nicht erforderlich und hebt die
automatische Freigabeprüfung nicht auf. Die manuellen Testknöpfe bleiben für
separate Versuche verfügbar.
Sehr langsame, zu schnelle, winzige oder
strukturarme Bewegung kann übersehen werden. Nach Auslösung gelten zehn Sekunden
Sperrzeit und erneut mindestens zwei Sekunden ruhiges Bild. Ein ruhiges Bild
beweist **keinen menschenfreien Bereich**. Stopp, Fehler-Watchdog, bewusstes
Rücksetzen und erneute Freigabe nach Start/Konfigurationswechsel bleiben wirksam.
Bei bereits gesetzter Fehlersperre nach Auswahl und Speichern bewusst
„Nach Stopp zurücksetzen“ drücken.
Dies ist ein **Bewegungs-Test für die Simulation**, keine Personenerkennung
für den späteren Gartenbetrieb. Auch Kamera-/Hintergrundbewegung kann auslösen.
Lichtwechsel werden nicht als Richtung behandelt, können aber die Ruhefreigabe
verzögern. Bei akzeptiertem Trigger wird ein Bild des grünen Bereichs gespeichert;
Besuchermerkmale werden nicht gespeichert.
Testereignisse erscheinen separat als „Bewegungs-Test“ (`motion_direction_test`).
`/api/detection` liefert zusätzlich flüchtige Bewegungswerte unter `motion`;
Config/Status enthalten `method` (`person_line` oder `motion_test`).
Lokal mit synthetischen Bildern getestet: Richtung/Umkehr/gedrehte Linie,
Gegenrichtung, seitliche Bewegung, Helligkeitswechsel, Bereichsgrenzen,
Bestätigung, Bildlücken, Sperrzeit und Stopp. Browser-Demo bestätigt Auswahl,
Speichern und Wiederherstellung nach Neuladen über die sichtbare UI-Struktur.
Echte Pi-Kamera und Nahbereich noch nicht geprüft.
### Abnahme am echten Pi (noch offen) ### Abnahme am echten Pi (noch offen)
- Angezeigte Rate und Reaktionsverzögerung prüfen; Livebild und Stopp müssen - Angezeigte Rate und Reaktionsverzögerung prüfen; Livebild und Stopp müssen
+113 -75
View File
@@ -1,91 +1,129 @@
# Projektdateien auf einen Raspberry Pi kopieren # Versionierte Updates auf dem Raspberry Pi
Das Skript `tools/upload_to_pi.sh` kopiert den gewählten lokalen Ordner per `tools/upload_to_pi.sh` überträgt per SSH/rsync eine neue Version und richtet
SSH und `rsync` auf **einen** Pi. Ohne `--source` ist das Projektverzeichnis bei jedem angewendeten Update den systemd-Autostart der WebUI ein. Ohne
die Quelle. Es löscht keine Dateien auf dem Pi. Benutzer, Host und absoluter `--apply` zeigt es nur eine lokale Vorschau und verbindet sich nicht zum Pi.
Zielpfad werden beim Aufruf angegeben. Der Nutzer-Screenshot vom 14. Oktober
2026 zeigt einen erfolgreichen SSH-Login als `marcinator_2` auf ## Auf dem Laptop ausführen
`raspi5wlan.local` und Projektdateien unter `~/halloween`.
Das folgende Beispiel verwendet den vom Nutzer bestätigten SSH-Benutzer,
Hostnamen und Projektordner. Der absolute Home-Pfad muss auf dem Pi stimmen:
```bash
bash tools/upload_to_pi.sh --user marcinator_2 --host raspi5wlan.local \
--remote-dir /home/marcinator_2/halloween --web-host 0.0.0.0
```
Zum tatsächlichen Update denselben Befehl mit `--apply` ausführen:
```bash
bash tools/upload_to_pi.sh --user marcinator_2 --host raspi5wlan.local \
--remote-dir /home/marcinator_2/halloween --web-host 0.0.0.0 --apply
```
`--web-host 0.0.0.0` ermöglicht den gewünschten Heimnetzzugriff auf
`http://raspi5wlan.local:8000`. Die WebUI hat noch keinen Login; keine
Router-Portfreigabe einrichten. Mit `--web-host 127.0.0.1` stattdessen den
SSH-Tunnel aus `SOFTWARE.md` verwenden. Ohne Angabe bleibt die gespeicherte
Wahl erhalten; beim ersten Update gilt Loopback. `--install-service` wird
weiterhin akzeptiert, ist aber nicht mehr erforderlich.
Autostart heißt: Der Webdienst startet beim Hochfahren des Pi. Ein Browser
auf dem Laptop wird dadurch nicht automatisch geöffnet.
## Voraussetzungen ## Voraussetzungen
- Auf dem Laptop: `ssh` und `rsync`. - Laptop: `bash`, `python3`, `ssh`, `rsync`.
- Auf dem Pi: aktivierter SSH-Zugang und `rsync`. - Pi: `bash`, `rsync`, `flock`, `runuser` (util-linux), `python3-venv`,
- Der Benutzer muss im Zielpfad schreiben dürfen. `python3-picamera2`, systemd
- Für den optionalen Autostart: Pi-Software laut `SOFTWARE.md` vorbereiten, und nutzbares `sudo` für den normalen SSH-Benutzer.
insbesondere `.venv` im Zielordner; `sudo` muss auf dem Pi nutzbar sein. - Der Pi muss die Python-Pakete aus `requirements.txt` herunterladen können.
- Vor dem ersten Upload den Pi per `ssh BENUTZER@HOST` erreichen und den Pro Version wird eine eigene `.venv` mit Systempaketzugriff angelegt;
angezeigten Hostschlüssel anhand des eigenen Geräts prüfen. Bei einem pip verwendet ausschließlich Wheels, keinen impliziten Quellbuild.
geänderten Hostschlüssel die Ursache klären, nicht die Prüfung abschalten. - Genug Speicher für neue Version, Python-Umgebung und vollständige Sicherung
von `data/`. Alte Versionen und Sicherungen werden nicht automatisch gelöscht.
- SSH-Hostschlüssel am eigenen Gerät prüfen; die Prüfung bleibt eingeschaltet.
- Eine mit Uvicorn manuell gestartete WebUI vor dem ersten Update mit `Strg+C`
beenden. Ein bestehender, vom Projekt verwalteter Dienst wird selbst gestoppt.
Eine fremde gleichnamige systemd-Unit wird abgelehnt.
## Ablauf ## Einmalige sudo-Abfrage beim Update
`BENUTZER`, `HOST` und `ZIEL` durch die geprüften Werte ersetzen. `ZIEL` Das Uploadskript bereitet die neue Python-Umgebung als normaler Pi-Benutzer vor.
muss ein absoluter Pfad ohne Leerzeichen sein. Danach läuft die Umschaltung mit **einem einzigen sudo-Aufruf**: Unitprüfung,
Dienststopp, Sicherung, Versionswechsel, Dienstneustart und gegebenenfalls Rückfall.
Bei gültiger sudo-Anmeldung entfällt die Frage; sonst ist für diesen Aufruf eine
Anmeldung nötig. Keine weitere Abfrage während Neustart oder Rückfall, auch bei
kurzem oder deaktiviertem sudo-Zeitstempel. SSH-Anmeldung ist davon getrennt.
Passwörter werden vom Skript weder gelesen noch gespeichert. Keine sudoers-
Ausnahme, kein Passwort per Kommandozeile/Pipe und keine Änderung der sudo-Frist.
Abhängigkeiten und Python-Prüfungen bleiben unprivilegiert; der privilegierte
Teil startet Python und den Unit-Renderer mit `runuser` als ursprünglichem
Pi-Benutzer. Auch der Webdienst behält diesen Benutzer. Die Updatesperre bleibt
im wartenden unprivilegierten Elternprozess über den ganzen sudo-Aufruf gehalten.
Eine abgelehnte sudo-Anmeldung lässt den aktiven Dienst und Datenbestand bestehen.
## Ablauf und Verzeichnisstruktur
1. Neue Dateien unter `releases/ZEITSTEMPEL-ZUFALL/` übertragen.
2. Mit einer Updatesperre parallele Aktivierung verhindern; neue Python-Umgebung
vorbereiten und Importe/Syntax prüfen, während die alte Version weiterläuft.
3. Dienst stoppen; Daten einschließlich SQLite-Dateien und Einstellungen unter
`backups/VERSION/data/` sichern. Keine laufende Datenbank kopieren.
4. `current` atomar auf die neue Version umschalten und die Unit aktualisieren.
`HALLOWEEN_STATE_DIR` verweist immer auf den gemeinsamen Basisordner `data/`.
5. Autostart aktivieren, Dienst neu starten, `/api/status` bis zu 30 Versuche
mit je einer Sekunde Pause abfragen und aktiven Dienststatus prüfen.
6. Bei einem Fehler während der Umschaltung Dienst stoppen, vorherige Version,
Daten und Unit wiederherstellen und den alten Dienst samt HTTP-Antwort prüfen.
Änderungen des fehlgeschlagenen Starts bleiben in `backups/VERSION/failed-data/`.
Die bisherige flache Installation mit `run.py` und `.venv` im Basisordner
bleibt beim ersten Update als Rückfallziel bestehen. Ohne vorherige Installation
kann ein fehlgeschlagener Erststart nur die Daten und den Zustand ohne neuen
Dienst wiederherstellen. Ein Fehlschlag liefert einen Fehlerstatus, auch wenn
der Rückfall gelungen ist. Ein unvollständiger Rückfall wird ausdrücklich gemeldet.
Abrupter Stromverlust/SIGKILL während des Updates ist nicht automatisch abgedeckt.
`data/` wird niemals vom Laptop übertragen: Datenbank, Bilder, Modelle,
Modus und Erkennungseinstellungen bleiben auf dem Pi. `.git`, `.venv`,
Vorschauen, Referenzen, `.env`, Schlüssel, Versionsordner und Sicherungen werden
ebenfalls ausgeschlossen. Eigene Dateien außerhalb von `data/` bleiben im
Basisordner, werden aber nicht automatisch in die neue Version übernommen.
Das Skript löscht keine alten Pi-Dateien. Systempakete werden nicht geändert.
Nach Start gelten die bisherigen Freigabe-/Resetbedingungen der Effektsteuerung.
## Auf dem Pi prüfen
```bash ```bash
BENUTZER='BENUTZER' systemctl status halloween-grab.service
HOST='HOST' systemctl is-enabled halloween-grab.service
ZIEL='/home/BENUTZER/halloween' readlink -f ~/halloween/current
sudo journalctl -u halloween-grab.service -n 80 --no-pager
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL"
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --apply
``` ```
Der erste Aufruf zeigt nur die geplanten Befehle. Der zweite legt den Erkennung nach dem ersten Update bei Bedarf im aktiven Versionsordner einrichten:
Zielordner an und überträgt die Dateien. Ein anderer Quellordner kann mit
`--source /absoluter/pfad` angegeben werden. Anschließend auf dem Pi prüfen:
```bash ```bash
ssh "$BENUTZER@$HOST" "ls -la '$ZIEL'" cd ~/halloween/current
```
Soll die WebUI auf dem Pi nach jedem Neustart automatisch laufen, beim
Upload `--install-service` ergänzen:
```bash
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service --apply
```
Der erste Befehl ist wieder nur eine lokale Vorschau. Der zweite überträgt
die Dateien, installiert/aktualisiert die systemd-Unit mit `sudo` auf dem Pi
und startet den Dienst. Bei späteren Updates startet der Dienst so mit dem
neuen Code neu. Er bindet weiterhin nur an `127.0.0.1` des Pi; vom Laptop
aus ist der SSH-Tunnel aus `SOFTWARE.md` nötig. Es öffnet sich kein
Browserfenster von selbst.
Das Skript lässt `.git`, `.venv`, `data`, `preview`, `references`,
`__pycache__`, `.env` und übliche Schlüsseldateien aus. So bleiben
Laufzeiteinstellungen auf dem Pi und die großen Konzeptdateien lokal.
Es synchronisiert vorhandene Dateien, löscht aber
keine alten Dateien auf dem Pi. Zugangsdaten und andere Geheimnisse gehören
nicht in den Quellordner. Für zwei Pis den Upload mit den jeweils geprüften
Zielwerten separat ausführen.
## Bestätigter Stand
Der Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang und vorhandene
Projektdateien auf dem Pi: unter anderem `run.py`, `halloween/`, `tools/`,
`tests/` und die Requirements-Dateien. Zeitpunkt und Methode der bisherigen
Übertragung sowie die Übereinstimmung mit dem aktuellen lokalen Stand sind
offen. Installation der Python-Pakete, WebUI-Start, Kamera, SSH-Tunnel und
systemd-Autostart sind damit noch nicht bestätigt.
## Personenerkennung nach dem Upload einrichten
Die Modellgewichte und Erkennungseinstellungen liegen unter `data/` und bleiben
beim Upload erhalten. Bei der ersten Einrichtung nach Code-Übertragung auf dem
Pi die zusätzlichen Pakete und das geprüfte Modell ausdrücklich installieren:
```bash
cd ~/halloween
sudo apt update sudo apt update
sudo apt install python3-opencv python3-numpy sudo apt install python3-opencv python3-numpy
python3 -m venv --system-site-packages .venv HALLOWEEN_STATE_DIR=/home/marcinator_2/halloween/data \
.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)" .venv/bin/python tools/install_detection_model.py
.venv/bin/python tools/install_detection_model.py sudo systemctl restart halloween-grab.service
``` ```
Danach den Server neu starten und Linie, Richtung, Bereich und Aktivierung in OpenCV mindestens 4.10 und das echte Kamera-/Erkennungsverhalten gemäß
der WebUI speichern. Details und Fehlerbehandlung in `SOFTWARE.md`. `SOFTWARE.md` prüfen. Nachfolgende Versionsumgebungen nutzen dieselben Pi-Systempakete.
## Verifizierter Stand
Lokale Tests simulieren Python-Installation, systemd und HTTP-Prüfung und prüfen
Versionswechsel, Autostart-Unit, Datenbestand, Start-/HTTP-Fehler, Rückfall,
Erstinstallation, Altinstallation und Ablehnung fremder Units/manueller Server.
Sie bestätigen keine echte Pi-Installation, Kamera oder Neustartfestigkeit.
Der Nutzer hat das echte Livebild und anschließend den erfolgreichen
Updateaufruf bestätigt. Autostart nach einem tatsächlichen Pi-Neustart und
automatischer Rückfall bei einem Fehler sind noch am Gerät zu prüfen.
Die HTTP-Prüfung bestätigt die Status-API, nicht Bildqualität oder Aktoren.
+58 -19
View File
@@ -13,14 +13,22 @@ from io import BytesIO
from PIL import Image from PIL import Image
from .motion import MotionDetector
DEFAULT_CONFIG = {"enabled": False, "line": [[0.2, 0.55], [0.8, 0.55]],
DEFAULT_CONFIG = {"enabled": False, "method": "person_line", "line": [[0.2, 0.55], [0.8, 0.55]],
"direction": 1, "region": [0.1, 0.15, 0.9, 0.95]} "direction": 1, "region": [0.1, 0.15, 0.9, 0.95]}
def validate_config(value): def validate_config(value):
# Migrate previous saved configurations and old clients without changing
# their enabled setting or requiring a new geometry setup.
if isinstance(value, dict) and 'method' not in value:
value = {**value, 'method': 'person_line'}
if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool: if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool:
raise ValueError("Ungültige Erkennungseinstellungen") raise ValueError("Ungültige Erkennungseinstellungen")
if value['method'] not in ('person_line', 'motion_test'):
raise ValueError('Unbekannte Erkennungsmethode')
if type(value["direction"]) is not int or value["direction"] not in (-1, 1): if type(value["direction"]) is not int or value["direction"] not in (-1, 1):
raise ValueError("Richtung muss -1 oder 1 sein") raise ValueError("Richtung muss -1 oder 1 sein")
line, region = value["line"], value["region"] line, region = value["line"], value["region"]
@@ -180,7 +188,7 @@ class DetectionService:
self.last_valid = None self.last_valid = None
self.started = self.clock() self.started = self.clock()
self.fps = 0. self.fps = 0.
self.result = {"frame_number": 0, "people": [], "image_base64": None} self.result = {"frame_number": 0, "people": [], "image_base64": None, "motion": None}
def reset_state(self): def reset_state(self):
self.revision += 1 self.revision += 1
@@ -211,7 +219,7 @@ class DetectionService:
self.reset_state() self.reset_state()
self.started = self.clock() self.started = self.clock()
self.last_valid = None self.last_valid = None
self.result = {"frame_number": 0, "people": [], "image_base64": None} self.result = {"frame_number": 0, "people": [], "image_base64": None, "motion": None}
return self.get_config() return self.get_config()
def get_config(self): def get_config(self):
@@ -243,8 +251,10 @@ class DetectionService:
elif self.clock() - (self.last_valid if self.last_valid is not None else self.started) > 2: elif self.clock() - (self.last_valid if self.last_valid is not None else self.started) > 2:
self.fail("Keine gültige Erkennung seit mehr als zwei Sekunden; zurücksetzen") self.fail("Keine gültige Erkennung seit mehr als zwei Sekunden; zurücksetzen")
def process(self, people, number, jpeg, captured_at, version): def process(self, people, number, jpeg, captured_at, version, motion=None):
event_mode = None event_mode = None
event_method = None
event_region = None
with self.lock: with self.lock:
now = self.clock() now = self.clock()
if self.stop_event.is_set(): if self.stop_event.is_set():
@@ -261,17 +271,26 @@ class DetectionService:
if not self.fault: if not self.fault:
self.error = None self.error = None
image = Image.open(BytesIO(jpeg)) image = Image.open(BytesIO(jpeg))
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height) if self.config['method'] == 'motion_test':
self.result = {"frame_number": number, "people": people, if motion is None:
self.fail('Bewegungs-Test liefert kein Ergebnis; zurücksetzen')
return
people = []
crossing = motion['trigger']
else:
motion = None
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height)
self.result = {"frame_number": number, "people": people, "motion": motion,
"image_base64": base64.b64encode(jpeg).decode("ascii")} "image_base64": base64.b64encode(jpeg).decode("ascii")}
if not self.config["enabled"] or self.fault or self.controller.stopped: if not self.config["enabled"] or self.fault or self.controller.stopped:
self.armed = False self.armed = False
self.free_since = None self.free_since = None
return return
rx1, ry1, rx2, ry2 = self.config["region"] rx1, ry1, rx2, ry2 = self.config["region"]
# Auch unbestätigte Personenrahmen verhindern eine falsche Freigabe. # In motion test mode, a quiet image is NOT proof of no visitors.
occupied = any(p["box"][0] <= rx2 and p["box"][2] >= rx1 and occupied = (not motion['ready'] or motion['moving']) if motion is not None else any(
p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people) p["box"][0] <= rx2 and p["box"][2] >= rx1 and
p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people)
if occupied: if occupied:
self.free_since = None self.free_since = None
else: else:
@@ -288,17 +307,22 @@ class DetectionService:
self.free_since = None self.free_since = None
if accepted: if accepted:
event_mode = self.controller.last_event["mode"] event_mode = self.controller.last_event["mode"]
event_method = self.config['method']
event_region = list(self.config['region'])
# SQLite darf UI und unabhängigen Watchdog nicht blockieren. # SQLite darf UI und unabhängigen Watchdog nicht blockieren.
if event_mode is not None: if event_mode is not None:
try: try:
self.events.record_detection_trigger(event_mode) if event_method == 'motion_test':
self.events.record_motion_trigger(event_mode, jpeg=jpeg, region=event_region)
else:
self.events.record_detection_trigger(event_mode, jpeg=jpeg, region=event_region)
except Exception as exc: except Exception as exc:
self.fail(f"Ereignis nicht gespeichert: {exc}") self.fail(f"Ereignis/Bild nicht gespeichert: {exc}")
def status(self): def status(self):
with self.lock: with self.lock:
age = None if self.last_valid is None else self.clock()-self.last_valid age = None if self.last_valid is None else self.clock()-self.last_valid
return {"enabled": self.config["enabled"], "ready": age is not None and age <= 2 and not self.error, return {"enabled": self.config["enabled"], "method": self.config["method"], "ready": age is not None and age <= 2 and not self.error,
"error": self.config_error or self.error, "reset_required": self.fault, "error": self.config_error or self.error, "reset_required": self.fault,
"armed": self.armed and not self.fault and not self.controller.stopped "armed": self.armed and not self.fault and not self.controller.stopped
and self.controller.generation == self.generation and self.controller.generation == self.generation
@@ -309,7 +333,7 @@ class DetectionService:
with self.lock: with self.lock:
status = self.status() status = self.status()
fresh = status["ready"] fresh = status["ready"]
return {**self.result, "people": self.result["people"] if fresh else [], return {**self.result, "motion": self.result["motion"] if fresh else None, "people": self.result["people"] if fresh else [],
"image_base64": self.result["image_base64"] if fresh else None, "image_base64": self.result["image_base64"] if fresh else None,
"status": status} "status": status}
@@ -325,27 +349,42 @@ class DetectionService:
self.thread.join(timeout=3) self.thread.join(timeout=3)
def _run(self): def _run(self):
detector, last_number, last_completed = None, 0, None detector, detector_key, last_number, last_completed = None, None, 0, None
while not self.stop_event.is_set(): while not self.stop_event.is_set():
began = self.clock() began = self.clock()
version = None
try: try:
if detector is None:
detector = self.factory(self.model_path)
with self.lock: with self.lock:
if self.controller.generation != self.generation: if self.controller.generation != self.generation:
self.reset_state() self.reset_state()
version = self.revision version = self.revision
config = self.get_config()
# A fresh motion baseline is required for every reconfiguration,
# controller change and reset. Never reuse pre-change optical flow.
key = (config['method'], version)
if detector is None or detector_key != key:
detector = MotionDetector() if config['method'] == 'motion_test' else self.factory(self.model_path)
detector_key = key
last_completed = None
number, jpeg, captured_at = self.camera.latest_timed() number, jpeg, captured_at = self.camera.latest_timed()
if jpeg is not None and number != last_number: if jpeg is not None and number != last_number:
last_number = number last_number = number
people = detector.detect(jpeg) if config['method'] == 'motion_test':
self.process(people, number, jpeg, captured_at, version) motion = detector.detect(jpeg, config, captured_at)
self.process([], number, jpeg, captured_at, version, motion=motion)
else:
people = detector.detect(jpeg)
self.process(people, number, jpeg, captured_at, version)
completed = self.clock() completed = self.clock()
with self.lock: with self.lock:
self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed) self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed)
last_completed = completed last_completed = completed
except Exception as exc: except Exception as exc:
self.fail(f"Personenerkennung nicht verfügbar: {exc}") # An old in-flight model failure must not fault a newly selected
# method. A current failure still uses the usual reset latch.
with self.lock:
if version == self.revision and self.controller.generation == self.generation:
self.fail(f"Erkennung nicht verfügbar: {exc}")
detector = None detector = None
self.stop_event.wait(1) self.stop_event.wait(1)
self.stop_event.wait(max(0, 0.2-(self.clock()-began))) self.stop_event.wait(max(0, 0.2-(self.clock()-began)))
+98 -10
View File
@@ -3,6 +3,12 @@
from contextlib import closing from contextlib import closing
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from io import BytesIO
import re
import tempfile
import uuid
from PIL import Image
import math import math
import os import os
import sqlite3 import sqlite3
@@ -36,6 +42,7 @@ class EventStore:
def __init__(self, state_dir: Path): def __init__(self, state_dir: Path):
state_dir.mkdir(parents=True, exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
self.path = state_dir / "events.sqlite3" self.path = state_dir / "events.sqlite3"
self.image_dir = state_dir / "event-images"
with closing(self._connect()) as connection: with closing(self._connect()) as connection:
with connection: with connection:
connection.execute(""" connection.execute("""
@@ -75,6 +82,8 @@ class EventStore:
"ALTER TABLE events ADD COLUMN profile_id INTEGER " "ALTER TABLE events ADD COLUMN profile_id INTEGER "
"REFERENCES profiles(id) ON DELETE SET NULL" "REFERENCES profiles(id) ON DELETE SET NULL"
) )
if "image_filename" not in columns:
connection.execute("ALTER TABLE events ADD COLUMN image_filename TEXT")
connection.execute(""" connection.execute("""
CREATE TABLE IF NOT EXISTS profile_templates ( CREATE TABLE IF NOT EXISTS profile_templates (
profile_id INTEGER PRIMARY KEY profile_id INTEGER PRIMARY KEY
@@ -98,18 +107,87 @@ class EventStore:
def record_test_trigger(self, mode: str) -> int: def record_test_trigger(self, mode: str) -> int:
return self._record_trigger(mode, "test_trigger") return self._record_trigger(mode, "test_trigger")
def record_detection_trigger(self, mode: str) -> int: def record_detection_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "person_line_crossing") return self._record_trigger(mode, "person_line_crossing", jpeg, region)
def _record_trigger(self, mode: str, kind: str) -> int: def record_motion_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "motion_direction_test", jpeg, region)
def _save_image(self, jpeg: bytes, region) -> str:
if (region is None or len(region) != 4 or
any(not math.isfinite(v) or not 0 <= v <= 1 for v in region) or
region[0] >= region[2] or region[1] >= region[3]):
raise ValueError("Ungültiger Bildbereich")
with Image.open(BytesIO(jpeg)) as image:
width, height = image.size
# Round inward: no pixel outside the chosen private region is saved.
box = (math.ceil(region[0]*width), math.ceil(region[1]*height),
math.floor(region[2]*width), math.floor(region[3]*height))
if box[2] <= box[0] or box[3] <= box[1]:
raise ValueError("Bildbereich ist zu klein")
cropped = image.crop(box).convert('RGB')
self.image_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.image_dir, 0o700)
filename = uuid.uuid4().hex + '.jpg'
target = self.image_dir / filename
with tempfile.NamedTemporaryFile(dir=self.image_dir, suffix='.tmp', delete=False) as stream:
temporary = Path(stream.name)
try:
cropped.save(stream, format='JPEG', quality=90)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
return filename
def _record_trigger(self, mode: str, kind: str, jpeg=None, region=None) -> int:
occurred_at = _timestamp(_utc_now()) occurred_at = _timestamp(_utc_now())
filename = self._save_image(jpeg, region) if jpeg is not None else None
try:
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode, image_filename) VALUES (?, ?, ?, ?)",
(occurred_at, kind, mode, filename),
)
event_id = cursor.lastrowid
return event_id
except Exception:
if filename:
(self.image_dir / filename).unlink(missing_ok=True)
raise
def _image_path(self, filename):
if not isinstance(filename, str) or not re.fullmatch(r'[0-9a-f]{32}\.jpg', filename):
raise KeyError("Kein Bild für dieses Ereignis")
path = self.image_dir / filename
if path.is_symlink():
raise KeyError("Kein Bild für dieses Ereignis")
return path
def image_path(self, event_id: int) -> Path:
with closing(self._connect()) as connection:
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
if row is None:
raise KeyError("Ereignis nicht gefunden")
path = self._image_path(row['image_filename'])
if not path.is_file():
raise KeyError("Bild nicht vorhanden")
return path
def delete_image(self, event_id: int) -> bool:
with closing(self._connect()) as connection: with closing(self._connect()) as connection:
with connection: with connection:
cursor = connection.execute( row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)", if row is None:
(occurred_at, kind, mode), raise KeyError("Ereignis nicht gefunden")
) if row['image_filename'] is None:
return cursor.lastrowid return False
path = self._image_path(row['image_filename'])
path.unlink(missing_ok=True)
connection.execute("UPDATE events SET image_filename = NULL WHERE id = ?", (event_id,))
return True
def recent(self, limit: int = 20) -> list[dict]: def recent(self, limit: int = 20) -> list[dict]:
if not 1 <= limit <= 100: if not 1 <= limit <= 100:
@@ -118,11 +196,21 @@ class EventStore:
with closing(self._connect()) as connection: with closing(self._connect()) as connection:
rows = connection.execute( rows = connection.execute(
"SELECT e.id, e.occurred_at, e.kind, e.mode, " "SELECT e.id, e.occurred_at, e.kind, e.mode, "
"e.profile_id, p.label AS profile_label " "e.profile_id, e.image_filename, p.label AS profile_label "
"FROM events e LEFT JOIN profiles p ON p.id = e.profile_id " "FROM events e LEFT JOIN profiles p ON p.id = e.profile_id "
"ORDER BY e.id DESC LIMIT ?", (limit,), "ORDER BY e.id DESC LIMIT ?", (limit,),
).fetchall() ).fetchall()
return [dict(row) for row in rows] result = []
for row in rows:
event = dict(row)
filename = event.pop('image_filename')
try:
present = self._image_path(filename).is_file()
except KeyError:
present = False
event['image_url'] = f"/api/events/{event['id']}/image" if present else None
result.append(event)
return result
def create_profile(self, label: str) -> dict: def create_profile(self, label: str) -> dict:
label = label.strip() label = label.strip()
+83 -13
View File
@@ -37,6 +37,7 @@
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; } #events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
#events-list li { margin: .35rem 0; } #events-list li { margin: .35rem 0; }
#profiles-list li { margin: .35rem 0; } #profiles-list li { margin: .35rem 0; }
.event-image { display: block; max-width: 200px; max-height: 150px; border-radius: 7px; margin: .5rem 0; }
.event-tools { margin: .3rem 0 .8rem; } .event-tools { margin: .3rem 0 .8rem; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; } } @media (max-width: 760px) { main { grid-template-columns: 1fr; } }
</style> </style>
@@ -53,17 +54,24 @@
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera"> <img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas> <canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
</div> </div>
<h2 style="margin-top:1rem">Personenerkennung</h2> <h2 style="margin-top:1rem">Automatische Auslösung</h2>
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p> <p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
<label for="detection-method">Erkennung wählen</label>
<select id="detection-method">
<option value="person_line">Person überquert Linie</option>
<option value="motion_test">Test: Bewegung in Pfeilrichtung</option>
</select>
<p id="detection-help" class="muted"></p>
<div class="row"> <div class="row">
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label> <label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
<button id="reverse-direction">Richtung umkehren</button> <button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button> <button id="save-detection">Linie und Bereich speichern</button>
</div> </div>
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p> <p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
<p id="motion-details" class="muted" role="status" hidden></p>
<p class="muted" id="camera-status">Kamera wird gestartet …</p> <p class="muted" id="camera-status">Kamera wird gestartet …</p>
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a> <a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
<p class="notice">Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p> <p class="notice">Bei automatischer Auslösung wird der grüne Bereich als Bild lokal auf dem Pi gespeichert, bis du es löschst. Den Bereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
</section> </section>
<section> <section>
<h2>Betrieb</h2> <h2>Betrieb</h2>
@@ -83,10 +91,11 @@
</select> </select>
<button id="save-mode">Übernehmen</button> <button id="save-mode">Übernehmen</button>
</div> </div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p> <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="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
<div class="row"> <div class="row">
<button id="zone-clear">Bereich frei (Test)</button> <button id="zone-clear">Bereich frei (manueller Test)</button>
<button id="test-trigger">Testauslösung</button> <button id="test-trigger">Manuelle Testauslösung</button>
</div> </div>
<div class="row" style="margin-top: 1rem"> <div class="row" style="margin-top: 1rem">
<button class="danger" id="stop">Stopp</button> <button class="danger" id="stop">Stopp</button>
@@ -97,6 +106,7 @@
<section class="events"> <section class="events">
<h2>Gespeicherte Ereignisse</h2> <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> <p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<button id="refresh-events">Ereignisse aktualisieren</button>
<ul id="events-list"><li>Lädt …</li></ul> <ul id="events-list"><li>Lädt …</li></ul>
</section> </section>
<section class="events"> <section class="events">
@@ -129,7 +139,20 @@
const c = data.controller, cam = data.camera; const c = data.controller, cam = data.camera;
$('mode-label').textContent = names[c.mode] || c.mode; $('mode-label').textContent = names[c.mode] || c.mode;
if (document.activeElement !== $('mode')) $('mode').value = c.mode; if (document.activeElement !== $('mode')) $('mode').value = c.mode;
$('phase').textContent = c.phase; const detection = data.detection;
let phase = c.phase;
// The manual zone-clear flag is not the automatic detection gate.
// Preserve running sequence, stop and cooldown labels from the controller.
if (detection.enabled && ['Bereit', 'Bereich frei abwarten'].includes(phase)) {
phase = !['sanft', 'voll'].includes(c.mode) ? 'Modus ohne Schreckauslösung'
: detection.reset_required ? 'Erkennung gesperrt; zurücksetzen'
: !detection.ready ? 'Auf Erkennung warten'
: detection.armed ? 'Bereit (Automatik)'
: detection.method === 'motion_test' ? 'Ruhiges Bild abwarten (Automatik)'
: 'Freien Bereich abwarten (Automatik)';
}
$('phase').textContent = phase;
$('zone-clear').title = 'Nur für manuelle Tests. Die Automatik prüft die Freigabe selbst.';
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–'; $('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–'; $('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild'); $('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
@@ -191,7 +214,7 @@
for (const event of data.events) { for (const event of data.events) {
const item = document.createElement('li'); const item = document.createElement('li');
const summary = document.createElement('span'); const summary = document.createElement('span');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : ''); summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + ({person_line_crossing: 'Linienüberquerung', motion_direction_test: 'Bewegungs-Test', test_trigger: 'Manueller Test'}[event.kind] || event.kind) + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
const tools = document.createElement('div'); const tools = document.createElement('div');
tools.className = 'row event-tools'; tools.className = 'row event-tools';
const select = document.createElement('select'); const select = document.createElement('select');
@@ -208,7 +231,31 @@
} catch (error) { $('message').textContent = error.message; } } catch (error) { $('message').textContent = error.message; }
}; };
tools.append(select, assign); tools.append(select, assign);
item.append(summary, tools); item.append(summary);
if (event.image_url) {
const link = document.createElement('a');
link.href = event.image_url; link.target = '_blank'; link.rel = 'noopener';
link.textContent = 'Bild ansehen';
const preview = document.createElement('img');
preview.className = 'event-image'; preview.loading = 'lazy';
preview.src = event.image_url; preview.alt = 'Auslösebild zu Ereignis #' + event.id;
link.prepend(preview);
const removeImage = document.createElement('button');
removeImage.textContent = 'Bild löschen';
removeImage.onclick = async () => {
if (!confirm('Bild unwiderruflich löschen? Der Ereigniseintrag bleibt erhalten.')) return;
try {
await api(event.image_url, {}, 'DELETE');
await refreshEvents();
} catch (error) { $('message').textContent = error.message; }
};
item.append(link, removeImage);
} else {
const missing = document.createElement('span');
missing.className = 'muted'; missing.textContent = ' · Kein gespeichertes Bild';
item.append(missing);
}
item.append(tools);
list.append(item); list.append(item);
} }
} catch (error) { $('events-list').textContent = error.message; } } catch (error) { $('events-list').textContent = error.message; }
@@ -222,6 +269,7 @@
$('events-list').textContent = error.message; $('events-list').textContent = error.message;
} }
} }
$('refresh-events').onclick = refreshEvents;
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value }); $('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
$('zone-clear').onclick = () => action('/api/zone-clear'); $('zone-clear').onclick = () => action('/api/zone-clear');
$('test-trigger').onclick = () => action('/api/test-trigger'); $('test-trigger').onclick = () => action('/api/test-trigger');
@@ -292,9 +340,12 @@
if (!detectionConfig) return; if (!detectionConfig) return;
try { try {
detectionConfig.enabled = $('detection-enabled').checked; detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig.method = $('detection-method').value;
detectionConfig = await api('/api/detection/config', detectionConfig); detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection(); detectionEpoch++; people = []; drawDetection();
$('message').textContent = 'Gespeichert; freien Auslösebereich abwarten'; $('message').textContent = detectionConfig.method === 'motion_test'
? 'Testmodus gespeichert; Bild zwei Sekunden ruhig halten. Bei Stopp bewusst zurücksetzen.'
: 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; } } catch (error) { $('message').textContent = error.message; }
}; };
async function refreshDetection() { async function refreshDetection() {
@@ -304,9 +355,20 @@
if (!response.ok) throw new Error('Erkennung nicht erreichbar'); if (!response.ok) throw new Error('Erkennung nicht erreichbar');
const data = await response.json(), status = data.status; const data = await response.json(), status = data.status;
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; } if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('detection-status').textContent = status.error || const motionTest = status.method === 'motion_test';
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') + $('motion-details').hidden = !motionTest;
' · '+status.fps+' Auswertungen/s'; const motion = data.motion;
$('motion-details').textContent = motionTest && motion
? 'Bildänderung: '+(motion.changed_fraction*100).toLocaleString('de-DE', {maximumFractionDigits:2})+
' % · '+motion.note+' · Bestätigung '+motion.confirmed_frames+'/2'
: 'Warte auf Bewegungswerte';
const detail = motionTest && data.motion?.moving
? (data.motion.directed ? ' · Bewegung in Pfeilrichtung' : ' · Bewegung erkannt') : '';
$('detection-status').textContent = (motionTest ? 'Bewegungs-Test · ' : '') + (status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed
? (motionTest ? 'Bereit für Bewegung in Pfeilrichtung' : 'Bereit für Überquerung zum Grab')
: (motionTest ? 'Ruhiges Bild / Sperrzeit abwarten' : 'Freigabe / Sperrzeit abwarten'))) +
detail+' · '+status.fps+' Auswertungen/s';
if (data.image_base64) { if (data.image_base64) {
const image = new Image(); const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64; image.src = 'data:image/jpeg;base64,'+data.image_base64;
@@ -330,8 +392,16 @@
} }
setTimeout(refreshDetection, 200); setTimeout(refreshDetection, 200);
} }
function updateDetectionHelp() {
$('detection-help').textContent = $('detection-method').value === 'motion_test'
? 'Nur Test: Hand, Kopf oder Gegenstand im grünen Bereich in Pfeilrichtung bewegen. Keine Linienüberquerung nötig. Zuerst zwei Sekunden ruhig halten; danach zehn Sekunden Sperrzeit und erneut Ruhe. Benötigt OpenCV, kein Personenmodell. Auch Kamerabewegung kann auslösen. Ein ruhiges Bild bedeutet nicht, dass der Bereich menschenfrei ist.'
: 'Eine erkannte Person muss die Linie in Pfeilrichtung überqueren. Zuerst den Bereich zwei Sekunden freihalten. Benötigt OpenCV und das installierte Personenmodell.';
}
$('detection-method').onchange = updateDetectionHelp;
updateDetectionHelp();
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => { fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
detectionConfig = c; $('detection-enabled').checked = c.enabled; drawDetection(); detectionConfig = c; $('detection-enabled').checked = c.enabled;
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; }); }).catch(error => { $('detection-status').textContent = error.message; });
refreshDetection(); refreshDetection();
setInterval(drawDetection, 250); setInterval(drawDetection, 250);
+41 -1
View File
@@ -13,7 +13,8 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel. bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel.
Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn
`halloween.web:create_app` als Factory auf `0.0.0.0:8000` starten; `halloween.web:create_app` als Factory auf `0.0.0.0:8000` starten;
siehe `SOFTWARE.md`. `run.py` und systemd bleiben bei Loopback. siehe `SOFTWARE.md`. `run.py` bleibt bei Loopback; systemd nutzt die
beim Upload gewählte Bind-Adresse, initial Loopback.
- `event_store.py` verwaltet `data/events.sqlite3`. Erfolgreiche manuelle - `event_store.py` verwaltet `data/events.sqlite3`. Erfolgreiche manuelle
Testauslösungen werden als Ereignisse gespeichert; Profile besitzen Testauslösungen werden als Ereignisse gespeichert; Profile besitzen
Kennungen und können Ereignissen zugeordnet werden. Profil-Löschen entfernt Kennungen und können Ereignissen zugeordnet werden. Profil-Löschen entfernt
@@ -53,3 +54,42 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
mit Canvas; keine Tracking-ID als Besucherprofil verwenden. mit Canvas; keine Tracking-ID als Besucherprofil verwenden.
- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung. - Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung.
Die vorhandene Steuerung bleibt ausschließlich simuliert. Die vorhandene Steuerung bleibt ausschließlich simuliert.
## Bewegungs-Test
- `motion.py` bietet OpenCV-Optical-Flow im gewählten Bereich ohne Personenmodell.
Zwei aufeinanderfolgende gerichtete Bewegungen entlang der Pfeilnormalen
bestätigen einen Trigger; die Linie muss dabei nicht überquert werden.
- `method=motion_test` ausdrücklich als Test kennzeichnen; `person_line` bleibt
der Default und die Migration für alte gespeicherte Konfigurationen/Clients.
Beide Methoden speichern Geometrie/Aktivierung unter data/detection.json.
- Beim Test bedeutet Bereichsfreigabe zwei Sekunden Bildruhe, KEINE bestätigte
Personenfreiheit. Zehn Sekunden Sperrzeit, Stopp, bewusster Reset und Watchdog
beibehalten. Nach Konfigurations-/Controllerwechsel neue Bildbasis verlangen;
veraltete Inferenzfehler und Resultate dürfen den neuen Modus nicht beeinflussen.
- Ereignisse getrennt als motion_direction_test, ohne Profile; mit Auslösebild
gemäß den folgenden Bildregeln.
Aktoren bleiben simuliert; keine Zusage für Außenbetrieb, Licht oder Kostüme.
- Kleine Nahbereichbewegung nicht durch einen prozentual großen Mindestanteil
am gesamten Rechteck verwerfen; Schwellen mit kleinem/langsamem synthetischem
Stimulus regressionsprüfen, Gegenrichtung/Lichtwechsel weiter ablehnen.
- Bewegungswerte enthalten flüchtige Bildänderung, Hinweis und bestätigte
Frames. UI zeigt diese zur Diagnose und verwendet bei aktivierter Automatik
deren Freigabe für die Bereitschaftsanzeige. Manuelles Bereich-frei ersetzt
diese nicht; Sequenz, Stopp und Sperrzeit behalten ihren Vorrang.
## Auslösebilder
- Automatische akzeptierte Trigger speichern den grünen Bereich des auslösenden
Frames als JPEG unter data/event-images/, ohne EXIF/Originalbild außerhalb
des Rechtecks. Konfiguration beim Trigger kopieren; keine neuere Kameraaufnahme
oder später geänderte Geometrie verwenden. Manuelle Tests speichern kein Bild.
- SQLite migriert image_filename; öffentlicher Abruf nur über Ereignis-ID und
geprüfte generierte Dateinamen. Keine statische Freigabe des data/-Ordners.
Ordner 0700, Dateien 0600; Bilder nicht cachen. Bildspeicherung/SQLite außerhalb
des Detection-Locks, Fehler nutzen die bestehende Fehlersperre.
- Nutzerwahl: bis manuelle Löschung behalten. UI bietet Vorschau, Ansehen und
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.
+78
View File
@@ -0,0 +1,78 @@
"""Optischer Bewegungs-Test, ohne Personenmodell oder Besucherzuordnung."""
from io import BytesIO
from PIL import Image
class MotionDetector:
"""Bildbewegung im Bereich entlang der sichtbaren Pfeilnormalen prüfen.
Feste, konservative Startwerte für den Nahbereichstest, keine Zusage für
Außenbetrieb. Nur zwei aufeinanderfolgende gerichtete Beobachtungen lösen
aus. Ein ruhiges Bild bedeutet hier keine Personen-/Bereichsfreiheit.
"""
def __init__(self):
import cv2
import numpy as np
self.cv, self.np = cv2, np
self.previous = None
self.previous_at = None
self.directed_count = 0
def detect(self, jpeg, config, captured_at):
cv, np = self.cv, self.np
image = Image.open(BytesIO(jpeg)).convert('L')
scale = min(1., 240 / max(image.size))
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
gray = np.asarray(image.resize(size, Image.Resampling.BILINEAR))
height, width = gray.shape
x1, y1, x2, y2 = config['region']
region = gray[round(y1*height):round(y2*height), round(x1*width):round(x2*width)]
if min(region.shape) < 8:
raise ValueError('Bewegungs-Test: Bereich im Kamerabild zu klein')
previous, previous_at = self.previous, self.previous_at
self.previous = region.copy()
self.previous_at = captured_at
result = {'ready': False, 'moving': False, 'directed': False,
'trigger': False, 'vector': [0., 0.], 'changed_fraction': 0.,
'confirmed_frames': 0, 'note': 'Neue Bildbasis; kurz ruhig halten'}
if (previous is None or previous.shape != region.shape or
captured_at <= previous_at or captured_at - previous_at > 0.8):
self.directed_count = 0
return result
result['ready'] = True
changed = cv.absdiff(previous, region) > 12
changed_fraction = float(changed.mean())
result['changed_fraction'] = round(changed_fraction, 5)
# Small near-camera motions must not be diluted by a large quiet ROI.
result['moving'] = changed_fraction >= 0.001
if not result['moving']:
result['note'] = 'Bild ruhig / Änderung zu klein'
self.directed_count = 0
return result
flow = cv.calcOpticalFlowFarneback(previous, region, None,
0.5, 3, 15, 3, 5, 1.2, 0)
magnitude = np.linalg.norm(flow, axis=2)
# Only changed pixels with meaningful, bounded displacement vote.
mask = changed & (magnitude >= 0.35) & (magnitude <= 30)
vectors = flow[mask]
if len(vectors) < 6:
result['note'] = 'Bildänderung erkannt; Richtung noch nicht messbar'
self.directed_count = 0
return result
a, b = config['line']
dx, dy = (b[0]-a[0])*width, (b[1]-a[1])*height
normal = np.array([-dy, dx], dtype=np.float32)
normal *= config['direction'] / np.linalg.norm(normal)
projection = vectors @ normal
aligned = (projection >= 0.35) & (projection / np.linalg.norm(vectors, axis=1) >= 0.65)
result['vector'] = [round(float(v), 2) for v in np.median(vectors, axis=0)]
result['directed'] = bool(aligned.mean() >= 0.65)
self.directed_count = self.directed_count + 1 if result['directed'] else 0
result['confirmed_frames'] = min(2, self.directed_count)
result['note'] = ('Bewegung in Pfeilrichtung' if result['directed']
else 'Bewegung nicht in Pfeilrichtung')
result['trigger'] = self.directed_count >= 2
return result
+21
View File
@@ -126,6 +126,27 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
except sqlite3.Error as exc: except sqlite3.Error as exc:
raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc
@app.get("/api/events/{event_id}/image")
async def event_image(event_id: int):
try:
content = events.image_path(event_id).read_bytes()
except (KeyError, FileNotFoundError) as exc:
raise HTTPException(status_code=404, detail="Bild nicht vorhanden") from exc
except (OSError, sqlite3.Error) as exc:
raise HTTPException(status_code=503, detail="Bild nicht verfügbar") from exc
return Response(content, media_type="image/jpeg", headers={
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
})
@app.delete("/api/events/{event_id}/image", dependencies=[Depends(require_ui)])
async def delete_event_image(event_id: int):
try:
return {"deleted": events.delete_image(event_id)}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc.args[0])) from exc
except (OSError, sqlite3.Error) as exc:
raise HTTPException(status_code=503, detail="Bild konnte nicht gelöscht werden") from exc
@app.get("/api/profiles") @app.get("/api/profiles")
async def list_profiles(): async def list_profiles():
try: try:
+35
View File
@@ -24,3 +24,38 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
- `test_detection_model.py` prüft atomare/identische Installation und Ablehnung - `test_detection_model.py` prüft atomare/identische Installation und Ablehnung
falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine
geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft. geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft.
- `test_pi_update.py` prüft echte temporäre Versions-/Datenverzeichnisse mit
simuliertem systemd, Python-Paketsetup und HTTP-Ergebnis. Erfolgreichen Wechsel,
Start-/HTTP-/Abhängigkeitsfehler, Rückfall samt Daten, alte flache Installation,
Erstinstallation und Ablehnung fremder Units/manueller Server prüfen.
Kein SSH, kein echter Dienst, kein Netzwerk und keine Hardware in diesen Tests.
- `test_motion.py` nutzt synthetische JPEGs und optional OpenCV für echten
Optical Flow. Vorwärts/gegenläufig/parallel, Pfeilumkehr, gedrehte Geometrie,
Bildruhe/Lichtwechsel, Bereichsgrenzen und Bildlücken prüfen. Diensttests
prüfen Bestätigung, Sperrzeit, Stopp, Methodenmigration, Persistenz, verworfene
alte Inferenzfehler und modellfreie Verarbeitung. Ohne OpenCV werden die
Bildtests übersprungen; kein Ersatz für den Nahbereichstest am Pi.
- Nahbereichregression: kleiner 60x60-Bildausschnitt im 640x480-Frame,
2 Pixel Bewegung pro Bild; automatischen Trigger ohne manuelles Bereich-frei
und Ablehnung der Gegenrichtung prüfen.
- `node tests/test_ui_status.js` prüft den echten JS-Statusrenderer isoliert
ohne Browser/Netzwerk: automatische Freigabe vs. manuelle Bereich-frei-Flagge,
Vorrang von Stopp/Sequenz/Sperrzeit und wirkungslose Betriebsmodi.
- `test_event_images.py` prüft automatisch zugeordnete JPEGs mit synthetischem
Motiv: beschnittener privater Bereich, Dateirechte, Neustart, Migration alter
Ereignisse, DB-/Dateifehler, sichere ID-Abrufe, Header für Löschen und Erhalt
von Ereignis/Profil. Motion-Test prüft die exakten JPEG-Bytes des Trigger-Frames.
- `node tests/test_event_images_ui.js` prüft Vorschau/Ansehen und bestätigtes
Bildlöschen am echten JS-Renderer mit erfundenen Daten, ohne Browser oder Netz.
Abgebrochene Bestätigung löscht nichts; Ereignis bleibt nach Bildlöschung sichtbar.
- Update-Tests verlegen den festen Unitpfad nur in temporären Skriptkopien und
simulieren sudo/runuser. Zweiten sudo-Aufruf stets ablehnen, damit Erfolg und
Rückfall unabhängig von Passwort-Tickets geprüft werden. Abgelehnte Anmeldung,
unprivilegierten Aufruf des internen Rootteils und unveränderte Daten prüfen.
Kein echter root-/PAM-/systemd- oder Pi-Nachweis aus diesen Tests.
+2 -2
View File
@@ -200,7 +200,7 @@ class ServiceTest(unittest.TestCase):
def test_database_stall_does_not_block_watchdog(self): def test_database_stall_does_not_block_watchdog(self):
entered, release = threading.Event(), threading.Event() entered, release = threading.Event(), threading.Event()
def blocked_record(mode): def blocked_record(mode, **kwargs):
entered.set() entered.set()
release.wait(3) release.wait(3)
self.events.record_detection_trigger = blocked_record self.events.record_detection_trigger = blocked_record
@@ -220,7 +220,7 @@ class ServiceTest(unittest.TestCase):
self.assertFalse(worker.is_alive()) self.assertFalse(worker.is_alive())
def test_database_failure_stops_simulation(self): def test_database_failure_stops_simulation(self):
def broken_record(mode): def broken_record(mode, **kwargs):
raise OSError('disk full') raise OSError('disk full')
self.events.record_detection_trigger = broken_record self.events.record_detection_trigger = broken_record
self.free() self.free()
+137
View File
@@ -0,0 +1,137 @@
"""Local trigger pictures: synchronized crop, persistence, API and deletion."""
import asyncio
from io import BytesIO
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import httpx
from PIL import Image
from halloween.event_store import EventStore
from halloween.web import create_app
def sample():
image = Image.new('RGB', (100, 80), (20, 40, 180))
# Distinct central crop: outside the chosen area must not be recorded.
image.paste((180, 40, 20), (20, 16, 80, 64))
stream = BytesIO()
image.save(stream, 'JPEG', quality=95)
return stream.getvalue()
REGION = [.2, .2, .8, .8]
class StoreImageTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.root = Path(self.folder.name)
self.store = EventStore(self.root)
def test_automatic_kinds_persist_cropped_picture_after_restart(self):
for method in (self.store.record_detection_trigger, self.store.record_motion_trigger):
event_id = method('sanft', jpeg=sample(), region=REGION)
path = EventStore(self.root).image_path(event_id)
with Image.open(path) as image:
self.assertEqual(image.size, (60, 48))
self.assertEqual(image.format, 'JPEG')
self.assertGreater(image.getpixel((30, 24))[0], 150)
self.assertLess(image.getpixel((30, 24))[2], 50)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
event = self.store.recent()[0]
self.assertEqual(event['image_url'], f'/api/events/{event_id}/image')
self.assertNotIn('image_filename', event)
self.assertIsNone(event['profile_id'])
def test_delete_image_preserves_event_and_profile(self):
event_id = self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
profile = self.store.create_profile('Fiktiv')
self.store.assign_profile(event_id, profile['id'])
path = self.store.image_path(event_id)
self.assertTrue(self.store.delete_image(event_id))
self.assertFalse(path.exists())
event = EventStore(self.root).recent()[0]
self.assertIsNone(event['image_url'])
self.assertEqual(event['profile_id'], profile['id'])
self.assertFalse(self.store.delete_image(event_id))
with self.assertRaises(KeyError):
self.store.image_path(event_id)
def test_manual_events_and_migrated_old_events_have_no_picture(self):
event_id = self.store.record_test_trigger('sanft')
self.assertIsNone(self.store.recent()[0]['image_url'])
with self.assertRaises(KeyError):
self.store.image_path(event_id)
old_root = self.root / 'legacy'
old_root.mkdir()
with sqlite3.connect(old_root / 'events.sqlite3') as connection:
connection.execute('CREATE TABLE events (id INTEGER PRIMARY KEY, occurred_at TEXT, kind TEXT, mode TEXT)')
connection.execute("INSERT INTO events VALUES (1,'2026-10-14T10:00:00+00:00','test_trigger','sanft')")
old = EventStore(old_root)
self.assertIsNone(old.recent()[0]['image_url'])
self.assertEqual(old.recent()[0]['id'], 1)
new_id = old.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
self.assertTrue(old.image_path(new_id).exists())
def test_database_failure_removes_written_image(self):
with patch.object(self.store, '_connect', side_effect=sqlite3.OperationalError('synthetic DB failure')):
with self.assertRaises(sqlite3.Error):
self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
self.assertEqual(list(self.store.image_dir.iterdir()), [])
self.assertEqual(self.store.recent(), [])
def test_image_failure_leaves_no_temp_file_or_database_event(self):
jpeg = sample()
with patch.object(Image.Image, 'save', side_effect=OSError('synthetic disk full')):
with self.assertRaises(OSError):
self.store.record_motion_trigger('sanft', jpeg=jpeg, region=REGION)
self.assertEqual(self.store.recent(), [])
if self.store.image_dir.exists():
self.assertEqual(list(self.store.image_dir.iterdir()), [])
def test_tampered_database_cannot_read_or_delete_outside_image_dir(self):
outside = self.root / 'keep.txt'
outside.write_text('keep')
event_id = self.store.record_test_trigger('sanft')
with sqlite3.connect(self.store.path) as connection:
connection.execute('UPDATE events SET image_filename = ? WHERE id = ?', ('../keep.txt', event_id))
with self.assertRaises(KeyError):
self.store.image_path(event_id)
with self.assertRaises(KeyError):
self.store.delete_image(event_id)
self.assertEqual(outside.read_text(), 'keep')
self.assertIsNone(self.store.recent()[0]['image_url'])
class ImageApiTest(unittest.TestCase):
def test_view_and_delete_image_without_affecting_event(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app = create_app(demo=True, state_dir=Path(folder))
event_id = app.state.events.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
url = (await client.get('/api/events')).json()['events'][0]['image_url']
image = await client.get(url)
self.assertEqual(image.status_code, 200)
self.assertEqual(image.headers['content-type'], 'image/jpeg')
self.assertEqual(image.headers['cache-control'], 'no-store')
self.assertEqual(image.headers['x-content-type-options'], 'nosniff')
self.assertEqual((await client.get('/api/events/999/image')).status_code, 404)
self.assertEqual((await client.delete(url)).status_code, 403)
self.assertEqual((await client.delete(url, headers={'X-Halloween-UI':'1'})).status_code, 200)
self.assertEqual((await client.get(url)).status_code, 404)
event = (await client.get('/api/events')).json()['events'][0]
self.assertEqual(event['id'], event_id)
self.assertIsNone(event['image_url'])
asyncio.run(scenario())
if __name__ == '__main__':
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
// Verify picture controls on real event-list renderer using fabricated records.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../halloween/index.html'), 'utf8');
const renderer = html.slice(html.indexOf(' async function refreshEvents() {'), html.indexOf(' async function refreshDb() {'));
class Element {
constructor(tag) {this.tag=tag; this.children=[]; this.textContent='';}
append(...children) {this.children.push(...children);}
prepend(...children) {this.children.unshift(...children);}
add(child) {this.append(child);}
setAttribute(name,value) {this[name]=value;}
replaceChildren() {this.children=[];}
}
(async () => {
const list = new Element('ul');
let allowDelete = false, deleted = false;
const calls = [];
const record = {id:1, occurred_at:'2026-10-14T10:00:00+00:00', kind:'motion_direction_test', mode:'sanft', profile_id:null};
const context = vm.createContext({
document:{createElement:tag => new Element(tag)},
$:id => id === 'events-list' ? list : new Element('p'),
profiles:[], names:{sanft:'Sanft'}, Option:class {},
confirm:() => allowDelete,
api:async (url, body, method) => {calls.push({url,method}); deleted=true;},
fetch:async () => ({ok:true, json:async () => ({events:[{...record,image_url:deleted ? null : '/api/events/1/image'}]})})
});
vm.runInContext(renderer, context);
await vm.runInContext('refreshEvents()', context);
let item = list.children[0];
const link = item.children.find(element => element.tag === 'a');
assert.equal(link.href, '/api/events/1/image');
assert.equal(link.textContent, 'Bild ansehen');
assert.equal(link.children[0].src, link.href);
assert.equal(link.children[0].loading, 'lazy');
const remove = item.children.find(element => element.textContent === 'Bild löschen');
await remove.onclick();
assert.equal(calls.length, 0, 'Cancel never deletes a picture');
allowDelete=true;
await remove.onclick();
assert.deepEqual(calls, [{url:'/api/events/1/image',method:'DELETE'}]);
item=list.children[0];
assert.equal(item.children.some(element => element.tag === 'a'), false);
assert.ok(item.children.some(element => element.textContent.includes('Kein gespeichertes Bild')));
assert.ok(item.children.some(element => element.textContent.includes('#1')));
console.log('Event image UI tests passed');
})().catch(error => {console.error(error); process.exitCode=1;});
+283
View File
@@ -0,0 +1,283 @@
"""Directional motion with synthetic images; no persons, model or Pi hardware."""
import asyncio
import importlib.util
from io import BytesIO
import json
from pathlib import Path
import tempfile
import threading
import unittest
import httpx
from PIL import Image
from halloween.controller import Controller
from halloween.detection import DEFAULT_CONFIG, DetectionService, validate_config
from halloween.event_store import EventStore
from halloween.motion import MotionDetector
from halloween.web import create_app
HAS_OPENCV = importlib.util.find_spec('cv2') is not None
CONFIG = {**DEFAULT_CONFIG, 'enabled': True, 'method': 'motion_test'}
def scene(x=85, y=35, brightness=0):
import numpy as np
rng = np.random.default_rng(91)
frame = np.full((180, 240), 45 + brightness, dtype=np.uint8)
texture = rng.integers(70, 210, size=(45, 50), dtype=np.uint8)
frame[y:y+45, x:x+50] = texture + brightness
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
def small_scene(x=210):
"""Small near-camera patch moving slowly in a full 640x480 frame."""
import numpy as np
rng = np.random.default_rng(121)
frame = np.full((480, 640), 95, dtype=np.uint8)
texture = rng.integers(110, 165, size=(60, 60), dtype=np.uint8)
frame[150:210, x:x+60] = texture
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
class ConfigurationTest(unittest.TestCase):
def test_old_config_retains_enabled_geometry_and_person_method(self):
old = {k: v for k, v in DEFAULT_CONFIG.items() if k != 'method'}
old['enabled'] = True
loaded = validate_config(old)
self.assertEqual(loaded['method'], 'person_line')
self.assertTrue(loaded['enabled'])
self.assertEqual(loaded['line'], old['line'])
self.assertNotIn('method', old)
def test_unknown_method_rejected(self):
with self.assertRaises(ValueError):
validate_config({**CONFIG, 'method': 'face'})
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class OpticalMotionTest(unittest.TestCase):
def detect_sequence(self, frames, config=CONFIG):
detector = MotionDetector()
return [detector.detect(frame, config, index*0.2) for index, frame in enumerate(frames)]
def test_forward_motion_without_line_crossing(self):
# Box stays entirely above the horizontal line at y=.55.
result = self.detect_sequence([scene(y=y) for y in (32, 36, 40, 44)])
self.assertFalse(result[0]['ready'])
self.assertFalse(result[1]['trigger'])
self.assertTrue(result[-1]['trigger'], result)
def test_reverse_and_parallel_do_not_trigger(self):
for frames in ([scene(y=y) for y in (44, 40, 36, 32)],
[scene(x=x) for x in (70, 74, 78, 82)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_arrow_can_be_reversed_or_rotated(self):
reverse = self.detect_sequence([scene(y=y) for y in (44, 40, 36, 32)],
{**CONFIG, 'direction': -1})
self.assertTrue(reverse[-1]['trigger'], reverse)
# Vertical line from bottom to top gives a rightward arrow.
rotated = self.detect_sequence([scene(x=x) for x in (70, 74, 78, 82)],
{**CONFIG, 'line': [[.55, .8], [.55, .2]]})
self.assertTrue(rotated[-1]['trigger'], rotated)
def test_static_scene_and_light_change_do_not_trigger(self):
for frames in ([scene()]*6, [scene(brightness=b) for b in (0, 25, 0, 25, 0)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
def test_motion_outside_region_is_ignored(self):
result = self.detect_sequence([scene(x=5, y=y) for y in (35, 39, 43, 47)],
{**CONFIG, 'region': [.3, .15, .9, .95]})
self.assertFalse(any(r['moving'] or r['trigger'] for r in result), result)
def test_small_slow_rightward_change_in_large_region(self):
result = self.detect_sequence([small_scene(x=x) for x in (210, 212, 214, 216, 218)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertTrue(result[-1]['trigger'], result)
def test_small_slow_reverse_change_does_not_trigger(self):
result = self.detect_sequence([small_scene(x=x) for x in (218, 216, 214, 212, 210)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_single_frame_or_camera_gap_cannot_trigger(self):
result = self.detect_sequence([scene(y=32), scene(y=36), scene(y=36)])
self.assertFalse(any(r['trigger'] for r in result), result)
detector = MotionDetector()
detector.detect(scene(y=32), CONFIG, 0)
detector.detect(scene(y=36), CONFIG, .2)
result = detector.detect(scene(y=40), CONFIG, 1.2)
self.assertFalse(result['ready'])
self.assertFalse(result['trigger'])
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class MotionServiceTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.directory = Path(self.folder.name)
self.time = 0
self.number = 0
self.controller = Controller(self.directory, clock=lambda: self.time)
self.events = EventStore(self.directory)
self.camera = type('Camera', (), {'error': None})()
self.service = DetectionService(self.camera, self.controller, self.events,
self.directory, clock=lambda: self.time)
self.service.configure(CONFIG)
self.detector = MotionDetector()
def step(self, x=85, y=35, dt=.2):
self.time += dt
self.number += 1
jpeg = scene(x, y)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
def quiet(self, y=35):
for _ in range(13):
self.step(y=y)
def move(self):
for y in (35, 39, 43, 47):
self.step(y=y)
def test_initial_quiet_cooldown_and_distinct_event(self):
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
self.assertTrue(self.service.status()['armed'])
self.move()
event = self.events.recent()[0]
self.assertEqual(event['kind'], 'motion_direction_test')
self.assertIsNone(event['profile_id'])
self.assertTrue(event['image_url'])
with Image.open(self.events.image_path(event['id'])) as saved:
self.assertEqual(saved.size, (192, 144))
expected = Image.open(BytesIO(scene(y=43))).crop((24, 27, 216, 171)).convert('RGB')
self.assertEqual(saved.size, expected.size)
encoded = BytesIO()
expected.save(encoded, format='JPEG', quality=90)
self.assertEqual(self.events.image_path(event['id']).read_bytes(), encoded.getvalue())
self.assertEqual(self.service.snapshot()['people'], [])
self.assertTrue(self.service.snapshot()['motion']['directed'])
self.quiet()
self.move()
self.assertEqual(len(self.events.recent()), 1)
self.quiet()
# Wait out remaining cooldown with valid quiet frames.
for _ in range(30):
self.step()
self.move()
self.assertEqual(len(self.events.recent()), 2)
def test_small_motion_triggers_without_manual_clear(self):
self.service.configure({**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.detector = MotionDetector()
def step(x):
self.time += .2
self.number += 1
jpeg = small_scene(x)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
for _ in range(13):
step(210)
self.assertTrue(self.service.status()['armed'])
self.assertFalse(self.controller.zone_clear_seen)
for x in (212, 214, 216):
step(x)
self.assertEqual(len(self.events.recent()), 1)
self.assertEqual(self.events.recent()[0]['kind'], 'motion_direction_test')
self.assertEqual(self.service.snapshot()['motion']['confirmed_frames'], 2)
def test_stop_watchdog_and_stale_inflight_frame(self):
self.quiet()
self.controller.stop()
self.move()
self.assertEqual(self.events.recent(), [])
self.controller.reset()
self.service.reset()
self.detector = MotionDetector()
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
old_revision = self.service.revision
self.service.configure(CONFIG)
self.service.process([], 99, scene(), self.time, old_revision,
{'ready': True, 'moving': True, 'trigger': True})
self.assertIsNone(self.service.last_valid)
self.time += 2.1
self.service.watchdog()
self.assertTrue(self.controller.stopped)
self.assertTrue(self.service.status()['reset_required'])
def test_restart_preserves_method_but_not_arming(self):
self.quiet()
other = DetectionService(self.camera, self.controller, self.events, self.directory)
self.assertEqual(other.get_config()['method'], 'motion_test')
self.assertTrue(other.get_config()['enabled'])
self.assertFalse(other.status()['armed'])
def test_switch_discards_old_model_failure(self):
entered, release = threading.Event(), threading.Event()
self.service.configure({**DEFAULT_CONFIG, 'enabled': False})
def old_model(path):
entered.set()
release.wait(3)
raise OSError('old model failure')
self.service.factory = old_model
self.camera.latest_timed = lambda: (1, scene(), self.time)
self.service.start()
try:
self.assertTrue(entered.wait(1))
self.service.configure(CONFIG)
release.set()
for _ in range(40):
if self.service.last_valid is not None:
break
threading.Event().wait(.05)
self.assertIsNotNone(self.service.last_valid)
self.assertFalse(self.service.fault)
self.assertFalse(self.controller.stopped)
finally:
release.set()
self.service.stop()
def test_worker_uses_motion_without_person_model(self):
async def scenario():
app = create_app(demo=True, state_dir=self.directory)
app.state.detection.configure(CONFIG)
def no_person_model(path):
raise AssertionError('Person model must not be loaded for motion test')
app.state.detection.factory = no_person_model
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
for _ in range(30):
await asyncio.sleep(.05)
result = (await client.get('/api/detection')).json()
if result['motion'] and result['motion']['ready']:
break
self.assertTrue(result['status']['ready'], result)
self.assertEqual(result['status']['method'], 'motion_test')
self.assertFalse(result['status']['reset_required'], result)
self.assertIsNotNone(result['motion'])
self.assertEqual(result['people'], [])
asyncio.run(scenario())
if __name__ == '__main__':
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""Filesystem deployment tests with simulated systemd, Python setup and health.
No SSH, package downloads, real services or Pi hardware are used.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PROJECT = Path(__file__).resolve().parents[1]
class PiUpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='halloween-update-')
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.root = self.base / 'pi'
self.bin = self.base / 'bin'
self.bin.mkdir()
self.root.mkdir()
(self.root / 'data').mkdir()
(self.root / 'data/settings.json').write_text('original settings')
(self.root / 'data/events.sqlite3').write_bytes(b'original database')
self.new = self.root / 'releases/new'
self.new.mkdir(parents=True)
shutil.copytree(PROJECT / 'tools', self.new / 'tools')
(self.new / 'halloween').mkdir()
for file in ('run.py', 'requirements.txt'):
shutil.copy(PROJECT / file, self.new / file)
self.old = self.root / 'releases/old'
self.old.mkdir()
(self.old / 'run.py').write_text('# old version')
(self.root / 'current').symlink_to(self.old)
self.unit = self.base / 'unit'
self.unit.write_text('# Managed by Halloween project: tools/install_pi_service.sh\nold unit\n')
self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}',
TEST_BASE=str(self.base), TEST_ROOT=str(self.root))
# Relocate the fixed unit path only in temporary test copies. No root
# commands run and no host /etc files are ever accessed by these tests.
for script in ('activate_pi_release.sh', 'install_pi_service.sh'):
file = self.new / 'tools' / script
contents = file.read_text()
contents = contents.replace('unit=/etc/systemd/system/$service', f'unit={self.unit}')
contents = contents.replace('unit_path="/etc/systemd/system/$service_name"', f'unit_path="{self.unit}"')
file.write_text(contents)
self.script('id', '''
if [[ $1 == -u ]]; then
if [[ ${TEST_PRIVILEGED:-0} == 1 ]]; then echo 0; else echo 1000; fi
else
echo piuser
fi
''')
self.env['TEST_SUDO_UID'] = str(os.getuid())
self.env['TEST_SUDO_GID'] = str(os.getgid())
self.script('sudo', '''
echo called >> "$TEST_BASE/sudo.log"
if [[ -f $TEST_BASE/deny-sudo ]]; then exit 1; fi
# Fail a second sudo call, simulating an expired/zero-lifetime ticket.
if [[ $(wc -l < "$TEST_BASE/sudo.log") != 1 ]]; then exit 1; fi
export TEST_PRIVILEGED=1 SUDO_USER=piuser SUDO_UID="$TEST_SUDO_UID" SUDO_GID="$TEST_SUDO_GID"
exec "$@"
''')
self.script('runuser', '''
[[ $1 == -u && $2 == piuser && $3 == -- ]] || exit 2
shift 3
export TEST_PRIVILEGED=0
exec "$@"
''')
self.script('systemctl', '''
echo "$*" >> "$TEST_BASE/systemctl.log"
case $1 in
is-enabled|is-active|daemon-reload|enable|disable|stop) exit 0 ;;
restart)
if [[ -f $TEST_BASE/fail-start && $(readlink "$TEST_ROOT/current") == */new ]]; then
echo changed > "$TEST_ROOT/data/events.sqlite3"
exit 1
fi ;;
esac
''')
self.script('python3', '''
if [[ $1 != -m || $2 != venv ]]; then exit 2; fi
mkdir -p "$4/bin"
cp "$TEST_BASE/bin/fake-python" "$4/bin/python"
''')
self.script('fake-python', '''
if [[ $1 == -m && $2 == pip && -f $TEST_BASE/fail-deps ]]; then exit 1; fi
if [[ $1 == - ]]; then
code=$(cat)
if [[ $code == *connect_ex* && -f $TEST_BASE/port-busy ]]; then exit 1; fi
if [[ $code == *urlopen* && -f $TEST_BASE/fail-all-health ]]; then exit 1; fi
if [[ $code == *urlopen* && -f $TEST_BASE/fail-health && $(readlink "$TEST_ROOT/current") == */new ]]; then
echo migrated > "$TEST_ROOT/data/events.sqlite3"
exit 1
fi
fi
exit 0
''')
def script(self, name, content):
path = self.bin / name
path.write_text('#!/usr/bin/env bash\nset -e\n' + content + '\n')
path.chmod(0o755)
def activate(self):
return subprocess.run(['bash', str(self.new / 'tools/activate_pi_release.sh'),
str(self.root), 'new', '0.0.0.0'],
env=self.env, capture_output=True, text=True)
def assert_single_sudo(self):
self.assertEqual((self.base / 'sudo.log').read_text().splitlines(), ['called'])
def assert_data(self):
self.assertEqual((self.root / 'data/settings.json').read_text(), 'original settings')
self.assertEqual((self.root / 'data/events.sqlite3').read_bytes(), b'original database')
def test_success_preserves_data_and_enables_autostart(self):
result = self.activate()
self.assertEqual(result.returncode, 0, result.stderr)
self.assert_single_sudo()
self.assertEqual((self.root / 'current').resolve(), self.new)
self.assert_data()
self.assertIn(f'Environment=HALLOWEEN_STATE_DIR={self.root}/data', self.unit.read_text())
self.assertIn('--host 0.0.0.0', self.unit.read_text())
self.assertIn('enable halloween-grab.service', (self.base / 'systemctl.log').read_text())
def test_start_and_health_failure_restore_code_unit_and_data(self):
for failure in ('fail-start', 'fail-health'):
with self.subTest(failure=failure):
(self.base / failure).touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertIn('old unit', self.unit.read_text())
self.assertIn('Rückfall abgeschlossen', result.stderr)
self.assert_single_sudo()
(self.base / failure).unlink()
shutil.rmtree(self.root / 'backups/new')
(self.base / 'sudo.log').unlink()
def test_dependency_failure_leaves_service_running(self):
(self.base / 'fail-deps').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertFalse((self.base / 'systemctl.log').exists())
self.assertFalse((self.base / 'sudo.log').exists())
def test_first_install_failure_has_no_previous_version(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.base / 'fail-health').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.unit.exists())
self.assertFalse((self.root / 'current').is_symlink())
self.assert_data()
def test_manual_server_blocks_first_install_without_stopping_it(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.base / 'port-busy').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_data()
def test_flat_installation_is_available_for_rollback(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.root / 'run.py').write_text('# legacy version')
(self.root / '.venv/bin').mkdir(parents=True)
shutil.copy(self.bin / 'fake-python', self.root / '.venv/bin/python')
(self.base / 'fail-start').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.root)
self.assertIn(str(self.root / 'current/.venv/bin/python'), self.unit.read_text())
self.assert_data()
self.assertIn('Rückfall abgeschlossen', result.stderr)
def test_first_install_success(self):
self.unit.unlink()
(self.root / 'current').unlink()
result = self.activate()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'current').resolve(), self.new)
self.assert_data()
def test_failed_rollback_health_is_reported(self):
(self.base / 'fail-all-health').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertIn('Rückfall unvollständig', result.stderr)
self.assert_data()
def test_upload_preview_never_connects(self):
for name in ('ssh', 'rsync'):
self.script(name, 'echo called > "$TEST_BASE/connected"; exit 1')
# Use real Python for the local release ID; no Pi package setup in preview.
(self.bin / 'python3').unlink()
result = subprocess.run(['bash', str(PROJECT / 'tools/upload_to_pi.sh'),
'--user', 'piuser', '--host', 'pi.local',
'--remote-dir', '/home/piuser/halloween'],
env=self.env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.base / 'connected').exists())
self.assertIn('releases/', result.stdout)
def test_denied_sudo_never_stops_service_or_changes_active_data(self):
(self.base / 'deny-sudo').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_single_sudo()
def test_privileged_entry_rejects_direct_unprivileged_call(self):
result = subprocess.run(['bash', str(self.new / 'tools/activate_pi_release.sh'),
'--privileged', str(self.root), 'new', '0.0.0.0'],
env=self.env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_data()
def test_foreign_unit_is_untouched(self):
self.unit.write_text('foreign unit')
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.unit.read_text(), 'foreign unit')
self.assertEqual((self.root / 'current').resolve(), self.old)
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
// Exercise the real status renderer without a browser, network or camera.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../halloween/index.html'), 'utf8');
const detectionRefresh = html.slice(html.indexOf(' async function refreshDetection() {'), html.indexOf(' function updateDetectionHelp()'));
const refresh = html.slice(html.indexOf(' async function refresh() {'), html.indexOf(' async function action('));
async function render(controller, detection) {
const nodes = {};
const context = vm.createContext({
document: {activeElement: null},
$: id => nodes[id] || (nodes[id] = {}),
names: {sanft: 'Sanft'},
fetch: async () => ({json: async () => ({controller, detection, camera: {ready:true, source:'Test'}})})
});
vm.runInContext(refresh, context);
await vm.runInContext('refresh()', context);
return nodes;
}
(async () => {
const controller = {mode:'sanft', phase:'Bereich frei abwarten', cooldown_seconds:0};
const ready = {enabled:true, ready:true, armed:true, method:'motion_test'};
let nodes = await render(controller, ready);
assert.equal(nodes.phase.textContent, 'Bereit (Automatik)', 'Automatic ready does not need manual clear');
nodes = await render({...controller, phase:'Bereit'}, {...ready, armed:false});
assert.equal(nodes.phase.textContent, 'Ruhiges Bild abwarten (Automatik)', 'Manual clear cannot claim automatic ready');
for (const phase of ['Stopp', 'Sperrzeit', 'Sanft (simuliert)']) {
nodes = await render({...controller, phase}, ready);
assert.equal(nodes.phase.textContent, phase, 'Preserve actual sequence/stop/cooldown');
}
nodes = await render({...controller, mode:'aus'}, ready);
assert.equal(nodes.phase.textContent, 'Modus ohne Schreckauslösung');
nodes = await render(controller, {...ready, reset_required:true});
assert.equal(nodes.phase.textContent, 'Erkennung gesperrt; zurücksetzen');
nodes = await render(controller, {...ready, enabled:false});
assert.equal(nodes.phase.textContent, controller.phase, 'Manual mode retains its own clear requirement');
const motionNodes = {};
const motionContext = vm.createContext({
$: id => motionNodes[id] || (motionNodes[id] = {}),
detectionEpoch:0, performance:{now:() => 0}, people:[], processed:false,
setTimeout:() => {}, drawDetection:() => {},
fetch:async () => ({ok:true, json:async () => ({
status:{...ready, fps:5}, image_base64:null,
motion:{changed_fraction:.0014, note:'Bewegung in Pfeilrichtung', confirmed_frames:2, moving:true, directed:true}
})})
});
vm.runInContext(detectionRefresh, motionContext);
await vm.runInContext('refreshDetection()', motionContext);
assert.equal(motionNodes['motion-details'].hidden, false);
assert.match(motionNodes['motion-details'].textContent, /0,14 %/);
assert.match(motionNodes['motion-details'].textContent, /Bewegung in Pfeilrichtung/);
assert.match(motionNodes['motion-details'].textContent, /Bestätigung 2\/2/);
console.log('UI status tests passed');
})().catch(error => {console.error(error); process.exitCode = 1;});
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Runs on the Pi, after rsync has completed. No secrets or device defaults.
set -euo pipefail
privileged=false
if [[ ${1:-} == --privileged ]]; then privileged=true; shift; fi
root=${1:?Basisordner fehlt}
release=${2:?Version fehlt}
bind_host=${3:-}
[[ $root =~ ^/[a-zA-Z0-9_./-]+$ && $root != / && /$root/ != *'/../'* && /$root/ != *'/./'* ]] || exit 2
[[ $release =~ ^[a-zA-Z0-9_-]+$ ]] || exit 2
[[ -z $bind_host || $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || exit 2
candidate="$root/releases/$release"
service=halloween-grab.service
unit=/etc/systemd/system/$service
if ! $privileged; then
exec 9>"$root/.update.lock"
flock -n 9 || { echo 'Ein anderes Update läuft.' >&2; exit 2; }
else
[[ $(id -u) == 0 && ${SUDO_USER:-} =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ && $SUDO_USER != root ]] || {
echo 'Privilegierter Teil darf nur vom Upload über sudo gestartet werden.' >&2; exit 2;
}
fi
[[ -f $candidate/run.py && ! -L $candidate ]] || exit 2
[[ ! -e $root/current || -L $root/current ]] || { echo 'current muss ein Symlink sein.' >&2; exit 2; }
previous=
if [[ -L $root/current ]]; then
previous=$(readlink -f "$root/current")
[[ -d $previous && ( $previous == "$root" || $previous == "$root/releases/"* ) ]] || {
echo 'Ungültiges Ziel von current.' >&2; exit 2;
}
fi
if [[ -z $previous && -f $root/run.py && -x $root/.venv/bin/python ]]; then
previous=$root # Existing flat installation remains untouched as rollback target.
fi
[[ ! -L $root/data ]] || { echo 'data darf kein Symlink sein.' >&2; exit 2; }
mkdir -p "$root/data" "$root/backups"
chmod 700 "$root/backups"
backup="$root/backups/$release"
if [[ -f $root/service-host ]]; then old_host=$(cat "$root/service-host"); else old_host=127.0.0.1; fi
bind_host=${bind_host:-$old_host}
[[ $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || exit 2
if ! $privileged; then
[[ $(id -un) != root ]] || { echo 'Update als normalen Pi-Benutzer starten.' >&2; exit 2; }
# Prepare dependencies as the Pi user, before the single sudo invocation.
# The parent retains the update lock while sudo and its child are running.
python3 -m venv --system-site-packages "$candidate/.venv"
"$candidate/.venv/bin/python" -m pip install --only-binary=:all: -r "$candidate/requirements.txt"
"$candidate/.venv/bin/python" -c 'import fastapi, uvicorn, PIL, picamera2'
"$candidate/.venv/bin/python" -m compileall -q "$candidate/halloween" "$candidate/run.py"
sudo bash "$candidate/tools/activate_pi_release.sh" --privileged "$root" "$release" "$bind_host"
exit $?
fi
# All systemd/root filesystem work, including rollback, runs inside this one
# sudo process. No sudo timestamp, keepalive, password pipe or sudoers exception.
command -v runuser >/dev/null || { echo 'runuser fehlt auf dem Pi (util-linux).' >&2; exit 2; }
as_user() {
runuser -u "$SUDO_USER" -- "$@"
}
mkdir "$backup"
chown "$SUDO_UID:$SUDO_GID" "$backup"
had_unit=false
was_enabled=false
if test -e "$unit"; then
grep -qF '# Managed by Halloween project: tools/install_pi_service.sh' "$unit" || {
echo 'Fremde systemd-Unit wird nicht verändert.' >&2; exit 2;
}
had_unit=true
cat "$unit" > "$backup/service.unit"
chmod 600 "$backup/service.unit"
chown "$SUDO_UID:$SUDO_GID" "$backup/service.unit"
if systemctl is-enabled --quiet "$service"; then was_enabled=true; fi
fi
install_service() {
local host=$1 temporary
temporary=$(mktemp) || return 1
# Render the unit and inspect user-owned project files without root privileges.
if ! as_user bash "$candidate/tools/install_pi_service.sh" --project-dir "$root" --host "$host" --print-unit > "$temporary"; then
rm -f "$temporary"
return 1
fi
if ! install -m 0644 "$temporary" "$unit"; then
rm -f "$temporary"
return 1
fi
rm -f "$temporary" || return 1
systemctl daemon-reload && systemctl enable "$service" &&
systemctl restart "$service" && systemctl is-active --quiet "$service"
}
switch_link() {
ln -s "$1" "$root/.current-$release"
mv -Tf "$root/.current-$release" "$root/current" &&
chown -h "$SUDO_UID:$SUDO_GID" "$root/current"
}
check_health() {
as_user "$candidate/.venv/bin/python" - <<'PY' || return 1
import json, time, urllib.request
for attempt in range(30):
try:
with urllib.request.urlopen('http://127.0.0.1:8000/api/status', timeout=1) as response:
value = json.load(response)
if isinstance(value, dict) and isinstance(value.get('controller'), dict) and isinstance(value.get('camera'), dict):
break
except Exception:
pass
time.sleep(1)
else:
raise SystemExit('WebUI antwortet nicht innerhalb der Startfrist.')
PY
systemctl is-active --quiet "$service"
}
transaction=false
data_saved=false
rollback() {
status=$?
trap - EXIT INT TERM
if $transaction; then
set +e
echo 'Update fehlgeschlagen: vorherigen Stand wiederherstellen.' >&2
rollback_ok=true
if ! systemctl stop "$service"; then
echo "Dienst lässt sich nicht stoppen; Daten nicht zurückgespielt. Sicherung: $backup" >&2
exit 1
fi
if $data_saved; then
mv "$root/data" "$backup/failed-data" && cp -a "$backup/data" "$root/data" || rollback_ok=false
fi
if [[ -n $previous ]]; then switch_link "$previous" || rollback_ok=false; else rm -f "$root/current"; fi
printf '%s\n' "$old_host" > "$root/service-host" &&
chown "$SUDO_UID:$SUDO_GID" "$root/service-host" || rollback_ok=false
if $had_unit; then
install -m 0644 "$backup/service.unit" "$unit" || rollback_ok=false
else
systemctl disable "$service" || rollback_ok=false
rm -f "$unit" || rollback_ok=false
fi
systemctl daemon-reload || rollback_ok=false
if $had_unit; then
if ! $was_enabled; then systemctl disable "$service" || rollback_ok=false; fi
systemctl restart "$service" && check_health || rollback_ok=false
elif [[ -n $previous ]]; then
install_service "$old_host" && check_health || rollback_ok=false
fi
if $rollback_ok; then echo 'Rückfall abgeschlossen.' >&2; else echo "Rückfall unvollständig! Dienst und Sicherung $backup prüfen." >&2; fi
exit 1
fi
exit "$status"
}
trap rollback EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if $had_unit; then transaction=true; systemctl stop "$service"; fi
# Refuse an unmanaged server, rather than claiming its response as update success.
as_user "$candidate/.venv/bin/python" - <<'PY'
import socket
with socket.socket() as sock:
if sock.connect_ex(('127.0.0.1', 8000)) == 0:
raise SystemExit('Port 8000 ist noch belegt. Manuell gestartete WebUI zuerst beenden.')
PY
cp -a "$root/data" "$backup/data"
data_saved=true
transaction=true
switch_link "$candidate"
printf '%s\n' "$bind_host" > "$root/service-host"
chown "$SUDO_UID:$SUDO_GID" "$root/service-host"
install_service "$bind_host"
check_health
transaction=false
printf 'Version %s aktiv; WebUI-Autostart eingeschaltet. Sicherung: %s\n' "$release" "$backup"
+27 -7
View File
@@ -5,15 +5,19 @@ project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
service_name=halloween-grab.service service_name=halloween-grab.service
unit_path="/etc/systemd/system/$service_name" unit_path="/etc/systemd/system/$service_name"
apply=false apply=false
print_unit=false
bind_host=127.0.0.1
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Richtet die Halloween-WebUI als systemd-Dienst auf dem Raspberry Pi ein. Richtet die Halloween-WebUI als systemd-Dienst auf dem Raspberry Pi ein.
Aufruf: bash tools/install_pi_service.sh [--apply] Aufruf: bash tools/install_pi_service.sh [--project-dir BASISORDNER] [--host 127.0.0.1|0.0.0.0] [--apply]
Ohne --apply wird nur die geplante Unit gezeigt. --apply installiert sie mit Ohne --apply wird nur die geplante Unit gezeigt. --apply installiert sie mit
sudo, aktiviert den Autostart und startet die WebUI sofort neu. sudo, aktiviert den Autostart und startet die WebUI sofort neu.
--print-unit gibt nur die Unit auf stdout aus; interner Renderer für den Upload,
ohne Installation, Paketprüfung oder sudo.
EOF EOF
} }
@@ -24,6 +28,11 @@ die() {
while (($#)); do while (($#)); do
case $1 in case $1 in
--project-dir|--host)
(($# >= 2)) || die "Wert für $1 fehlt"
if [[ $1 == --project-dir ]]; then project_dir=$2; else bind_host=$2; fi
shift 2 ;;
--print-unit) print_unit=true; shift ;;
--apply) apply=true; shift ;; --apply) apply=true; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) die "Unbekanntes Argument: $1" ;; *) die "Unbekanntes Argument: $1" ;;
@@ -33,9 +42,13 @@ done
service_user=$(id -un) service_user=$(id -un)
[[ $service_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Ungeeigneter Benutzername für systemd' [[ $service_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Ungeeigneter Benutzername für systemd'
[[ $service_user != root ]] || die 'Skript als normaler Pi-Benutzer ausführen, nicht als root' [[ $service_user != root ]] || die 'Skript als normaler Pi-Benutzer ausführen, nicht als root'
[[ $project_dir =~ ^/[a-zA-Z0-9_./-]+$ ]] || die 'Projektpfad enthält für die Unit ungeeignete Zeichen' [[ $project_dir =~ ^/[a-zA-Z0-9_./-]+$ && $project_dir != / ]] || die 'Projektpfad enthält für die Unit ungeeignete Zeichen'
[[ /$project_dir/ != *'/../'* && /$project_dir/ != *'/./'* ]] || die 'Projektpfad enthält . oder .. Segmente' [[ /$project_dir/ != *'/../'* && /$project_dir/ != *'/./'* ]] || die 'Projektpfad enthält . oder .. Segmente'
[[ $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || die 'Ungültige Bind-Adresse'
code_dir=$project_dir
if [[ -L $project_dir/current ]]; then code_dir=$project_dir/current; fi
unit_contents=$(cat <<EOF unit_contents=$(cat <<EOF
# Managed by Halloween project: tools/install_pi_service.sh # Managed by Halloween project: tools/install_pi_service.sh
[Unit] [Unit]
@@ -45,8 +58,9 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
User=$service_user User=$service_user
WorkingDirectory=$project_dir WorkingDirectory=$code_dir
ExecStart=$project_dir/.venv/bin/python $project_dir/run.py ExecStart=$code_dir/.venv/bin/python -m uvicorn halloween.web:create_app --factory --host $bind_host --port 8000
Environment=HALLOWEEN_STATE_DIR=$project_dir/data
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
@@ -56,6 +70,12 @@ WantedBy=multi-user.target
EOF EOF
) )
if $print_unit; then
$apply && die '--print-unit kann nicht mit --apply kombiniert werden'
printf '%s\n' "$unit_contents"
exit 0
fi
printf 'Dienst: %s\nBenutzer: %s\nProjekt: %s\n' "$service_name" "$service_user" "$project_dir" printf 'Dienst: %s\nBenutzer: %s\nProjekt: %s\n' "$service_name" "$service_user" "$project_dir"
if ! $apply; then if ! $apply; then
printf '\nVorschau für %s:\n%s\n' "$unit_path" "$unit_contents" printf '\nVorschau für %s:\n%s\n' "$unit_path" "$unit_contents"
@@ -63,9 +83,9 @@ if ! $apply; then
exit 0 exit 0
fi fi
[[ -f "$project_dir/run.py" ]] || die 'run.py fehlt im Projektordner' [[ -f "$code_dir/run.py" ]] || die 'run.py fehlt im Projektordner'
[[ -x "$project_dir/.venv/bin/python" ]] || die '.venv fehlt; zuerst SOFTWARE.md: Pi vorbereiten ausführen' [[ -x "$code_dir/.venv/bin/python" ]] || die '.venv fehlt; zuerst SOFTWARE.md: Pi vorbereiten ausführen'
"$project_dir/.venv/bin/python" -c 'import fastapi, uvicorn, PIL' \ "$code_dir/.venv/bin/python" -c 'import fastapi, uvicorn, PIL' \
|| die 'Python-Abhängigkeiten fehlen; zuerst requirements.txt installieren' || die 'Python-Abhängigkeiten fehlen; zuerst requirements.txt installieren'
command -v sudo >/dev/null || die 'sudo fehlt' command -v sudo >/dev/null || die 'sudo fehlt'
command -v systemctl >/dev/null || die 'systemctl fehlt' command -v systemctl >/dev/null || die 'systemctl fehlt'
+23 -7
View File
@@ -2,13 +2,17 @@
Hier liegen Pi-Transfer/Einrichtung und die reproduzierbaren Blender-Werkzeuge. Hier liegen Pi-Transfer/Einrichtung und die reproduzierbaren Blender-Werkzeuge.
- `upload_to_pi.sh` zeigt ohne `--apply` nur eine Vorschau. Beim Übertragen - `upload_to_pi.sh` zeigt ohne `--apply` nur eine lokale Vorschau. Jede Version
keine Dateien auf dem Pi löschen; `.git`, `.venv`, `data`, Vorschauen, kommt in einen neuen releases/-Ordner; niemals data/ oder bestehende Dateien
Referenzen und Schlüsseldateien ausgeschlossen lassen. SSH-Hostschlüssel überschreiben/löschen. SSH-Hostschlüssel weiterhin prüfen.
weiterhin prüfen. - `activate_pi_release.sh` läuft auf dem Pi: flock-Sperre, eigene venv vorbereiten,
- `install_pi_service.sh` richtet den optionalen systemd-Autostart ein. verwalteten Dienst stoppen, data/ sichern, current atomar wechseln. Bei Fehlern
Die Python-Umgebung muss vorher auf dem Pi eingerichtet sein. Der Dienst Code, Unit und Daten wiederherstellen und alten Dienst prüfen. Abhängigkeiten
bindet über `run.py` nur an `127.0.0.1`; `sudo` nur bei `--apply`. nur als Wheels installieren. Fremde Units und manuelle Server ablehnen.
- `install_pi_service.sh` richtet systemd-Autostart ein, beim Upload automatisch.
Gemeinsames HALLOWEEN_STATE_DIR; Code/venv über current, wo vorhanden.
Default Loopback; direkter Heimnetzzugriff nur mit ausdrücklich gewähltem Host.
sudo nur bei --apply; keine Browser-Automation. Alle Versionen/Backups behalten.
- Render-, Ton- und Prüfscripte unter `tools/` erzeugen Dateien in - Render-, Ton- und Prüfscripte unter `tools/` erzeugen Dateien in
`preview/`. Bearbeitbare `.blend`-Szenen werden versioniert, gerenderte `preview/`. Bearbeitbare `.blend`-Szenen werden versioniert, gerenderte
Medien und `.blend1`-Backups bleiben lokal. Medien und `.blend1`-Backups bleiben lokal.
@@ -28,3 +32,15 @@ Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren.
fixierte NanoDet-Modell unter `data/models/`. Größe und SHA-256 vor atomarem fixierte NanoDet-Modell unter `data/models/`. Größe und SHA-256 vor atomarem
Ersetzen prüfen, TLS-Prüfung beibehalten. `--state-dir` bzw. Ersetzen prüfen, TLS-Prüfung beibehalten. `--state-dir` bzw.
`HALLOWEEN_STATE_DIR` muss zum Server passen. Keine Modellgewichte hochladen. `HALLOWEEN_STATE_DIR` muss zum Server passen. Keine Modellgewichte hochladen.
- Versionierte Updates verwenden genau einen sudo-Aufruf nach unprivilegierter
Paketvorbereitung. activate_pi_release.sh --privileged ist der interne Teil:
Unitprüfung, Sicherung, Umschaltung, systemd und Rückfall im selben sudo-Prozess.
Normale Einstiegsshell hält flock über dessen Laufzeit. Nicht direkt aufrufen.
- Python-/Projektprüfungen und Unit-Renderer im privilegierten Teil mit runuser
als SUDO_USER ausführen. Keine Pakete als root, kein Passwortspeicher/Keepalive
und keine sudoers-/Zeitstempeländerung. install_pi_service.sh --print-unit
rendert ohne sudo; standalone --apply ist vom Updateaufruf getrennt.
- Neu erzeugte Backupordner, Unit-Sicherung, current-Link und service-host
dem ursprünglichen Pi-Benutzer zuordnen; bestehende Datenbesitzer beim Kopieren
bewahren. Abgelehnte sudo-Anmeldung darf keine Umschaltung/Dienstpause auslösen.
+30 -23
View File
@@ -7,22 +7,26 @@ pi_user=
pi_host= pi_host=
remote_dir= remote_dir=
apply=false apply=false
install_service=false bind_host=
release="$(date -u +%Y%m%dT%H%M%SZ)-$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Projektdateien per SSH/rsync auf einen Raspberry Pi kopieren. Versioniertes Pi-Update per SSH/rsync mit Autostart und automatischem Rückfall.
Aufruf: Aufruf:
bash tools/upload_to_pi.sh --user BENUTZER --host HOST \ bash tools/upload_to_pi.sh --user BENUTZER --host HOST \
--remote-dir /home/BENUTZER/halloween [--source ORDNER] [--install-service] [--apply] --remote-dir /home/BENUTZER/halloween [--source ORDNER] \
[--web-host 127.0.0.1|0.0.0.0] [--apply]
Ohne --apply nur Vorschau, keine Verbindung. Mit --apply neue Version unter
releases/ vorbereiten, Daten sichern, Dienst umschalten und WebUI prüfen.
Autostart wird immer eingerichtet (sudo auf dem Pi erforderlich).
--web-host wählt SSH-Tunnel oder direkten Heimnetzzugriff; ohne Angabe bleibt
die gespeicherte Wahl erhalten, erstmalig 127.0.0.1.
--install-service wird als bisheriges Argument weiterhin akzeptiert.
Daten, Einstellungen und Modelle unter data/ bleiben erhalten.
Ohne --apply wird nur der geplante Aufruf angezeigt; es gibt keine Verbindung
zum Pi. --apply legt den Zielordner an und kopiert die Dateien. Alte Dateien
auf dem Pi werden nicht gelöscht.
--install-service richtet nach dem Upload einen systemd-Autostart ein. Dazu
muss die Python-Umgebung auf dem Pi bereits vorbereitet sein; sudo wird dort
benötigt. Ohne --apply wird auch der Dienst nicht installiert.
EOF EOF
} }
@@ -33,18 +37,19 @@ die() {
while (($#)); do while (($#)); do
case $1 in case $1 in
--user|--host|--remote-dir|--source) --user|--host|--remote-dir|--source|--web-host)
(($# >= 2)) || die "Wert für $1 fehlt" (($# >= 2)) || die "Wert für $1 fehlt"
case $1 in case $1 in
--user) pi_user=$2 ;; --user) pi_user=$2 ;;
--host) pi_host=$2 ;; --host) pi_host=$2 ;;
--remote-dir) remote_dir=$2 ;; --remote-dir) remote_dir=$2 ;;
--source) source_dir=$2 ;; --source) source_dir=$2 ;;
--web-host) bind_host=$2 ;;
esac esac
shift 2 shift 2
;; ;;
--apply) apply=true; shift ;; --apply) apply=true; shift ;;
--install-service) install_service=true; shift ;; --install-service) shift ;; # Compatibility: service installation is now automatic.
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) die "Unbekanntes Argument: $1" ;; *) die "Unbekanntes Argument: $1" ;;
esac esac
@@ -56,15 +61,21 @@ done
[[ /$remote_dir/ != *'/../'* && /$remote_dir/ != *'/./'* ]] || die 'Zielpfad darf keine . oder .. Segmente enthalten' [[ /$remote_dir/ != *'/../'* && /$remote_dir/ != *'/./'* ]] || die 'Zielpfad darf keine . oder .. Segmente enthalten'
[[ -d $source_dir ]] || die "Quellordner fehlt: $source_dir" [[ -d $source_dir ]] || die "Quellordner fehlt: $source_dir"
source_dir=$(cd -- "$source_dir" && pwd -P) source_dir=$(cd -- "$source_dir" && pwd -P)
if $install_service; then [[ -z $bind_host || $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || die 'Ungültiger --web-host'
[[ -f "$source_dir/tools/install_pi_service.sh" ]] || die 'tools/install_pi_service.sh fehlt in der Quelle' for required in run.py requirements.txt tools/install_pi_service.sh tools/activate_pi_release.sh; do
fi [[ -f "$source_dir/$required" ]] || die "Quelle unvollständig: $required fehlt"
done
command -v ssh >/dev/null || die 'ssh fehlt lokal' command -v ssh >/dev/null || die 'ssh fehlt lokal'
command -v rsync >/dev/null || die 'rsync fehlt lokal' command -v rsync >/dev/null || die 'rsync fehlt lokal'
target="$pi_user@$pi_host" target="$pi_user@$pi_host"
excludes=( excludes=(
--exclude=releases/
--exclude=backups/
--exclude=current
--exclude=service-host
--exclude=.update.lock
--exclude=.git/ --exclude=.git/
--exclude=.venv/ --exclude=.venv/
--exclude=__pycache__/ --exclude=__pycache__/
@@ -77,14 +88,14 @@ excludes=(
--exclude='*.p12' --exclude='*.p12'
--exclude='*.pfx' --exclude='*.pfx'
) )
ssh_cmd=(ssh -o StrictHostKeyChecking=ask "$target" "mkdir -p -- '$remote_dir'") ssh_cmd=(ssh -o StrictHostKeyChecking=ask "$target" "mkdir -p -- '$remote_dir/releases' && mkdir -- '$remote_dir/releases/$release'")
rsync_cmd=( rsync_cmd=(
rsync -a --delay-updates --human-readable --info=stats2 rsync -a --delay-updates --human-readable --info=stats2
"${excludes[@]}" "${excludes[@]}"
-e 'ssh -o StrictHostKeyChecking=ask' -e 'ssh -o StrictHostKeyChecking=ask'
-- "$source_dir/" "$target:$remote_dir/" -- "$source_dir/" "$target:$remote_dir/releases/$release/"
) )
service_cmd=(ssh -tt -o StrictHostKeyChecking=ask "$target" "cd -- '$remote_dir' && bash tools/install_pi_service.sh --apply") service_cmd=(ssh -tt -o StrictHostKeyChecking=ask "$target" "bash '$remote_dir/releases/$release/tools/activate_pi_release.sh' '$remote_dir' '$release' '$bind_host'")
printf 'Quelle: %s\nZiel: %s:%s/\n' "$source_dir" "$target" "$remote_dir" printf 'Quelle: %s\nZiel: %s:%s/\n' "$source_dir" "$target" "$remote_dir"
printf 'Ausgelassen: .git, .venv, data, preview, references, __pycache__, .env, Schlüsseldateien\n' printf 'Ausgelassen: .git, .venv, data, preview, references, __pycache__, .env, Schlüsseldateien\n'
@@ -92,9 +103,7 @@ if ! $apply; then
printf '\nVorschau, noch keine Verbindung und keine Übertragung:\n' printf '\nVorschau, noch keine Verbindung und keine Übertragung:\n'
printf ' %q' "${ssh_cmd[@]}"; printf '\n' printf ' %q' "${ssh_cmd[@]}"; printf '\n'
printf ' %q' "${rsync_cmd[@]}"; printf '\n' printf ' %q' "${rsync_cmd[@]}"; printf '\n'
if $install_service; then printf ' %q' "${service_cmd[@]}"; printf '\n'
printf ' %q' "${service_cmd[@]}"; printf '\n'
fi
printf '\nMit --apply ausführen.\n' printf '\nMit --apply ausführen.\n'
exit 0 exit 0
fi fi
@@ -102,6 +111,4 @@ fi
"${ssh_cmd[@]}" "${ssh_cmd[@]}"
"${rsync_cmd[@]}" "${rsync_cmd[@]}"
printf 'Übertragung abgeschlossen.\n' printf 'Übertragung abgeschlossen.\n'
if $install_service; then "${service_cmd[@]}"
"${service_cmd[@]}"
fi