SQLite-Ereignisse und Personenprofile vorbereiten

This commit is contained in:
marcinator_2 committed 2026-10-01 03:50:26 +02:00
1 parent 643146599d
commit f15ab1d399
9 files changed
+635 -19

No files matched your search

+12 -3
View File
@@ -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.
+23 -7
View File
@@ -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
View File
@@ -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:
+241
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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
View File
@@ -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)])
+3
View File
@@ -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
+129
View File
@@ -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()