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
This commit is contained in:
2026-08-13 16:50:23 +08:00
parent 4dee7f664d
commit 25fa0c5825
25 changed files with 1058 additions and 669 deletions

View File

@@ -11,7 +11,6 @@ import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
@@ -19,6 +18,8 @@ from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont
from .storage import DeliveryStatsStore, utc_now_iso
LOGGER = logging.getLogger(__name__)
ALERT_CLASSES = ("fire", "smoke")
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
@@ -45,6 +46,7 @@ class AlertManager:
confirm_frames: int = 3,
cooldown_seconds: float = 60.0,
session_ttl_seconds: float = 3600.0,
stats_store: DeliveryStatsStore | None = None,
) -> None:
self.wechat_webhook_url = (
wechat_webhook_url
@@ -58,6 +60,7 @@ class AlertManager:
self.confirm_frames = max(1, confirm_frames)
self.cooldown_seconds = max(0.0, cooldown_seconds)
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
self.stats_store = stats_store
self._states: dict[str, SessionState] = {}
self._lock = threading.Lock()
self._delivery_stats = {
@@ -92,11 +95,27 @@ class AlertManager:
@property
def channel_status(self) -> list[dict[str, Any]]:
configured = self.channels
with self._lock:
if self.stats_store is not None:
saved = self.stats_store.get_all()
stats = {
channel: dict(values)
for channel, values in self._delivery_stats.items()
channel: {
"delivered": 0,
"failed": 0,
"tests": 0,
"last_status": "idle",
"last_delivery_at": None,
"last_test_at": None,
"last_error": None,
**saved.get(channel, {}),
}
for channel in CHANNEL_NAMES
}
else:
with self._lock:
stats = {
channel: dict(values)
for channel, values in self._delivery_stats.items()
}
return [
{
"id": channel,
@@ -191,6 +210,10 @@ class AlertManager:
with self._lock:
self._states.pop(session_id, None)
def close(self) -> None:
"""Shut down the alert dispatch executor (call on service shutdown)."""
self._executor.shutdown(wait=True, cancel_futures=True)
def _prune_sessions(self, now: float) -> None:
expired = [
session_id
@@ -230,7 +253,15 @@ class AlertManager:
error: str | None = None,
test: bool = False,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
if self.stats_store is not None:
self.stats_store.record(
channel,
success=success,
error=error,
test=test,
)
return
now = utc_now_iso()
with self._lock:
stats = self._delivery_stats[channel]
stats["last_status"] = "success" if success else "failed"
@@ -386,7 +417,7 @@ def alert_summary(
def feishu_signature(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
string_to_sign = f"{timestamp}\n{secret}".encode()
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
@@ -429,7 +460,7 @@ def annotate_image(
class_name = str(detection.get("class", "target"))
confidence = float(detection.get("confidence", 0.0))
color = colors.get(class_name, "#ffd166")
coordinates = tuple(int(round(value)) for value in box)
coordinates = tuple(round(value) for value in box)
draw.rectangle(coordinates, outline=color, width=4)
draw.text(
(coordinates[0] + 4, max(0, coordinates[1] - 16)),