Private
Public Access
336 lines
15 KiB
Python
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
|