Private
Public Access
248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""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:
|
|
return self._record_trigger(mode, "test_trigger")
|
|
|
|
def record_detection_trigger(self, mode: str) -> int:
|
|
return self._record_trigger(mode, "person_line_crossing")
|
|
|
|
def _record_trigger(self, mode: str, kind: 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, kind, 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
|