Files
yolo/backend/events.py
Kunpeng 25fa0c5825 feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
  YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
  uses read/write-through UPSERT; in-memory fallback remains for standalone
  AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
  lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
  share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
  executor and database on shutdown via FastAPI lifespan

Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
  + views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
  event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)

Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
  no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
  .env.example; add persistence unit tests; ruff clean
2026-08-13 16:50:23 +08:00

165 lines
5.2 KiB
Python

from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime
from typing import Any
from .storage import Database, utc_now_iso
EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
def _local_today_utc_boundary() -> str:
"""UTC Z-format timestamp of local midnight, for 'today' comparisons."""
local_midnight = datetime.now().astimezone().replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
return (
local_midnight.astimezone(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class EventStore:
"""Persistent alert event store backed by a single SQLite table.
Keeps at most ``max_events`` rows (newest first, ordered by rowid so
events created within the same second keep a stable insertion order).
"""
def __init__(
self,
db: Database | None = None,
max_events: int = 500,
) -> None:
self._db = db or Database(":memory:")
self._max_events = max(1, max_events)
def create(
self,
session_id: str,
classes: list[str],
detections: list[dict[str, Any]],
notification_channels: dict[str, bool],
) -> dict[str, Any]:
confidences = [
float(detection.get("confidence", 0.0))
for detection in detections
if detection.get("class") in classes
]
event = {
"id": uuid.uuid4().hex[:12],
"created_at": utc_now_iso(),
"session_id": session_id,
"classes": classes,
"max_confidence": round(max(confidences, default=0.0), 6),
"detection_count": len(detections),
"status": "pending",
"notification_channels": notification_channels,
"handled_at": None,
}
self._db.execute(
"""
INSERT INTO events (
id, created_at, session_id, classes, max_confidence,
detection_count, status, notification_channels, handled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event["id"],
event["created_at"],
session_id,
json.dumps(classes, ensure_ascii=False),
event["max_confidence"],
event["detection_count"],
event["status"],
json.dumps(notification_channels, ensure_ascii=False),
None,
),
)
self._db.execute(
"""
DELETE FROM events WHERE id IN (
SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ?
)
""",
(self._max_events,),
)
return event
def list(
self,
limit: int = 100,
status: str | None = None,
) -> list[dict[str, Any]]:
rows = self._db.execute(
"SELECT * FROM events ORDER BY rowid DESC",
).fetchall()
events = [self._to_dict(row) for row in rows]
if status:
events = [event for event in events if event["status"] == status]
return events[: max(1, min(limit, 500))]
def update(self, event_id: str, status: str) -> dict[str, Any] | None:
if status not in EVENT_STATUSES:
raise ValueError(f"Unsupported event status: {status}")
cursor = self._db.execute(
"""
UPDATE events SET status = ?, handled_at = ?
WHERE id = ?
""",
(status, None if status == "pending" else utc_now_iso(), event_id),
)
if cursor.rowcount == 0:
return None
row = self._db.execute(
"SELECT * FROM events WHERE id = ?",
(event_id,),
).fetchone()
return self._to_dict(row)
def summary(self) -> dict[str, int]:
rows = self._db.execute(
"SELECT created_at, classes, status FROM events",
).fetchall()
today_boundary = _local_today_utc_boundary()
today_events = 0
counts = {status: 0 for status in EVENT_STATUSES}
fire = 0
smoke = 0
for row in rows:
counts[row["status"]] += 1
classes = json.loads(row["classes"])
if row["created_at"] >= today_boundary:
today_events += 1
fire += "fire" in classes
smoke += "smoke" in classes
return {
"total": len(rows),
"today": today_events,
"pending": counts["pending"],
"acknowledged": counts["acknowledged"],
"resolved": counts["resolved"],
"fire": fire,
"smoke": smoke,
}
@staticmethod
def _to_dict(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"created_at": row["created_at"],
"session_id": row["session_id"],
"classes": json.loads(row["classes"]),
"max_confidence": row["max_confidence"],
"detection_count": row["detection_count"],
"status": row["status"],
"notification_channels": json.loads(row["notification_channels"]),
"handled_at": row["handled_at"],
}