Private
Public Access
SQLite-Ereignisse und Personenprofile vorbereiten
This commit is contained in:
1 parent
643146599d
commit
f15ab1d399
9 files changed
+637
-21
No files matched your search
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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)])
|
||||
|
||||
Reference in new issue
Block a user