Private
Public Access
SQLite-Ereignisse und Personenprofile vorbereiten
This commit is contained in:
1 parent
643146599d
commit
f15ab1d399
9 files changed
+635
-19
No files matched your search
@@ -66,9 +66,14 @@ Versorgung und Sicherheit im Plan.
|
||||
|
||||
## Kamera, Aufnahmen und Zugriff
|
||||
|
||||
- Personen als Ereignisse erkennen, ohne Identitäten zu bestimmen.
|
||||
- Keine Gesichtserkennung oder automatische Wiedererkennung konkreter
|
||||
Personen über getrennte Videos hinweg implementieren.
|
||||
- Personenerkennung für Auslösung und Profilzuordnung als getrennte Schritte
|
||||
behandeln; unsichere Zuordnungen keinem Profil zuschreiben.
|
||||
- Neuer Nutzerwunsch: Personenprofile und später automatische Zuordnung
|
||||
wiederkehrender Besucher per Kamera. Aktuell ist nur die lokale Datenbank
|
||||
mit manueller Zuordnung umgesetzt; automatische Erkennung nicht als
|
||||
funktionsfähig ausgeben, bevor Verfahren und echte Kamera geprüft sind.
|
||||
- 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.
|
||||
- Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen;
|
||||
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
|
||||
@@ -84,6 +89,10 @@ Versorgung und Sicherheit im Plan.
|
||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||
- Moduswahl und simulierte Sequenz sind implementiert. GPIO, automatische
|
||||
Personenauslösung und Clip-Speicherung sind noch offen.
|
||||
- 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
|
||||
schreibt derzeit keine solchen Datensätze.
|
||||
- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben.
|
||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
||||
|
||||
@@ -13,7 +13,8 @@ sich eine Skelett-Hand innerhalb der Attrappe.
|
||||
Die Weboberfläche zeigt das Livebild, Betriebszustand und Ereignisse.
|
||||
Bilder und kurze Videoclips lassen sich auf dem Laptop speichern und
|
||||
manuell prüfen. Die Erkennung meldet Personen oder Bewegungen in definierten
|
||||
Bereichen; sie identifiziert keine Personen.
|
||||
Bereichen. Eine spätere Kamera-Zuordnung zu Profilen soll nur bei
|
||||
ausreichender Sicherheit erfolgen; sonst bleibt das Ereignis ohne Profil.
|
||||
|
||||
## Bisherige Wünsche und Bestand
|
||||
|
||||
@@ -47,8 +48,13 @@ Bereichen; sie identifiziert keine Personen.
|
||||
- „Voller Schreck“ ist der gewünschte Standardmodus; der Modus soll später
|
||||
in der WebUI auf Sanft, Atmosphäre oder Aus umstellbar sein.
|
||||
- Liveansicht sowie Speicherung von Fotos und Videoclips auf dem Laptop.
|
||||
- Wunsch nach Prüfung verdächtiger Rückkehr: Ereignisübersicht und manuelles
|
||||
Vergleichen der Clips; keine automatische personenübergreifende Identifizierung.
|
||||
- Neuer Wunsch nach Personenprofilen und automatischer Zuordnung
|
||||
wiederkehrender Besucher per Kamera. Die bisherige Einschränkung auf
|
||||
manuelles Vergleichen wurde damit geändert; Erkennungsverfahren und
|
||||
Zuverlässigkeit am echten Kamerabild sind noch offen.
|
||||
- Unbekannte Besucher sollen automatisch neue Profile erhalten. Automatisch
|
||||
erzeugte Profile und Gesichtsmerkmale sollen 30 Tage nach der letzten
|
||||
Sichtung gelöscht werden; der Kamerateil ist noch nicht implementiert.
|
||||
|
||||
## Erkenntnisse aus dem Referenzvideo
|
||||
|
||||
@@ -79,7 +85,7 @@ zum Belastungstest.
|
||||
|
||||
| Komponente | Aufgabe |
|
||||
|---|---|
|
||||
| Pi 5 | Kamera, anonyme Personenerkennung, WebUI, Ereignisse und Clip-Puffer |
|
||||
| Pi 5 | Kamera, Personenerkennung, spätere Profilzuordnung, WebUI, Ereignisse und Clip-Puffer |
|
||||
| Pi 3 oder 4 (optional) | Effekte, Ablaufsteuerung und lokale Bedienelemente |
|
||||
| Laptop | WebUI aufrufen, Livebild ansehen, Clips herunterladen |
|
||||
| Treiber und separate Versorgung | Motoren, Servos und Beleuchtung versorgen |
|
||||
@@ -116,8 +122,9 @@ Wartbarkeit wichtiger als sture Einhaltung der Grenze. Vorgesehene Module:
|
||||
|
||||
- `config.py` – zentrale Konfiguration und Validierung
|
||||
- `camera.py` – Kamerazugriff und Capture-Puffer
|
||||
- `detector.py` – anonyme Personen-/Bewegungserkennung
|
||||
- `events.py` – Ereignisverwaltung und Clip-Speicherung
|
||||
- `detector.py` – Personen-/Bewegungserkennung als Auslöser
|
||||
- `event_store.py` – SQLite für Testereignisse und Profilkennungen;
|
||||
Clip-Speicherung und Kamera-Zuordnung folgen später
|
||||
- `effects.py` – Effektablaufsteuerung, bei Bedarf auf dem Effekt-Pi
|
||||
- `web.py` – FastAPI-Endpunkte und MJPEG-Livebild in der ersten Version
|
||||
- `main_controller.py` – Start, Verbindung und Hauptloop auf Pi 5
|
||||
@@ -261,8 +268,12 @@ Rücksetzen neu.
|
||||
### 4. Kamera und WebUI ergänzen
|
||||
|
||||
- [x] MJPEG-Livebild, Schnappschuss und WebUI im Demo-Modus lokal prüfen.
|
||||
- [x] SQLite für Testereignisse und Profile lokal prüfen; automatische
|
||||
Kamera-Zuordnung ist noch nicht aktiv.
|
||||
- [ ] Livebild und geschützten lokalen Zugriff bereitstellen.
|
||||
- [ ] Person-/Bereichserkennung ohne Identitätsbestimmung einrichten.
|
||||
- [ ] Person-/Bereichserkennung als Auslöser einrichten.
|
||||
- [ ] Erkennungsverfahren für wiederkehrende Personen auswählen, mit echten
|
||||
Tag-/Nachtbildern auf Fehlzuordnungen prüfen und erst dann aktivieren.
|
||||
- [ ] Gewählten Auslöser mit dem Effektablauf verbinden und Fehlalarme messen.
|
||||
- [ ] Ereignisse mit begrenztem Vor- und Nachlauf aufzeichnen.
|
||||
- [ ] Übersicht, Download und automatische Löschung umsetzen.
|
||||
@@ -301,6 +312,8 @@ sind bekannt und der Betreiber kann jederzeit stoppen oder sanft schalten.
|
||||
die Kamera nur dokumentieren?
|
||||
9. Reicht nach dem ersten Test ein Pi, oder begründen Aufbau und Last einen
|
||||
zweiten Pi?
|
||||
10. Wie werden Profilbeispiele erfasst und geprüft, und ab welcher
|
||||
Unsicherheit bleibt eine Kamera-Zuordnung offen?
|
||||
|
||||
## Fortschritt
|
||||
|
||||
@@ -308,6 +321,9 @@ sind bekannt und der Betreiber kann jederzeit stoppen oder sanft schalten.
|
||||
- [x] Allgemeines Uploadskript und Anleitung für Projektdateien vorbereitet.
|
||||
- [x] Erste Kamera-WebUI mit Demo-Feed und simulierter Steuerung erstellt;
|
||||
fünf lokale Softwaretests bestanden.
|
||||
- [x] SQLite-Ereignisdatenbank mit Profilkennungen und WebUI-Verwaltung
|
||||
implementiert. Die 30-Tage-Löschung für Auto-Profile ist im
|
||||
Datenmodell und als laufende Bereinigung vorbereitet.
|
||||
- [x] Optionales Installationsskript für WebUI-Autostart beim Pi-Start und
|
||||
Integration in das Uploadskript erstellt; nur lokal geprüft.
|
||||
- [x] Lokale Git-Versionsverwaltung für Code, Planung und bearbeitbare
|
||||
|
||||
+24
-2
@@ -3,8 +3,10 @@
|
||||
Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
||||
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
||||
Servos, Lampen oder Lautsprecher. Automatische Personenerkennung, Clips,
|
||||
Maskierung und Löschfristen sind noch nicht umgesetzt. Daher vorerst nur
|
||||
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
||||
Testereignisse und Profilkennungen. Automatische Personenerkennung und
|
||||
Profilzuordnung per Kamera, Clips und Maskierung 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.
|
||||
|
||||
@@ -73,6 +75,26 @@ noch nicht vorhandenen Einstellungen der Standard. Ein WebUI-Wechsel wird
|
||||
unter `data/settings.json` gespeichert und bleibt bei normalem Neustart
|
||||
erhalten. Das Uploadskript überträgt diesen Laufzeitordner nicht.
|
||||
|
||||
## Ereignisdatenbank und Profile
|
||||
|
||||
Beim Start wird `data/events.sqlite3` angelegt. Erfolgreiche manuelle
|
||||
Testauslösungen werden mit Zeitpunkt und Modus gespeichert und in der WebUI
|
||||
aufgelistet. Profilkennungen lassen sich anlegen, Ereignissen zum Testen
|
||||
zuordnen und wieder löschen. Beim Löschen eines Profils bleibt das Ereignis
|
||||
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.
|
||||
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
|
||||
Merkmalsvektoren entfernt. Manuelle Profile bleiben bis zur manuellen
|
||||
Löschung bestehen; Ereignisse bleiben ohne Profilzuordnung erhalten.
|
||||
Eine echte Zuordnung braucht noch Kameratests und eine Prüfung von
|
||||
Fehlzuordnungen.
|
||||
|
||||
## Lokal prüfen
|
||||
|
||||
Für die Webtests zusätzlich `httpx` installieren:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Lokale Ereignisse und Profile mit begrenzter Lebensdauer für Auto-Profile."""
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import struct
|
||||
|
||||
|
||||
AUTO_PROFILE_DAYS = 30
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
raise ValueError("Zeitstempel benötigt eine Zeitzone")
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalized_vector(values) -> tuple[float, ...]:
|
||||
vector = tuple(float(value) for value in values)
|
||||
if not 16 <= len(vector) <= 4096 or not all(math.isfinite(x) for x in vector):
|
||||
raise ValueError("Ungültiger Merkmalsvektor")
|
||||
magnitude = math.hypot(*vector)
|
||||
if magnitude == 0 or not math.isfinite(magnitude):
|
||||
raise ValueError("Leerer Merkmalsvektor")
|
||||
return tuple(value / magnitude for value in vector)
|
||||
|
||||
|
||||
class EventStore:
|
||||
def __init__(self, state_dir: Path):
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.path = state_dir / "events.sqlite3"
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
connection.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
label TEXT NOT NULL CHECK(length(label) BETWEEN 1 AND 80),
|
||||
created_at TEXT NOT NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'manual',
|
||||
last_seen_at TEXT,
|
||||
expires_at TEXT
|
||||
)
|
||||
""")
|
||||
profile_columns = {
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(profiles)")
|
||||
}
|
||||
for name, definition in (
|
||||
("origin", "TEXT NOT NULL DEFAULT 'manual'"),
|
||||
("last_seen_at", "TEXT"),
|
||||
("expires_at", "TEXT"),
|
||||
):
|
||||
if name not in profile_columns:
|
||||
connection.execute(f"ALTER TABLE profiles ADD COLUMN {name} {definition}")
|
||||
connection.execute("""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
columns = {
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(events)")
|
||||
}
|
||||
if "profile_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE events ADD COLUMN profile_id INTEGER "
|
||||
"REFERENCES profiles(id) ON DELETE SET NULL"
|
||||
)
|
||||
connection.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile_templates (
|
||||
profile_id INTEGER PRIMARY KEY
|
||||
REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
dimension INTEGER NOT NULL,
|
||||
vector BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
os.chmod(self.path, 0o600)
|
||||
self.cleanup_expired()
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=5)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA secure_delete=ON")
|
||||
return connection
|
||||
|
||||
def record_test_trigger(self, mode: str) -> int:
|
||||
occurred_at = _timestamp(_utc_now())
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
|
||||
(occurred_at, "test_trigger", mode),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def recent(self, limit: int = 20) -> list[dict]:
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("limit muss zwischen 1 und 100 liegen")
|
||||
self.cleanup_expired()
|
||||
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 "
|
||||
"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]
|
||||
|
||||
def create_profile(self, label: str) -> dict:
|
||||
label = label.strip()
|
||||
if not 1 <= len(label) <= 80:
|
||||
raise ValueError("Profilname muss 1 bis 80 Zeichen lang sein")
|
||||
created_at = _timestamp(_utc_now())
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO profiles (label, created_at) VALUES (?, ?)",
|
||||
(label, created_at),
|
||||
)
|
||||
profile_id = cursor.lastrowid
|
||||
return {"id": profile_id, "label": label, "created_at": created_at}
|
||||
|
||||
def profiles(self) -> list[dict]:
|
||||
self.cleanup_expired()
|
||||
with closing(self._connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT p.id, p.label, p.created_at, p.origin, p.last_seen_at, "
|
||||
"p.expires_at, COUNT(e.id) AS event_count "
|
||||
"FROM profiles p LEFT JOIN events e ON e.profile_id = p.id "
|
||||
"GROUP BY p.id ORDER BY p.id DESC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def create_auto_profile(self, values, model_id: str, now: datetime | None = None) -> dict:
|
||||
if not model_id or len(model_id) > 80:
|
||||
raise ValueError("Ungültige Modellkennung")
|
||||
vector = _normalized_vector(values)
|
||||
moment = now or _utc_now()
|
||||
created_at = _timestamp(moment)
|
||||
expires_at = _timestamp(moment + timedelta(days=AUTO_PROFILE_DAYS))
|
||||
packed = struct.pack(f"<{len(vector)}f", *vector)
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO profiles (label, created_at, origin, last_seen_at, expires_at) "
|
||||
"VALUES (?, ?, 'auto', ?, ?)",
|
||||
("Unbekannt", created_at, created_at, expires_at),
|
||||
)
|
||||
profile_id = cursor.lastrowid
|
||||
label = f"Unbekannt #{profile_id}"
|
||||
connection.execute(
|
||||
"UPDATE profiles SET label = ? WHERE id = ?", (label, profile_id)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO profile_templates "
|
||||
"(profile_id, model_id, dimension, vector, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(profile_id, model_id, len(vector), packed, created_at),
|
||||
)
|
||||
return {"id": profile_id, "label": label, "created_at": created_at,
|
||||
"origin": "auto", "last_seen_at": created_at, "expires_at": expires_at}
|
||||
|
||||
def best_template_match(
|
||||
self, values, model_id: str, now: datetime | None = None,
|
||||
) -> tuple[int | None, float]:
|
||||
vector = _normalized_vector(values)
|
||||
with closing(self._connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT t.profile_id, t.dimension, t.vector FROM profile_templates t "
|
||||
"JOIN profiles p ON p.id = t.profile_id "
|
||||
"WHERE t.model_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)",
|
||||
(model_id, _timestamp(now or _utc_now())),
|
||||
).fetchall()
|
||||
best_id, best_score = None, -1.0
|
||||
for row in rows:
|
||||
if row["dimension"] != len(vector):
|
||||
continue
|
||||
if len(row["vector"]) != 4 * len(vector):
|
||||
continue
|
||||
template = struct.unpack(f"<{len(vector)}f", row["vector"])
|
||||
score = sum(a * b for a, b in zip(vector, template))
|
||||
if score > best_score:
|
||||
best_id, best_score = row["profile_id"], score
|
||||
return best_id, best_score
|
||||
|
||||
def touch_auto_profile(self, profile_id: int, now: datetime | None = None) -> None:
|
||||
moment = now or _utc_now()
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE profiles SET last_seen_at = ?, expires_at = ? "
|
||||
"WHERE id = ? AND origin = 'auto' AND expires_at > ?",
|
||||
(_timestamp(moment),
|
||||
_timestamp(moment + timedelta(days=AUTO_PROFILE_DAYS)),
|
||||
profile_id, _timestamp(moment)),
|
||||
)
|
||||
|
||||
def cleanup_expired(self, now: datetime | None = None) -> int:
|
||||
cutoff = _timestamp(now or _utc_now())
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM profiles WHERE origin = 'auto' "
|
||||
"AND expires_at IS NOT NULL AND expires_at <= ?", (cutoff,)
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
def assign_profile(self, event_id: int, profile_id: int | None) -> None:
|
||||
self.cleanup_expired()
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
if profile_id is not None and connection.execute(
|
||||
"SELECT 1 FROM profiles WHERE id = ?", (profile_id,),
|
||||
).fetchone() is None:
|
||||
raise KeyError("Profil nicht gefunden")
|
||||
cursor = connection.execute(
|
||||
"UPDATE events SET profile_id = ? WHERE id = ?",
|
||||
(profile_id, event_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise KeyError("Ereignis nicht gefunden")
|
||||
|
||||
def delete_profile(self, profile_id: int) -> bool:
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM profiles WHERE id = ?", (profile_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
+110
-4
@@ -16,9 +16,10 @@
|
||||
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
||||
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
button, select, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; cursor: pointer; }
|
||||
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
|
||||
button, select, a.download { cursor: pointer; }
|
||||
button:hover, a.download:hover { background: #36504a; }
|
||||
button:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
|
||||
button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
|
||||
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
|
||||
button.danger:hover { background: #9c352c; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
@@ -29,6 +30,11 @@
|
||||
#message { min-height: 1.5rem; color: #ffca78; }
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
|
||||
dt { color: #adbfba; } dd { margin: 0; }
|
||||
.events { grid-column: 1 / -1; }
|
||||
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
|
||||
#events-list li { margin: .35rem 0; }
|
||||
#profiles-list li { margin: .35rem 0; }
|
||||
.event-tools { margin: .3rem 0 .8rem; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -74,13 +80,28 @@
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<section class="events">
|
||||
<h2>Gespeicherte Testereignisse</h2>
|
||||
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||
<ul id="events-list"><li>Lädt …</li></ul>
|
||||
</section>
|
||||
<section class="events">
|
||||
<h2>Personenprofile</h2>
|
||||
<p class="notice">Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.</p>
|
||||
<div class="row">
|
||||
<input id="profile-label" type="text" maxlength="80" aria-label="Profilkennung" placeholder="Profilkennung oder Spitzname">
|
||||
<button id="create-profile">Profil anlegen</button>
|
||||
</div>
|
||||
<ul id="profiles-list"><li>Lädt …</li></ul>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
|
||||
async function api(path, body = {}) {
|
||||
let profiles = [];
|
||||
async function api(path, body = {}, method = 'POST') {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
|
||||
method, headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -109,15 +130,100 @@
|
||||
const data = await api(path, body);
|
||||
$('message').textContent = data.reason || 'Einstellung übernommen';
|
||||
await refresh();
|
||||
await refreshDb();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
}
|
||||
async function refreshProfiles() {
|
||||
const response = await fetch('/api/profiles', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Profile nicht verfügbar');
|
||||
profiles = data.profiles;
|
||||
const list = $('profiles-list');
|
||||
list.replaceChildren();
|
||||
if (!profiles.length) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'Noch keine Profile';
|
||||
list.append(item);
|
||||
}
|
||||
for (const profile of profiles) {
|
||||
const item = document.createElement('li');
|
||||
const label = document.createElement('span');
|
||||
label.textContent = '#' + profile.id + ' · ' + profile.label + ' · ' + profile.event_count + ' Ereignisse ';
|
||||
const remove = document.createElement('button');
|
||||
remove.textContent = 'Löschen';
|
||||
remove.onclick = async () => {
|
||||
if (!confirm('Profil und seine Zuordnungen entfernen? Die Ereignisse bleiben erhalten.')) return;
|
||||
try {
|
||||
await api('/api/profiles/' + profile.id, {}, 'DELETE');
|
||||
await refreshDb();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
};
|
||||
item.append(label, remove);
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
async function refreshEvents() {
|
||||
try {
|
||||
const response = await fetch('/api/events', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Ereignisse nicht verfügbar');
|
||||
const list = $('events-list');
|
||||
list.replaceChildren();
|
||||
if (!data.events.length) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'Noch keine Testauslösung gespeichert';
|
||||
list.append(item);
|
||||
}
|
||||
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') + ' · ' + (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');
|
||||
select.setAttribute('aria-label', 'Profil für Ereignis ' + event.id);
|
||||
select.add(new Option('Ohne Profil', ''));
|
||||
for (const profile of profiles) select.add(new Option('#' + profile.id + ' · ' + profile.label, String(profile.id)));
|
||||
select.value = event.profile_id === null ? '' : String(event.profile_id);
|
||||
const assign = document.createElement('button');
|
||||
assign.textContent = 'Zuordnen';
|
||||
assign.onclick = async () => {
|
||||
try {
|
||||
await api('/api/events/' + event.id + '/profile', { profile_id: select.value ? Number(select.value) : null }, 'PATCH');
|
||||
await refreshDb();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
};
|
||||
tools.append(select, assign);
|
||||
item.append(summary, tools);
|
||||
list.append(item);
|
||||
}
|
||||
} catch (error) { $('events-list').textContent = error.message; }
|
||||
}
|
||||
async function refreshDb() {
|
||||
try {
|
||||
await refreshProfiles();
|
||||
await refreshEvents();
|
||||
} catch (error) {
|
||||
$('profiles-list').textContent = error.message;
|
||||
$('events-list').textContent = error.message;
|
||||
}
|
||||
}
|
||||
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
|
||||
$('zone-clear').onclick = () => action('/api/zone-clear');
|
||||
$('test-trigger').onclick = () => action('/api/test-trigger');
|
||||
$('stop').onclick = () => action('/api/stop');
|
||||
$('reset').onclick = () => action('/api/reset');
|
||||
$('create-profile').onclick = async () => {
|
||||
try {
|
||||
await api('/api/profiles', { label: $('profile-label').value });
|
||||
$('profile-label').value = '';
|
||||
await refreshDb();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
};
|
||||
refresh();
|
||||
refreshDb();
|
||||
setInterval(refresh, 1000);
|
||||
setInterval(refreshDb, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,16 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
|
||||
kommen. Beschädigte Einstellungen schalten auf `aus`.
|
||||
- `web.py` bietet WebUI und API; `index.html` ist die Oberfläche. `run.py`
|
||||
bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel.
|
||||
- `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
|
||||
die Zuordnung, nicht das Ereignis. Auto-Profile können Merkmalsvektoren
|
||||
aufnehmen und verfallen 30 Tage nach letzter Sichtung. Start, stündlicher
|
||||
Lauf und Lesezugriffe räumen abgelaufene Profile auf; die Kamera liefert
|
||||
derzeit noch keine Merkmalsvektoren.
|
||||
- Automatische Profilzuordnung per Kamera ist ein neuer Nutzerwunsch, aber
|
||||
noch nicht implementiert. Eine künftige Erkennung muss Unsicherheit und
|
||||
mögliche Fehlzuordnungen sichtbar machen und am echten Pi geprüft werden.
|
||||
- Der Header `X-Halloween-UI` ist nur eine einfache Schranke für
|
||||
Schreibaufrufe, kein echter Zugangsschutz. Vor Betrieb mit anderen
|
||||
Netzfreigaben Authentifizierung entwerfen und prüfen.
|
||||
|
||||
+83
-3
@@ -1,25 +1,38 @@
|
||||
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .camera import CameraStream
|
||||
from .controller import Controller, MODES
|
||||
from .event_store import EventStore
|
||||
|
||||
|
||||
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||
PROFILE_CLEANUP_INTERVAL_SECONDS = 3600
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModeInput(BaseModel):
|
||||
mode: str
|
||||
|
||||
|
||||
class ProfileInput(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class ProfileAssignment(BaseModel):
|
||||
profile_id: int | None
|
||||
|
||||
|
||||
async def require_ui(request: Request):
|
||||
if request.headers.get("X-Halloween-UI") != "1":
|
||||
raise HTTPException(status_code=403, detail="UI-Anfrage erforderlich")
|
||||
@@ -29,16 +42,32 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
directory = state_dir or Path(os.environ.get("HALLOWEEN_STATE_DIR", "data"))
|
||||
camera = CameraStream(demo=demo)
|
||||
controller = Controller(directory)
|
||||
events = EventStore(directory)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async def periodic_profile_cleanup():
|
||||
while True:
|
||||
await asyncio.sleep(PROFILE_CLEANUP_INTERVAL_SECONDS)
|
||||
try:
|
||||
events.cleanup_expired()
|
||||
except sqlite3.Error:
|
||||
LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden")
|
||||
|
||||
camera.start()
|
||||
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
camera.stop()
|
||||
|
||||
app = FastAPI(title="Halloween-Grab", lifespan=lifespan)
|
||||
app.state.camera = camera
|
||||
app.state.controller = controller
|
||||
app.state.events = events
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
@@ -57,6 +86,49 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
async def status():
|
||||
return {"controller": controller.status(), "camera": camera.status()}
|
||||
|
||||
@app.get("/api/events")
|
||||
async def recent_events(limit: int = Query(20, ge=1, le=100)):
|
||||
try:
|
||||
return {"events": events.recent(limit)}
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc
|
||||
|
||||
@app.get("/api/profiles")
|
||||
async def list_profiles():
|
||||
try:
|
||||
return {"profiles": events.profiles()}
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc
|
||||
|
||||
@app.post("/api/profiles", dependencies=[Depends(require_ui)])
|
||||
async def create_profile(payload: ProfileInput):
|
||||
try:
|
||||
return {"profile": events.create_profile(payload.label)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Profil nicht gespeichert: {exc}") from exc
|
||||
|
||||
@app.patch("/api/events/{event_id}/profile", dependencies=[Depends(require_ui)])
|
||||
async def assign_profile(event_id: int, payload: ProfileAssignment):
|
||||
try:
|
||||
events.assign_profile(event_id, payload.profile_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc.args[0])) from exc
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Zuordnung nicht gespeichert: {exc}") from exc
|
||||
return {"updated": True}
|
||||
|
||||
@app.delete("/api/profiles/{profile_id}", dependencies=[Depends(require_ui)])
|
||||
async def delete_profile(profile_id: int):
|
||||
try:
|
||||
deleted = events.delete_profile(profile_id)
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Profil nicht gelöscht: {exc}") from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
return {"deleted": True}
|
||||
|
||||
@app.post("/api/mode", dependencies=[Depends(require_ui)])
|
||||
async def set_mode(payload: ModeInput):
|
||||
if payload.mode not in MODES:
|
||||
@@ -75,6 +147,14 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
@app.post("/api/test-trigger", dependencies=[Depends(require_ui)])
|
||||
async def test_trigger():
|
||||
accepted, reason = controller.trigger()
|
||||
if accepted:
|
||||
try:
|
||||
events.record_test_trigger(controller.mode)
|
||||
except sqlite3.Error as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Test gestartet, aber Ereignis nicht gespeichert: {exc}",
|
||||
) from exc
|
||||
return {"accepted": accepted, "reason": reason, "controller": controller.status()}
|
||||
|
||||
@app.post("/api/stop", dependencies=[Depends(require_ui)])
|
||||
|
||||
@@ -5,6 +5,9 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
|
||||
- `test_controller.py` prüft Zustandswechsel, Stopp, Neustartvoraussetzungen
|
||||
und gespeicherten Modus mit kontrollierter Uhr und temporären Daten.
|
||||
- `test_web.py` prüft Demo-Bild und Webendpunkte über einen ASGI-Client.
|
||||
- `test_event_store.py` prüft dauerhafte Ereignisse, Profilzuordnung,
|
||||
Profil-Löschen, 30-Tage-Ablauf und die Erweiterung einer älteren
|
||||
Ereignistabelle.
|
||||
- Lokal ausführen: `python3 -m unittest discover -s tests -v`; für Webtests
|
||||
wird zusätzlich `requirements-dev.txt` benötigt.
|
||||
- Hardwareverhalten, echte Pi-Kamera, WLAN, SSH-Tunnel und Außeneinsatz nicht
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from halloween.event_store import EventStore
|
||||
from halloween.web import create_app
|
||||
|
||||
|
||||
class EventStoreTest(unittest.TestCase):
|
||||
def test_events_and_profiles_persist_and_delete_keeps_events(self):
|
||||
async def scenario(folder: Path):
|
||||
headers = {"X-Halloween-UI": "1"}
|
||||
app = create_app(demo=True, state_dir=folder)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://local") as client:
|
||||
self.assertEqual((await client.get("/api/events")).json()["events"], [])
|
||||
self.assertEqual((await client.post("/api/profiles", json={"label": "Gast"})).status_code, 403)
|
||||
profile_response = await client.post(
|
||||
"/api/profiles", headers=headers, json={"label": " Gast "}
|
||||
)
|
||||
self.assertEqual(profile_response.status_code, 200)
|
||||
profile_id = profile_response.json()["profile"]["id"]
|
||||
self.assertEqual(profile_response.json()["profile"]["label"], "Gast")
|
||||
await client.post("/api/zone-clear", headers=headers)
|
||||
triggered = await client.post("/api/test-trigger", headers=headers)
|
||||
self.assertTrue(triggered.json()["accepted"])
|
||||
self.assertFalse((await client.post("/api/test-trigger", headers=headers)).json()["accepted"])
|
||||
events = (await client.get("/api/events")).json()["events"]
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0]["kind"], "test_trigger")
|
||||
event_id = events[0]["id"]
|
||||
self.assertEqual(
|
||||
(await client.patch(
|
||||
f"/api/events/{event_id}/profile", headers=headers,
|
||||
json={"profile_id": profile_id},
|
||||
)).status_code, 200,
|
||||
)
|
||||
self.assertEqual(
|
||||
(await client.get("/api/profiles")).json()["profiles"][0]["event_count"], 1
|
||||
)
|
||||
|
||||
restarted = create_app(demo=True, state_dir=folder)
|
||||
transport = httpx.ASGITransport(app=restarted)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://local") as client:
|
||||
event = (await client.get("/api/events")).json()["events"][0]
|
||||
self.assertEqual(event["profile_id"], profile_id)
|
||||
self.assertEqual(event["profile_label"], "Gast")
|
||||
self.assertEqual(
|
||||
(await client.delete(f"/api/profiles/{profile_id}", headers=headers)).status_code,
|
||||
200,
|
||||
)
|
||||
remaining = (await client.get("/api/events")).json()["events"]
|
||||
self.assertEqual(len(remaining), 1)
|
||||
self.assertIsNone(remaining[0]["profile_id"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
asyncio.run(scenario(Path(folder)))
|
||||
mode = stat.S_IMODE((Path(folder) / "events.sqlite3").stat().st_mode)
|
||||
self.assertEqual(mode, 0o600)
|
||||
|
||||
def test_existing_event_table_is_extended_without_losing_rows(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
db_path = Path(folder) / "events.sqlite3"
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE events (id INTEGER PRIMARY KEY, occurred_at TEXT NOT NULL, "
|
||||
"kind TEXT NOT NULL, mode TEXT NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
|
||||
("2026-10-01T00:00:00+00:00", "test_trigger", "voll"),
|
||||
)
|
||||
store = EventStore(Path(folder))
|
||||
self.assertEqual(len(store.recent()), 1)
|
||||
self.assertIsNone(store.recent()[0]["profile_id"])
|
||||
|
||||
def test_auto_profiles_expire_after_30_days_and_manual_profiles_remain(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
store = EventStore(Path(folder))
|
||||
manual = store.create_profile("Bekannter Gast")
|
||||
now = datetime.now(timezone.utc)
|
||||
vector = [1.0] + [0.0] * 31
|
||||
auto = store.create_auto_profile(vector, "test-model", now=now)
|
||||
self.assertEqual(
|
||||
store.best_template_match(vector, "test-model", now=now)[0], auto["id"]
|
||||
)
|
||||
self.assertIsNone(store.best_template_match(vector, "other-model", now=now)[0])
|
||||
event_id = store.record_test_trigger("voll")
|
||||
store.assign_profile(event_id, auto["id"])
|
||||
self.assertEqual(store.cleanup_expired(now + timedelta(days=29)), 0)
|
||||
self.assertEqual(store.cleanup_expired(now + timedelta(days=31)), 1)
|
||||
self.assertEqual([profile["id"] for profile in store.profiles()], [manual["id"]])
|
||||
self.assertIsNone(store.recent()[0]["profile_id"])
|
||||
self.assertIsNone(
|
||||
store.best_template_match(vector, "test-model", now=now + timedelta(days=31))[0]
|
||||
)
|
||||
|
||||
def test_expired_profiles_are_removed_during_read_without_restart(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
store = EventStore(Path(folder))
|
||||
past = datetime.now(timezone.utc) - timedelta(days=31)
|
||||
auto = store.create_auto_profile([1.0] + [0.0] * 31, "test-model", now=past)
|
||||
self.assertEqual(store.profiles(), [])
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
self.assertEqual(
|
||||
connection.execute(
|
||||
"SELECT COUNT(*) FROM profile_templates WHERE profile_id = ?",
|
||||
(auto["id"],),
|
||||
).fetchone()[0], 0,
|
||||
)
|
||||
|
||||
def test_repeat_sighting_extends_retention_from_last_seen(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
store = EventStore(Path(folder))
|
||||
now = datetime.now(timezone.utc)
|
||||
auto = store.create_auto_profile([1.0] + [0.0] * 31, "test-model", now=now)
|
||||
store.touch_auto_profile(auto["id"], now=now + timedelta(days=20))
|
||||
self.assertEqual(store.cleanup_expired(now + timedelta(days=31)), 0)
|
||||
self.assertEqual(store.cleanup_expired(now + timedelta(days=50)), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user