Private
Public Access
addet test set
This commit is contained in:
1 parent
d370f992ce
commit
b0f35bf3cc
21 files changed
+1789
-176
No files matched your search
@@ -153,6 +153,13 @@ Versorgung und Sicherheit im Plan.
|
||||
- Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten
|
||||
Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen.
|
||||
- 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;
|
||||
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
|
||||
- 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
|
||||
Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben.
|
||||
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
|
||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||
- 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
|
||||
Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile.
|
||||
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/`;
|
||||
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
|
||||
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
|
||||
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
||||
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
|
||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
||||
- Versionierter Upload richtet systemd-Autostart bei jedem `--apply` ein.
|
||||
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
|
||||
und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft.
|
||||
|
||||
@@ -153,6 +153,13 @@ Versorgung und Sicherheit im Plan.
|
||||
- Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten
|
||||
Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen.
|
||||
- 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;
|
||||
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
|
||||
- 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
|
||||
Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben.
|
||||
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
|
||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||
- 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
|
||||
Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile.
|
||||
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/`;
|
||||
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
|
||||
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
|
||||
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
||||
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
|
||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
||||
- Versionierter Upload richtet systemd-Autostart bei jedem `--apply` ein.
|
||||
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
|
||||
|
||||
@@ -227,3 +255,65 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen.
|
||||
nur Wheels und brechen andernfalls ab. Kein weiterer Quellbuild vorgesehen.
|
||||
- Offen: installierte OpenCV-Version/-Herkunft und echte Modellinferenz am Pi
|
||||
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
@@ -5,8 +5,9 @@ Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
||||
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
||||
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
|
||||
am Pi installiert und kalibriert werden. Profilzuordnung per Kamera, Clips
|
||||
und Maskierung sind noch nicht umgesetzt.
|
||||
am Pi installiert und kalibriert werden. Bei automatischer Auslösung wird
|
||||
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
|
||||
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
|
||||
richten.
|
||||
@@ -30,19 +31,28 @@ direkt im WLAN sichtbar.
|
||||
|
||||
### WebUI beim Hochfahren starten
|
||||
|
||||
Nach der Vorbereitung kann `tools/install_pi_service.sh` auf dem Pi einen
|
||||
systemd-Dienst einrichten. Erst eine Vorschau ansehen, dann anwenden:
|
||||
Das versionierte Uploadskript richtet den systemd-Autostart jetzt bei jedem
|
||||
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 tools/install_pi_service.sh
|
||||
bash tools/install_pi_service.sh --apply
|
||||
systemctl status halloween-grab.service
|
||||
systemctl is-enabled halloween-grab.service
|
||||
```
|
||||
|
||||
Der Dienst läuft unter dem aktuellen Pi-Benutzer, startet `run.py` beim
|
||||
Hochfahren und wird bei einem Fehler neu gestartet. Das Skript benötigt für
|
||||
die Einrichtung `sudo` und überschreibt keine fremde Unit gleichen Namens.
|
||||
Nach Code-Updates kann derselbe `--apply`-Aufruf den Dienst neu starten.
|
||||
Der einzelne Installer bleibt für manuelle Einrichtung verfügbar:
|
||||
`bash tools/install_pi_service.sh --project-dir /home/BENUTZER/halloween --apply`.
|
||||
Er prüft keinen vollständigen Versionsrückfall; Updates deshalb vom Laptop
|
||||
über das Uploadskript durchführen. Autostart ist am echten Pi noch zu prüfen.
|
||||
Zum Deaktivieren: `sudo systemctl disable --now halloween-grab.service`.
|
||||
Bei Startproblemen: `sudo journalctl -u halloween-grab.service -n 80 --no-pager`.
|
||||
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
|
||||
Kamera und Bedienoberfläche nutzen. Eine öffentliche Internetfreigabe ist
|
||||
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
|
||||
|
||||
@@ -103,8 +114,9 @@ ohne Zuordnung erhalten. Die Daten bleiben bei einem Neustart und bei
|
||||
weiteren Uploads bestehen, weil das Uploadskript `data/` auslässt.
|
||||
|
||||
Der Nutzer wünscht später automatische Wiedererkennung per Kamera. Diese
|
||||
Version erstellt noch keine Profile aus Kamerabildern und speichert keine
|
||||
Bilder, Gesichtsmerkmale oder Clips aus Kamerabildern in der Datenbank.
|
||||
Version erstellt noch keine Profile aus Kamerabildern. Gesichtsmerkmale und
|
||||
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
|
||||
30-Tage-Frist ab letzter Sichtung vorbereitet. Beim Start, stündlich und
|
||||
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),
|
||||
[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
|
||||
|
||||
Nach Übertragung des aktuellen Codes im Projektordner auf dem Pi installieren:
|
||||
Nach einem versionierten Update im aktiven Versionsordner auf dem Pi installieren:
|
||||
|
||||
```bash
|
||||
cd ~/halloween
|
||||
cd ~/halloween/current
|
||||
sudo apt update
|
||||
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 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
|
||||
@@ -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
|
||||
Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der
|
||||
bestehenden Datenbank gespeichert. Weder Kamerabilder noch Tracking-IDs werden
|
||||
automatisch gespeichert; Profil-Wiedererkennung und Altersentscheidung fehlen.
|
||||
bestehenden Datenbank gespeichert, mit lokalem Bild des grünen Bereichs aus
|
||||
dem auslösenden Frame. Tracking-IDs werden nicht gespeichert; Profil-Wiedererkennung
|
||||
und Altersentscheidung fehlen.
|
||||
Aktoren bleiben ausschließlich simuliert.
|
||||
|
||||
### 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)
|
||||
|
||||
- Angezeigte Rate und Reaktionsverzögerung prüfen; Livebild und Stopp müssen
|
||||
|
||||
@@ -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
|
||||
SSH und `rsync` auf **einen** Pi. Ohne `--source` ist das Projektverzeichnis
|
||||
die Quelle. Es löscht keine Dateien auf dem Pi. Benutzer, Host und absoluter
|
||||
Zielpfad werden beim Aufruf angegeben. Der Nutzer-Screenshot vom 14. Oktober
|
||||
2026 zeigt einen erfolgreichen SSH-Login als `marcinator_2` auf
|
||||
`raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
||||
`tools/upload_to_pi.sh` überträgt per SSH/rsync eine neue Version und richtet
|
||||
bei jedem angewendeten Update den systemd-Autostart der WebUI ein. Ohne
|
||||
`--apply` zeigt es nur eine lokale Vorschau und verbindet sich nicht zum Pi.
|
||||
|
||||
## Auf dem Laptop ausführen
|
||||
|
||||
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
|
||||
|
||||
- Auf dem Laptop: `ssh` und `rsync`.
|
||||
- Auf dem Pi: aktivierter SSH-Zugang und `rsync`.
|
||||
- Der Benutzer muss im Zielpfad schreiben dürfen.
|
||||
- Für den optionalen Autostart: Pi-Software laut `SOFTWARE.md` vorbereiten,
|
||||
insbesondere `.venv` im Zielordner; `sudo` muss auf dem Pi nutzbar sein.
|
||||
- Vor dem ersten Upload den Pi per `ssh BENUTZER@HOST` erreichen und den
|
||||
angezeigten Hostschlüssel anhand des eigenen Geräts prüfen. Bei einem
|
||||
geänderten Hostschlüssel die Ursache klären, nicht die Prüfung abschalten.
|
||||
- Laptop: `bash`, `python3`, `ssh`, `rsync`.
|
||||
- Pi: `bash`, `rsync`, `flock`, `runuser` (util-linux), `python3-venv`,
|
||||
`python3-picamera2`, systemd
|
||||
und nutzbares `sudo` für den normalen SSH-Benutzer.
|
||||
- Der Pi muss die Python-Pakete aus `requirements.txt` herunterladen können.
|
||||
Pro Version wird eine eigene `.venv` mit Systempaketzugriff angelegt;
|
||||
pip verwendet ausschließlich Wheels, keinen impliziten Quellbuild.
|
||||
- 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`
|
||||
muss ein absoluter Pfad ohne Leerzeichen sein.
|
||||
Das Uploadskript bereitet die neue Python-Umgebung als normaler Pi-Benutzer vor.
|
||||
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
|
||||
BENUTZER='BENUTZER'
|
||||
HOST='HOST'
|
||||
ZIEL='/home/BENUTZER/halloween'
|
||||
|
||||
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
|
||||
systemctl status halloween-grab.service
|
||||
systemctl is-enabled halloween-grab.service
|
||||
readlink -f ~/halloween/current
|
||||
sudo journalctl -u halloween-grab.service -n 80 --no-pager
|
||||
```
|
||||
|
||||
Der erste Aufruf zeigt nur die geplanten Befehle. Der zweite legt den
|
||||
Zielordner an und überträgt die Dateien. Ein anderer Quellordner kann mit
|
||||
`--source /absoluter/pfad` angegeben werden. Anschließend auf dem Pi prüfen:
|
||||
Erkennung nach dem ersten Update bei Bedarf im aktiven Versionsordner einrichten:
|
||||
|
||||
```bash
|
||||
ssh "$BENUTZER@$HOST" "ls -la '$ZIEL'"
|
||||
```
|
||||
|
||||
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
|
||||
cd ~/halloween/current
|
||||
sudo apt update
|
||||
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 tools/install_detection_model.py
|
||||
HALLOWEEN_STATE_DIR=/home/marcinator_2/halloween/data \
|
||||
.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
|
||||
der WebUI speichern. Details und Fehlerbehandlung in `SOFTWARE.md`.
|
||||
OpenCV mindestens 4.10 und das echte Kamera-/Erkennungsverhalten gemäß
|
||||
`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.
|
||||
+54
-15
@@ -13,14 +13,22 @@ from io import BytesIO
|
||||
|
||||
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]}
|
||||
|
||||
|
||||
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:
|
||||
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):
|
||||
raise ValueError("Richtung muss -1 oder 1 sein")
|
||||
line, region = value["line"], value["region"]
|
||||
@@ -180,7 +188,7 @@ class DetectionService:
|
||||
self.last_valid = None
|
||||
self.started = self.clock()
|
||||
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):
|
||||
self.revision += 1
|
||||
@@ -211,7 +219,7 @@ class DetectionService:
|
||||
self.reset_state()
|
||||
self.started = self.clock()
|
||||
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()
|
||||
|
||||
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:
|
||||
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_method = None
|
||||
event_region = None
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
if self.stop_event.is_set():
|
||||
@@ -261,16 +271,25 @@ class DetectionService:
|
||||
if not self.fault:
|
||||
self.error = None
|
||||
image = Image.open(BytesIO(jpeg))
|
||||
if self.config['method'] == 'motion_test':
|
||||
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,
|
||||
self.result = {"frame_number": number, "people": people, "motion": motion,
|
||||
"image_base64": base64.b64encode(jpeg).decode("ascii")}
|
||||
if not self.config["enabled"] or self.fault or self.controller.stopped:
|
||||
self.armed = False
|
||||
self.free_since = None
|
||||
return
|
||||
rx1, ry1, rx2, ry2 = self.config["region"]
|
||||
# Auch unbestätigte Personenrahmen verhindern eine falsche Freigabe.
|
||||
occupied = any(p["box"][0] <= rx2 and p["box"][2] >= rx1 and
|
||||
# In motion test mode, a quiet image is NOT proof of no visitors.
|
||||
occupied = (not motion['ready'] or motion['moving']) if motion is not None else any(
|
||||
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:
|
||||
self.free_since = None
|
||||
@@ -288,17 +307,22 @@ class DetectionService:
|
||||
self.free_since = None
|
||||
if accepted:
|
||||
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.
|
||||
if event_mode is not None:
|
||||
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:
|
||||
self.fail(f"Ereignis nicht gespeichert: {exc}")
|
||||
self.fail(f"Ereignis/Bild nicht gespeichert: {exc}")
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
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,
|
||||
"armed": self.armed and not self.fault and not self.controller.stopped
|
||||
and self.controller.generation == self.generation
|
||||
@@ -309,7 +333,7 @@ class DetectionService:
|
||||
with self.lock:
|
||||
status = self.status()
|
||||
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,
|
||||
"status": status}
|
||||
|
||||
@@ -325,19 +349,30 @@ class DetectionService:
|
||||
self.thread.join(timeout=3)
|
||||
|
||||
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():
|
||||
began = self.clock()
|
||||
version = None
|
||||
try:
|
||||
if detector is None:
|
||||
detector = self.factory(self.model_path)
|
||||
with self.lock:
|
||||
if self.controller.generation != self.generation:
|
||||
self.reset_state()
|
||||
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()
|
||||
if jpeg is not None and number != last_number:
|
||||
last_number = number
|
||||
if config['method'] == 'motion_test':
|
||||
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()
|
||||
@@ -345,7 +380,11 @@ class DetectionService:
|
||||
self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed)
|
||||
last_completed = completed
|
||||
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
|
||||
self.stop_event.wait(1)
|
||||
self.stop_event.wait(max(0, 0.2-(self.clock()-began)))
|
||||
@@ -3,6 +3,12 @@
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -36,6 +42,7 @@ class EventStore:
|
||||
def __init__(self, state_dir: Path):
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.path = state_dir / "events.sqlite3"
|
||||
self.image_dir = state_dir / "event-images"
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
connection.execute("""
|
||||
@@ -75,6 +82,8 @@ class EventStore:
|
||||
"ALTER TABLE events ADD COLUMN profile_id INTEGER "
|
||||
"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("""
|
||||
CREATE TABLE IF NOT EXISTS profile_templates (
|
||||
profile_id INTEGER PRIMARY KEY
|
||||
@@ -98,18 +107,87 @@ class EventStore:
|
||||
def record_test_trigger(self, mode: str) -> int:
|
||||
return self._record_trigger(mode, "test_trigger")
|
||||
|
||||
def record_detection_trigger(self, mode: str) -> int:
|
||||
return self._record_trigger(mode, "person_line_crossing")
|
||||
def record_detection_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
|
||||
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())
|
||||
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) VALUES (?, ?, ?)",
|
||||
(occurred_at, kind, mode),
|
||||
"INSERT INTO events (occurred_at, kind, mode, image_filename) VALUES (?, ?, ?, ?)",
|
||||
(occurred_at, kind, mode, filename),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
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 connection:
|
||||
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("Ereignis nicht gefunden")
|
||||
if row['image_filename'] is None:
|
||||
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]:
|
||||
if not 1 <= limit <= 100:
|
||||
@@ -118,11 +196,21 @@ class EventStore:
|
||||
with closing(self._connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"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 "
|
||||
"ORDER BY e.id DESC LIMIT ?", (limit,),
|
||||
).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:
|
||||
label = label.strip()
|
||||
|
||||
+83
-13
@@ -37,6 +37,7 @@
|
||||
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
|
||||
#events-list li { margin: .35rem 0; }
|
||||
#profiles-list li { margin: .35rem 0; }
|
||||
.event-image { display: block; max-width: 200px; max-height: 150px; border-radius: 7px; margin: .5rem 0; }
|
||||
.event-tools { margin: .3rem 0 .8rem; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -53,17 +54,24 @@
|
||||
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||||
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
|
||||
</div>
|
||||
<h2 style="margin-top:1rem">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>
|
||||
<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">
|
||||
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
|
||||
<button id="reverse-direction">Richtung umkehren</button>
|
||||
<button id="save-detection">Linie und Bereich speichern</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<h2>Betrieb</h2>
|
||||
@@ -83,10 +91,11 @@
|
||||
</select>
|
||||
<button id="save-mode">Übernehmen</button>
|
||||
</div>
|
||||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die 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">
|
||||
<button id="zone-clear">Bereich frei (Test)</button>
|
||||
<button id="test-trigger">Testauslösung</button>
|
||||
<button id="zone-clear">Bereich frei (manueller Test)</button>
|
||||
<button id="test-trigger">Manuelle Testauslösung</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1rem">
|
||||
<button class="danger" id="stop">Stopp</button>
|
||||
@@ -97,6 +106,7 @@
|
||||
<section class="events">
|
||||
<h2>Gespeicherte Ereignisse</h2>
|
||||
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||
<button id="refresh-events">Ereignisse aktualisieren</button>
|
||||
<ul id="events-list"><li>Lädt …</li></ul>
|
||||
</section>
|
||||
<section class="events">
|
||||
@@ -129,7 +139,20 @@
|
||||
const c = data.controller, cam = data.camera;
|
||||
$('mode-label').textContent = names[c.mode] || 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' : '–';
|
||||
$('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');
|
||||
@@ -191,7 +214,7 @@
|
||||
for (const event of data.events) {
|
||||
const item = document.createElement('li');
|
||||
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');
|
||||
tools.className = 'row event-tools';
|
||||
const select = document.createElement('select');
|
||||
@@ -208,7 +231,31 @@
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
};
|
||||
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);
|
||||
}
|
||||
} catch (error) { $('events-list').textContent = error.message; }
|
||||
@@ -222,6 +269,7 @@
|
||||
$('events-list').textContent = error.message;
|
||||
}
|
||||
}
|
||||
$('refresh-events').onclick = refreshEvents;
|
||||
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
|
||||
$('zone-clear').onclick = () => action('/api/zone-clear');
|
||||
$('test-trigger').onclick = () => action('/api/test-trigger');
|
||||
@@ -292,9 +340,12 @@
|
||||
if (!detectionConfig) return;
|
||||
try {
|
||||
detectionConfig.enabled = $('detection-enabled').checked;
|
||||
detectionConfig.method = $('detection-method').value;
|
||||
detectionConfig = await api('/api/detection/config', detectionConfig);
|
||||
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; }
|
||||
};
|
||||
async function refreshDetection() {
|
||||
@@ -304,9 +355,20 @@
|
||||
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
|
||||
const data = await response.json(), status = data.status;
|
||||
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
|
||||
$('detection-status').textContent = status.error ||
|
||||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') +
|
||||
' · '+status.fps+' Auswertungen/s';
|
||||
const motionTest = status.method === 'motion_test';
|
||||
$('motion-details').hidden = !motionTest;
|
||||
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) {
|
||||
const image = new Image();
|
||||
image.src = 'data:image/jpeg;base64,'+data.image_base64;
|
||||
@@ -330,8 +392,16 @@
|
||||
}
|
||||
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 => {
|
||||
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; });
|
||||
refreshDetection();
|
||||
setInterval(drawDetection, 250);
|
||||
|
||||
@@ -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.
|
||||
Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn
|
||||
`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
|
||||
Testauslösungen werden als Ereignisse gespeichert; Profile besitzen
|
||||
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.
|
||||
- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung.
|
||||
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.
|
||||
@@ -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
|
||||
@@ -126,6 +126,27 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
except sqlite3.Error as 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")
|
||||
async def list_profiles():
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
- `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.
|
||||
@@ -200,7 +200,7 @@ class ServiceTest(unittest.TestCase):
|
||||
|
||||
def test_database_stall_does_not_block_watchdog(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
def blocked_record(mode):
|
||||
def blocked_record(mode, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
self.events.record_detection_trigger = blocked_record
|
||||
@@ -220,7 +220,7 @@ class ServiceTest(unittest.TestCase):
|
||||
self.assertFalse(worker.is_alive())
|
||||
|
||||
def test_database_failure_stops_simulation(self):
|
||||
def broken_record(mode):
|
||||
def broken_record(mode, **kwargs):
|
||||
raise OSError('disk full')
|
||||
self.events.record_detection_trigger = broken_record
|
||||
self.free()
|
||||
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
@@ -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"
|
||||
@@ -5,15 +5,19 @@ project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
service_name=halloween-grab.service
|
||||
unit_path="/etc/systemd/system/$service_name"
|
||||
apply=false
|
||||
print_unit=false
|
||||
bind_host=127.0.0.1
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,6 +28,11 @@ die() {
|
||||
|
||||
while (($#)); do
|
||||
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 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unbekanntes Argument: $1" ;;
|
||||
@@ -33,9 +42,13 @@ done
|
||||
service_user=$(id -un)
|
||||
[[ $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'
|
||||
[[ $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'
|
||||
|
||||
[[ $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
|
||||
# Managed by Halloween project: tools/install_pi_service.sh
|
||||
[Unit]
|
||||
@@ -45,8 +58,9 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$service_user
|
||||
WorkingDirectory=$project_dir
|
||||
ExecStart=$project_dir/.venv/bin/python $project_dir/run.py
|
||||
WorkingDirectory=$code_dir
|
||||
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
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -56,6 +70,12 @@ WantedBy=multi-user.target
|
||||
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"
|
||||
if ! $apply; then
|
||||
printf '\nVorschau für %s:\n%s\n' "$unit_path" "$unit_contents"
|
||||
@@ -63,9 +83,9 @@ if ! $apply; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -f "$project_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'
|
||||
"$project_dir/.venv/bin/python" -c 'import fastapi, uvicorn, PIL' \
|
||||
[[ -f "$code_dir/run.py" ]] || die 'run.py fehlt im Projektordner'
|
||||
[[ -x "$code_dir/.venv/bin/python" ]] || die '.venv fehlt; zuerst SOFTWARE.md: Pi vorbereiten ausführen'
|
||||
"$code_dir/.venv/bin/python" -c 'import fastapi, uvicorn, PIL' \
|
||||
|| die 'Python-Abhängigkeiten fehlen; zuerst requirements.txt installieren'
|
||||
command -v sudo >/dev/null || die 'sudo fehlt'
|
||||
command -v systemctl >/dev/null || die 'systemctl fehlt'
|
||||
|
||||
+23
-7
@@ -2,13 +2,17 @@
|
||||
|
||||
Hier liegen Pi-Transfer/Einrichtung und die reproduzierbaren Blender-Werkzeuge.
|
||||
|
||||
- `upload_to_pi.sh` zeigt ohne `--apply` nur eine Vorschau. Beim Übertragen
|
||||
keine Dateien auf dem Pi löschen; `.git`, `.venv`, `data`, Vorschauen,
|
||||
Referenzen und Schlüsseldateien ausgeschlossen lassen. SSH-Hostschlüssel
|
||||
weiterhin prüfen.
|
||||
- `install_pi_service.sh` richtet den optionalen systemd-Autostart ein.
|
||||
Die Python-Umgebung muss vorher auf dem Pi eingerichtet sein. Der Dienst
|
||||
bindet über `run.py` nur an `127.0.0.1`; `sudo` nur bei `--apply`.
|
||||
- `upload_to_pi.sh` zeigt ohne `--apply` nur eine lokale Vorschau. Jede Version
|
||||
kommt in einen neuen releases/-Ordner; niemals data/ oder bestehende Dateien
|
||||
überschreiben/löschen. SSH-Hostschlüssel weiterhin prüfen.
|
||||
- `activate_pi_release.sh` läuft auf dem Pi: flock-Sperre, eigene venv vorbereiten,
|
||||
verwalteten Dienst stoppen, data/ sichern, current atomar wechseln. Bei Fehlern
|
||||
Code, Unit und Daten wiederherstellen und alten Dienst prüfen. Abhängigkeiten
|
||||
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
|
||||
`preview/`. Bearbeitbare `.blend`-Szenen werden versioniert, gerenderte
|
||||
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
|
||||
Ersetzen prüfen, TLS-Prüfung beibehalten. `--state-dir` bzw.
|
||||
`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.
|
||||
+29
-22
@@ -7,22 +7,26 @@ pi_user=
|
||||
pi_host=
|
||||
remote_dir=
|
||||
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() {
|
||||
cat <<'EOF'
|
||||
Projektdateien per SSH/rsync auf einen Raspberry Pi kopieren.
|
||||
Versioniertes Pi-Update per SSH/rsync mit Autostart und automatischem Rückfall.
|
||||
|
||||
Aufruf:
|
||||
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
|
||||
}
|
||||
|
||||
@@ -33,18 +37,19 @@ die() {
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--user|--host|--remote-dir|--source)
|
||||
--user|--host|--remote-dir|--source|--web-host)
|
||||
(($# >= 2)) || die "Wert für $1 fehlt"
|
||||
case $1 in
|
||||
--user) pi_user=$2 ;;
|
||||
--host) pi_host=$2 ;;
|
||||
--remote-dir) remote_dir=$2 ;;
|
||||
--source) source_dir=$2 ;;
|
||||
--web-host) bind_host=$2 ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--apply) apply=true; shift ;;
|
||||
--install-service) install_service=true; shift ;;
|
||||
--install-service) shift ;; # Compatibility: service installation is now automatic.
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unbekanntes Argument: $1" ;;
|
||||
esac
|
||||
@@ -56,15 +61,21 @@ done
|
||||
[[ /$remote_dir/ != *'/../'* && /$remote_dir/ != *'/./'* ]] || die 'Zielpfad darf keine . oder .. Segmente enthalten'
|
||||
[[ -d $source_dir ]] || die "Quellordner fehlt: $source_dir"
|
||||
source_dir=$(cd -- "$source_dir" && pwd -P)
|
||||
if $install_service; then
|
||||
[[ -f "$source_dir/tools/install_pi_service.sh" ]] || die 'tools/install_pi_service.sh fehlt in der Quelle'
|
||||
fi
|
||||
[[ -z $bind_host || $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || die 'Ungültiger --web-host'
|
||||
for required in run.py requirements.txt tools/install_pi_service.sh tools/activate_pi_release.sh; do
|
||||
[[ -f "$source_dir/$required" ]] || die "Quelle unvollständig: $required fehlt"
|
||||
done
|
||||
|
||||
command -v ssh >/dev/null || die 'ssh fehlt lokal'
|
||||
command -v rsync >/dev/null || die 'rsync fehlt lokal'
|
||||
|
||||
target="$pi_user@$pi_host"
|
||||
excludes=(
|
||||
--exclude=releases/
|
||||
--exclude=backups/
|
||||
--exclude=current
|
||||
--exclude=service-host
|
||||
--exclude=.update.lock
|
||||
--exclude=.git/
|
||||
--exclude=.venv/
|
||||
--exclude=__pycache__/
|
||||
@@ -77,14 +88,14 @@ excludes=(
|
||||
--exclude='*.p12'
|
||||
--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 -a --delay-updates --human-readable --info=stats2
|
||||
"${excludes[@]}"
|
||||
-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 '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 ' %q' "${ssh_cmd[@]}"; printf '\n'
|
||||
printf ' %q' "${rsync_cmd[@]}"; printf '\n'
|
||||
if $install_service; then
|
||||
printf ' %q' "${service_cmd[@]}"; printf '\n'
|
||||
fi
|
||||
printf '\nMit --apply ausführen.\n'
|
||||
exit 0
|
||||
fi
|
||||
@@ -102,6 +111,4 @@ fi
|
||||
"${ssh_cmd[@]}"
|
||||
"${rsync_cmd[@]}"
|
||||
printf 'Übertragung abgeschlossen.\n'
|
||||
if $install_service; then
|
||||
"${service_cmd[@]}"
|
||||
fi
|
||||
"${service_cmd[@]}"
|
||||
Reference in new issue
Block a user