From f15ab1d3992a2482968974d0d325395f0d8cc8a3 Mon Sep 17 00:00:00 2001 From: Marcinator2 Date: Thu, 1 Oct 2026 03:50:26 +0200 Subject: [PATCH] SQLite-Ereignisse und Personenprofile vorbereiten --- AGENTS.md | 15 ++- PLAN.md | 30 +++-- SOFTWARE.md | 26 +++- halloween/event_store.py | 241 ++++++++++++++++++++++++++++++++++++++ halloween/index.html | 114 +++++++++++++++++- halloween/instructions.md | 10 ++ halloween/web.py | 90 +++++++++++++- tests/instructions.md | 3 + tests/test_event_store.py | 129 ++++++++++++++++++++ 9 files changed, 637 insertions(+), 21 deletions(-) create mode 100644 halloween/event_store.py create mode 100644 tests/test_event_store.py diff --git a/AGENTS.md b/AGENTS.md index dff63e9..9ccc9a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/PLAN.md b/PLAN.md index 8e333b4..ab4f722 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/SOFTWARE.md b/SOFTWARE.md index 984b9cf..568ee52 100644 --- a/SOFTWARE.md +++ b/SOFTWARE.md @@ -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: diff --git a/halloween/event_store.py b/halloween/event_store.py new file mode 100644 index 0000000..2cb2cd1 --- /dev/null +++ b/halloween/event_store.py @@ -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 diff --git a/halloween/index.html b/halloween/index.html index 3a91bf4..cad1351 100644 --- a/halloween/index.html +++ b/halloween/index.html @@ -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; } } @@ -74,13 +80,28 @@

+
+

Gespeicherte Testereignisse

+

Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.

+ +
+
+

Personenprofile

+

Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.

+
+ + +
+ +
diff --git a/halloween/instructions.md b/halloween/instructions.md index d131193..895a8f7 100644 --- a/halloween/instructions.md +++ b/halloween/instructions.md @@ -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. diff --git a/halloween/web.py b/halloween/web.py index fc9e572..ab265e0 100644 --- a/halloween/web.py +++ b/halloween/web.py @@ -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() - yield - camera.stop() + 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)]) diff --git a/tests/instructions.md b/tests/instructions.md index ec6c0c9..058b7e7 100644 --- a/tests/instructions.md +++ b/tests/instructions.md @@ -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 diff --git a/tests/test_event_store.py b/tests/test_event_store.py new file mode 100644 index 0000000..0fb8fa2 --- /dev/null +++ b/tests/test_event_store.py @@ -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()