Files
2026-10-14 17:40:31 +02:00

336 lines
15 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
from io import BytesIO
import re
import tempfile
import uuid
from PIL import Image
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"
self.image_dir = state_dir / "event-images"
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"
)
if "image_filename" not in columns:
connection.execute("ALTER TABLE events ADD COLUMN image_filename TEXT")
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, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "person_line_crossing", jpeg, region)
def record_motion_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "motion_direction_test", jpeg, region)
def _save_image(self, jpeg: bytes, region) -> str:
if (region is None or len(region) != 4 or
any(not math.isfinite(v) or not 0 <= v <= 1 for v in region) or
region[0] >= region[2] or region[1] >= region[3]):
raise ValueError("Ungültiger Bildbereich")
with Image.open(BytesIO(jpeg)) as image:
width, height = image.size
# Round inward: no pixel outside the chosen private region is saved.
box = (math.ceil(region[0]*width), math.ceil(region[1]*height),
math.floor(region[2]*width), math.floor(region[3]*height))
if box[2] <= box[0] or box[3] <= box[1]:
raise ValueError("Bildbereich ist zu klein")
cropped = image.crop(box).convert('RGB')
self.image_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.image_dir, 0o700)
filename = uuid.uuid4().hex + '.jpg'
target = self.image_dir / filename
with tempfile.NamedTemporaryFile(dir=self.image_dir, suffix='.tmp', delete=False) as stream:
temporary = Path(stream.name)
try:
cropped.save(stream, format='JPEG', quality=90)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
return filename
def _record_trigger(self, mode: str, kind: str, jpeg=None, region=None) -> int:
occurred_at = _timestamp(_utc_now())
filename = self._save_image(jpeg, region) if jpeg is not None else None
try:
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode, image_filename) VALUES (?, ?, ?, ?)",
(occurred_at, kind, mode, filename),
)
event_id = cursor.lastrowid
return event_id
except Exception:
if filename:
(self.image_dir / filename).unlink(missing_ok=True)
raise
def _image_path(self, filename):
if not isinstance(filename, str) or not re.fullmatch(r'[0-9a-f]{32}\.jpg', filename):
raise KeyError("Kein Bild für dieses Ereignis")
path = self.image_dir / filename
if path.is_symlink():
raise KeyError("Kein Bild für dieses Ereignis")
return path
def image_path(self, event_id: int) -> Path:
with closing(self._connect()) as connection:
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
if row is None:
raise KeyError("Ereignis nicht gefunden")
path = self._image_path(row['image_filename'])
if not path.is_file():
raise KeyError("Bild nicht vorhanden")
return path
def delete_image(self, event_id: int) -> bool:
with closing(self._connect()) as connection:
with connection:
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
if row is None:
raise KeyError("Ereignis nicht gefunden")
if row['image_filename'] is None:
return False
path = self._image_path(row['image_filename'])
path.unlink(missing_ok=True)
connection.execute("UPDATE events SET image_filename = NULL WHERE id = ?", (event_id,))
return True
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, e.image_filename, 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()
result = []
for row in rows:
event = dict(row)
filename = event.pop('image_filename')
try:
present = self._image_path(filename).is_file()
except KeyError:
present = False
event['image_url'] = f"/api/events/{event['id']}/image" if present else None
result.append(event)
return result
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