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:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user