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
+637 -21

No files matched your search

+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.
+85 -5
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()
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)])