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
165 lines
5.2 KiB
Python
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"],
|
|
}
|