feat: initial CamWatch service
Polls Frigate events API, extracts dominant vehicle color (KMeans), reads LPR plate from Frigate sub_label. Responsive dark UI with filter by plate/camera/date and auto-refresh. FastAPI + SQLite. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+100
@@ -0,0 +1,100 @@
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, Query
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from datetime import datetime
|
||||
|
||||
import database
|
||||
import watcher
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
|
||||
DATA_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
database.init_db()
|
||||
t = threading.Thread(target=watcher.run_watcher, daemon=True)
|
||||
t.start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.mount("/snapshots", StaticFiles(directory="/data/snapshots"), name="snapshots")
|
||||
|
||||
templates = Jinja2Templates(directory="/app/templates")
|
||||
|
||||
|
||||
def ts_to_str(ts):
|
||||
try:
|
||||
return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M:%S")
|
||||
except Exception:
|
||||
return str(ts)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
plate: str = Query(""),
|
||||
camera: str = Query(""),
|
||||
date: str = Query(""),
|
||||
):
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
events = database.get_events(limit=limit, offset=offset,
|
||||
plate_filter=plate or None,
|
||||
camera_filter=camera or None,
|
||||
date_filter=date or None)
|
||||
total = database.count_events(plate_filter=plate or None,
|
||||
camera_filter=camera or None,
|
||||
date_filter=date or None)
|
||||
cameras = database.get_cameras()
|
||||
total_pages = max(1, (total + limit - 1) // limit)
|
||||
|
||||
for ev in events:
|
||||
ev["time_str"] = ts_to_str(ev["start_time"])
|
||||
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"events": events,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"cameras": cameras,
|
||||
"filter_plate": plate,
|
||||
"filter_camera": camera,
|
||||
"filter_date": date,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/events")
|
||||
async def api_events(
|
||||
page: int = Query(1, ge=1),
|
||||
plate: str = Query(""),
|
||||
camera: str = Query(""),
|
||||
date: str = Query(""),
|
||||
):
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
events = database.get_events(limit=limit, offset=offset,
|
||||
plate_filter=plate or None,
|
||||
camera_filter=camera or None,
|
||||
date_filter=date or None)
|
||||
total = database.count_events(plate_filter=plate or None,
|
||||
camera_filter=camera or None,
|
||||
date_filter=date or None)
|
||||
for ev in events:
|
||||
ev["time_str"] = ts_to_str(ev["start_time"])
|
||||
return {"events": events, "total": total}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user