diff --git a/orangepi-server/requirements.txt b/orangepi-server/requirements.txt index 9d041a6..13377e5 100644 --- a/orangepi-server/requirements.txt +++ b/orangepi-server/requirements.txt @@ -2,3 +2,4 @@ fastapi>=0.110.0 uvicorn[standard]>=0.29.0 websockets>=11.0 aiohttp>=3.8.0 +aiosqlite>=0.18.0 diff --git a/orangepi-server/static/app.js b/orangepi-server/static/app.js index 10036fa..0529769 100644 --- a/orangepi-server/static/app.js +++ b/orangepi-server/static/app.js @@ -52,6 +52,19 @@ const I18N = { edit_zone_y1: "Y1", edit_zone_x2: "X2", edit_zone_y2: "Y2", + + tab_radar: "Radar", + tab_history: "Lịch sử", + history_title: "Lịch sử phát hiện", + history_summary_empty: "Chưa có dữ liệu — hãy vẫy tay trước radar để tạo bản ghi đầu tiên.", + history_summary_with: "Tổng {total} lần detect · {uniq} slot khác nhau trong ngày.", + history_date: "Ngày", + history_hour: "Giờ", + history_refresh: "Tải lại", + history_hour_bucket: "{hh}h — {n} lần detect", + history_no_data: "Không có bản ghi nào trong khoảng thời gian đã chọn", + history_detected: "Phát hiện", + history_xy: "Tọa độ", }, en: { page_title: "ESP32 LD2453 — Radar Web Visualizer", @@ -100,6 +113,19 @@ const I18N = { edit_zone_y1: "Y1", edit_zone_x2: "X2", edit_zone_y2: "Y2", + + tab_radar: "Radar", + tab_history: "History", + history_title: "Detection history", + history_summary_empty: "No records yet — wave in front of the radar to create the first row.", + history_summary_with: "Total {total} detections · {uniq} different slots for the day.", + history_date: "Date", + history_hour: "Hour", + history_refresh: "Refresh", + history_hour_bucket: "{hh}:00 — {n} detections", + history_no_data: "No detections in the selected window", + history_detected: "Detected", + history_xy: "Coordinates", }, }; @@ -803,6 +829,139 @@ function loop() { requestAnimationFrame(loop); } +/* ---------- Tab wiring + History tab ---------- */ +function switchTab(tabName) { + if (!["radar", "history"].includes(tabName)) tabName = "radar"; + State.activeTab = tabName; + localStorage.setItem("activeTab", tabName); + + $$(".tab-btn").forEach(b => { + const active = (b.dataset.tab === tabName); + b.classList.toggle("tab-active", active); + b.setAttribute("aria-selected", String(active)); + }); + $$("[data-tab-panel]").forEach(p => { + p.hidden = (p.dataset.tabPanel !== tabName); + }); + + if (tabName === "history") refreshHistory(); +} + +function pad2(n) { return String(n).padStart(2, "0"); } +function todayIso() { + const d = new Date(); + return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; +} + +function getHistoryFilters() { + const dateEl = $("#historyDate"); + const hs = $("#hourStart").value; + const he = $("#hourEnd").value; + return { + date: dateEl && dateEl.value ? dateEl.value : todayIso(), + hour_start: hs === "" || Number.isNaN(+hs) ? null : Math.min(23, Math.max(0, +hs | 0)), + hour_end: he === "" || Number.isNaN(+he) ? null : Math.min(23, Math.max(0, +he | 0)), + }; +} + +async function refreshHistory() { + const summary = $("#historySummary"); + const listEl = $("#historyList"); + const hourlyEl = $("#historyHourly"); + if (!listEl || !summary) return; + + const f = getHistoryFilters(); + const params = new URLSearchParams(); + params.set("date", f.date); + if (f.hour_start != null) params.set("hour_start", String(f.hour_start)); + if (f.hour_end != null) params.set("hour_end", String(f.hour_end)); + params.set("limit", "500"); + + try { + listEl.innerHTML = ""; + hourlyEl.innerHTML = ""; + summary.textContent = i18n("history_summary_empty"); + const res = await fetch("/api/history?" + params.toString()); + if (!res.ok) throw new Error("HTTP " + res.status); + const data = await res.json(); + renderHistory(data); + } catch (err) { + console.error(err); + listEl.innerHTML = `
${err.message || err}
`; + } +} + +function renderHistory(data) { + const summary = $("#historySummary"); + const listEl = $("#historyList"); + const hourlyEl = $("#historyHourly"); + if (!summary || !listEl || !hourlyEl) return; + + const total = (data.total_detections | 0); + const uniq = (data.unique_slots | 0); + if (total <= 0) { + summary.textContent = i18n("history_summary_empty"); + } else { + summary.textContent = i18n("history_summary_with") + .replace("{total}", String(total)) + .replace("{uniq}", String(uniq)); + } + + /* 24-hour bars */ + hourlyEl.innerHTML = ""; + const hourly = Array.isArray(data.hourly) ? data.hourly : []; + const maxCount = hourly.reduce((m, h) => Math.max(m, h.count | 0), 0); + hourly.forEach(h => { + const hh = pad2(h.hour | 0); + const count = h.count | 0; + const pct = maxCount > 0 ? Math.max(4, Math.round((count / maxCount) * 100)) : 0; + const el = document.createElement("div"); + el.className = "hour-bar"; + el.dataset.count = String(count); + el.style.height = (count > 0 ? pct : 2) + "%"; + el.title = i18n("history_hour_bucket") + .replace("{hh}", hh) + .replace("{n}", String(count)); + hourlyEl.appendChild(el); + }); + + /* Row list */ + const rows = Array.isArray(data.rows) ? data.rows : []; + if (!rows.length) { + listEl.innerHTML = `
${i18n("history_no_data")}
`; + return; + } + const frag = document.createDocumentFragment(); + rows.forEach(r => { + const iso = String(r.ts_local || ""); + const dt = new Date(iso.includes("T") ? iso : new Date((r.unixtime|0) * 1000).toISOString()); + const hh = pad2(dt.getHours()); + const mm = pad2(dt.getMinutes()); + const ss = pad2(dt.getSeconds()); + const slot = (r.slot | 0); + const x = (r.x_mm | 0); + const y = (r.y_mm | 0); + const dist = Number(r.distance_mm) || 0; + const ang = Number(r.angle_deg) || 0; + const spd = (r.speed_cm_s | 0); + + const row = document.createElement("div"); + row.className = "detect"; + row.innerHTML = ` +
${hh}:${mm}:${ss}
+
T${slot}
+
+ ${i18n("history_xy")}:(${x}, ${y})mm + ${i18n("angle")}:${ang.toFixed(1)}° + ${i18n("speed")}:${spd > 0 ? "+" : ""}${spd} cm/s +
+
${(dist/1000).toFixed(2)} m
+ `; + frag.appendChild(row); + }); + listEl.appendChild(frag); +} + /* ---------- Wiring up ---------- */ function initUI() { /* Theme toggle */ @@ -818,7 +977,6 @@ function initUI() { localStorage.setItem("lang", State.lang); applyI18n(); refreshPanels(); - buildEditorInputs(); }); /* Tool toggles */ @@ -826,38 +984,22 @@ function initUI() { $("#chkTrails").addEventListener("change", e => State.options.trails = e.target.checked); $("#chkLabels").addEventListener("change", e => State.options.labels = e.target.checked); - /* Zone editor */ - $("#btnEditZones").addEventListener("click", () => { - buildEditorInputs(); - $("#zoneEditor").hidden = false; - }); - $("#btnCancelZones").addEventListener("click", () => { - $("#zoneEditor").hidden = true; - }); - $("#btnResetZones").addEventListener("click", () => { - DEFAULT_ZONES.forEach((def, idx) => { - const z = State.radar.zones[idx]; - z.x1 = def.x1; z.y1 = def.y1; z.x2 = def.x2; z.y2 = def.y2; - }); - buildEditorInputs(); - }); - $("#zoneEditor").addEventListener("submit", async (e) => { - e.preventDefault(); - const payload = collectEditorPayload(); - try { - const res = await fetch("/api/zones", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error("HTTP " + res.status); - toast(i18n("zone_saved"), "success"); - $("#zoneEditor").hidden = true; - } catch (err) { - console.error(err); - toast(i18n("zone_save_error") + ": " + err.message, "error"); - } + /* Tabs: Radar / History */ + $$(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => switchTab(btn.dataset.tab)); }); + State.activeTab = localStorage.getItem("activeTab") || "radar"; + switchTab(State.activeTab); + + /* History filters */ + const dateEl = $("#historyDate"); + if (dateEl) dateEl.value = todayIso(); + if (dateEl) dateEl.addEventListener("change", () => refreshHistory()); + const hs = $("#hourStart"), he = $("#hourEnd"); + if (hs) hs.addEventListener("change", () => refreshHistory()); + if (he) he.addEventListener("change", () => refreshHistory()); + const btnRef = $("#btnRefreshHistory"); + if (btnRef) btnRef.addEventListener("click", () => refreshHistory()); /* Canvas resize */ window.addEventListener("resize", resizeCanvas); diff --git a/orangepi-server/static/style.css b/orangepi-server/static/style.css index 375e2c2..4c379f7 100644 --- a/orangepi-server/static/style.css +++ b/orangepi-server/static/style.css @@ -184,6 +184,7 @@ input, button { font-family: inherit; color: inherit; } .app-main { padding: 12px; gap: 14px; } .status-chip span:last-child { display: none; } .status-chip { padding: 6px 8px; } + .legend { display: none !important; } } /* ---------- Panels ---------- */ @@ -460,6 +461,140 @@ input, button { font-family: inherit; color: inherit; } box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-blue) 30%, transparent); } .btn-primary:hover { box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-blue) 40%, transparent); } +.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 8px; } + +/* Tabs (Radar / History) */ +.tabs-row { + display: flex; gap: 6px; padding: 6px; + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 12px; +} +.tab-btn { + flex: 1 1 0; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 8px 12px; + background: transparent; color: var(--text-muted); + border: none; border-radius: 10px; + font-size: 13px; font-weight: 600; cursor: pointer; + transition: all .2s; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.tab-active { + background: var(--surface); color: var(--text); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); +} +.tab-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.tab-panel { display: flex; flex-direction: column; gap: 16px; } + +/* History card + filters */ +.history-toolbar { + display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: start; + margin-bottom: 16px; +} +.history-toolbar-title h3 { margin-bottom: 4px; } +.history-summary { font-size: 12px; color: var(--text-muted); } +.history-filters { + display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; + justify-content: flex-end; +} +.field { display: inline-flex; flex-direction: column; gap: 4px; } +.field > span { font-size: 11px; color: var(--text-muted); font-weight: 600; } +.field input { + height: 34px; padding: 0 10px; + border-radius: 8px; border: 1px solid var(--border); + background: var(--bg-elev-1); color: var(--text); + font-size: 12px; font-family: "JetBrains Mono", monospace; +} +.field-range .range { display: inline-flex; align-items: center; gap: 6px; } +.field-range .range input { width: 68px; } +.field-range .sep { color: var(--text-dim); font-size: 12px; } + +/* 24-hour bar summary */ +.history-hourly { + display: grid; grid-template-columns: repeat(24, 1fr); + gap: 3px; align-items: end; + height: 90px; margin-bottom: 18px; + padding: 8px 6px; + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.hour-bar { + position: relative; + width: 100%; + background: linear-gradient(180deg, var(--accent-blue), color-mix(in srgb, var(--accent-mauve) 60%, var(--accent-blue))); + border-radius: 3px 3px 0 0; + min-height: 2px; + max-height: 100%; + transition: transform .2s; + cursor: help; +} +.hour-bar:hover { transform: scaleY(1.1); } +.hour-bar[data-count="0"] { background: var(--bg-elev-2); opacity: .6; } +.hour-bar[title]::after { + content: attr(title); + position: absolute; bottom: calc(100% + 6px); left: 50%; + transform: translateX(-50%); + background: var(--surface); border: 1px solid var(--border); + color: var(--text); font-size: 11px; padding: 2px 6px; border-radius: 6px; + white-space: nowrap; display: none; box-shadow: var(--shadow-sm); +} +.hour-bar:hover[title]::after { display: block; } + +/* Detection list items */ +.history-list { + display: flex; flex-direction: column; gap: 8px; + max-height: 560px; overflow: auto; + padding-right: 4px; +} +.history-empty { + padding: 22px 14px; text-align: center; + color: var(--text-muted); font-size: 13px; + border: 1px dashed var(--border); border-radius: 10px; +} +.detect { + display: grid; grid-template-columns: auto auto 1fr auto; + gap: 10px 14px; align-items: center; + padding: 10px 12px; + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: background .2s; +} +.detect:hover { background: var(--bg-elev-2); } +.detect-time { + font-family: "JetBrains Mono", monospace; + font-size: 12px; color: var(--accent-blue); font-weight: 700; +} +.detect-slot { + width: 36px; height: 36px; border-radius: 10px; + display: inline-flex; align-items: center; justify-content: center; + font-weight: 800; font-size: 14px; + border: 1px solid; +} +.detect-slot.s1 { background: color-mix(in srgb, var(--z1) 30%, transparent); color: var(--z1); border-color: color-mix(in srgb, var(--z1) 60%, transparent); } +.detect-slot.s2 { background: color-mix(in srgb, var(--z2) 30%, transparent); color: var(--z2); border-color: color-mix(in srgb, var(--z2) 60%, transparent); } +.detect-slot.s3 { background: color-mix(in srgb, var(--z3) 30%, transparent); color: var(--z3); border-color: color-mix(in srgb, var(--z3) 60%, transparent); } +.detect-meta { + display: flex; flex-wrap: wrap; gap: 10px 14px; + font-size: 12px; color: var(--text-muted); +} +.detect-meta .kv { display: inline-flex; gap: 4px; align-items: baseline; } +.detect-meta .kv b { color: var(--text); font-weight: 600; font-family: "JetBrains Mono", monospace; } +.detect-dist { + font-weight: 700; color: var(--text); font-size: 14px; + font-family: "JetBrains Mono", monospace; +} + +@media (max-width: 600px) { + .history-toolbar { grid-template-columns: 1fr; } + .history-filters { justify-content: flex-start; } + .detect { grid-template-columns: auto 1fr auto; row-gap: 6px; } + .detect-time { grid-column: 1 / -1; } +} /* Toast */ .toast-container { diff --git a/orangepi-server/templates/index.html b/orangepi-server/templates/index.html index e60ec63..9d41ab7 100644 --- a/orangepi-server/templates/index.html +++ b/orangepi-server/templates/index.html @@ -130,55 +130,89 @@
- -
-

Thống kê

-
-
-
Frames nhận
-
0
-
-
-
Cập nhật cách đây
-
—s
-
-
-
Target hợp lệ
-
0 / 3
-
-
-
Vùng có người
-
0 / 3
-
-
+ + + +
+ +
- -
-

Chi tiết Target

-
- -
-
+ +
- -
-
+ +
+

Thống kê

+
+
+
Frames nhận
+
0
+
+
+
Cập nhật cách đây
+
—s
+
+
+
Target hợp lệ
+
0 / 3
+
+
+
Vùng có người
+
0 / 3
+
+
+
+ + +
+

Chi tiết Target

+
+ +
+
+ + +

Zones

- +
-
+
- -
diff --git a/orangepi-server/visualizer.py b/orangepi-server/visualizer.py index e45c1c2..0de33a1 100644 --- a/orangepi-server/visualizer.py +++ b/orangepi-server/visualizer.py @@ -32,10 +32,15 @@ from typing import List, Set # ------------------------------------------------------------ try: import websockets - from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect + from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles import aiohttp + try: + import aiosqlite # async SQLite driver (>=0.18) + except ImportError: # allow fallback to stdlib sqlite3 for dev + aiosqlite = None + import sqlite3 except ImportError as e: print(f"Lỗi: Thiếu thư viện. Cần cài đặt trước: pip install -r requirements.txt") print(f"Chi tiết: {e}") @@ -81,6 +86,204 @@ class RadarState: last_update: float = 0.0 esp32_connected: bool = False + +# ============================================================ +# SQLite-backed detection history store +# +# We deduplicate writes by slot: when a slot stays continuously valid +# (e.g. a person standing still for 60 seconds), we only persist 1 row +# every SAMPLE_EVERY_N_SECONDS instead of every heartbeat frame, which +# keeps the database small on a Raspberry/Orange Pi flash filesystem. +# Switching a slot from INVALID -> VALID always writes immediately so +# entry events have accurate timestamps. +# ============================================================ +class TargetStore: + SAMPLE_EVERY_N_SECONDS = 2.0 # ~30 writes/slot/minute max + + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self._conn_sync: "sqlite3.Connection | None" = None + self._conn_async = None + self._last_written_ts: dict[int, float] = {} + + # ---------- init / close ---------- + async def init_db(self): + # Create parent folder if needed. + self.db_path.parent.mkdir(parents=True, exist_ok=True) + schema_sql = """ + CREATE TABLE IF NOT EXISTS detections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts_utc TEXT NOT NULL, -- ISO8601 (UTC) + ts_local TEXT NOT NULL, -- ISO8601 (local tz) + unixtime REAL NOT NULL, -- seconds since epoch + slot INTEGER NOT NULL CHECK (slot IN (1,2,3)), + x_mm INTEGER NOT NULL, + y_mm INTEGER NOT NULL, + speed_cm_s INTEGER NOT NULL, + distance_mm REAL NOT NULL, + angle_deg REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_detections_dayhour + ON detections(slot, unixtime); + """ + if aiosqlite is not None: + self._conn_async = await aiosqlite.connect(str(self.db_path)) + await self._conn_async.executescript(schema_sql) + await self._conn_async.commit() + else: + self._conn_sync = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._conn_sync.executescript(schema_sql) + self._conn_sync.commit() + + async def close(self): + if self._conn_async is not None: + try: + await self._conn_async.close() + finally: + self._conn_async = None + if self._conn_sync is not None: + try: + self._conn_sync.close() + finally: + self._conn_sync = None + + # ---------- internal runner (async preferred, sync fallback) ---------- + async def _run_write(self, sql: str, params: tuple) -> None: + if aiosqlite is not None and self._conn_async is not None: + await self._conn_async.execute(sql, params) + await self._conn_async.commit() + return + if self._conn_sync is None: + return + def _job(): + assert self._conn_sync is not None + self._conn_sync.execute(sql, params) + self._conn_sync.commit() + await asyncio.to_thread(_job) + + async def _run_read(self, sql: str, params: tuple) -> list[dict]: + if aiosqlite is not None and self._conn_async is not None: + conn = self._conn_async + conn.row_factory = aiosqlite.Row # type: ignore[union-attr] + async with conn.execute(sql, params) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + if self._conn_sync is None: + return [] + def _job(): + assert self._conn_sync is not None + self._conn_sync.row_factory = sqlite3.Row + cur = self._conn_sync.execute(sql, params) + return [dict(r) for r in cur.fetchall()] + return await asyncio.to_thread(_job) + + # ---------- write API (dedup per slot) ---------- + async def maybe_record(self, targets: list[Target], now_ts: float) -> None: + import datetime as _dt + for t in targets: + if not t.valid: + # Slot went invalid; reset its timer so the *next* valid + # event flushes immediately (captures re-entry into the room). + self._last_written_ts.pop(t.slot, None) + continue + prev = self._last_written_ts.get(t.slot, None) + is_first_valid_entry = prev is None + enough_time_passed = ( + prev is None or (now_ts - prev) >= self.SAMPLE_EVERY_N_SECONDS + ) + if not (is_first_valid_entry or enough_time_passed): + continue + self._last_written_ts[t.slot] = now_ts + utc = _dt.datetime.fromtimestamp(now_ts, tz=_dt.timezone.utc) + local = _dt.datetime.fromtimestamp(now_ts).astimezone() + sql = ( + "INSERT INTO detections" + " (ts_utc, ts_local, unixtime, slot, x_mm, y_mm," + " speed_cm_s, distance_mm, angle_deg)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + await self._run_write( + sql, + ( + utc.isoformat(timespec="seconds"), + local.isoformat(timespec="seconds"), + round(now_ts, 3), + int(t.slot), + int(t.x_mm), + int(t.y_mm), + int(t.speed_cm_s), + round(float(t.distance_mm), 2), + round(float(t.angle_deg), 4), + ), + ) + + # ---------- read API ---------- + async def query( + self, + date_iso: str, + hour_start: int | None = None, + hour_end: int | None = None, + limit: int = 500, + ) -> dict: + """date_iso = YYYY-MM-DD in the server LOCAL timezone (matches what + returns from a browser in the same tz).""" + import datetime as _dt + try: + day = _dt.date.fromisoformat(date_iso) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"date không hợp lệ: {e}") + day_start_local = _dt.datetime.combine(day, _dt.time(0, 0, 0)).astimezone() + day_end_local = day_start_local + _dt.timedelta(days=1) + unixtime_start = day_start_local.timestamp() + unixtime_end = day_end_local.timestamp() + where = ["unixtime >= ? AND unixtime < ?"] + params: list = [unixtime_start, unixtime_end] + if hour_start is not None and 0 <= hour_start <= 23: + where.append("CAST(strftime('%H', ts_local) AS INTEGER) >= ?") + params.append(int(hour_start)) + if hour_end is not None and 0 <= hour_end <= 23: + where.append("CAST(strftime('%H', ts_local) AS INTEGER) <= ?") + params.append(int(hour_end)) + + # 1) Raw rows (newest first) + rows_sql = ( + "SELECT id, ts_local, unixtime, slot, x_mm, y_mm," + " speed_cm_s, distance_mm, angle_deg" + " FROM detections WHERE " + " AND ".join(where) + + " ORDER BY unixtime DESC LIMIT ?" + ) + rows = await self._run_read(rows_sql, tuple(params + [int(limit)])) + + # 2) 24 hourly buckets (0..23) → count+unique slots for the day filter. + hour_sql = ( + "SELECT CAST(strftime('%H', ts_local) AS INTEGER) AS hh," + " COUNT(*) AS n, COUNT(DISTINCT slot) AS uniq_slots" + " FROM detections WHERE " + " AND ".join(where) + + " GROUP BY hh ORDER BY hh ASC" + ) + buckets_raw = await self._run_read(hour_sql, tuple(params)) + hourly = [{"hour": h, "count": 0, "uniq_slots": 0} for h in range(24)] + for b in buckets_raw: + hourly[int(b["hh"])]["count"] = int(b["n"]) + hourly[int(b["hh"])]["uniq_slots"] = int(b["uniq_slots"]) + + total = sum(h["count"] for h in hourly) + unique_slots_total = len({r["slot"] for r in rows}) if rows else 0 + return { + "date": date_iso, + "filters": { + "hour_start": hour_start, + "hour_end": hour_end, + "limit": limit, + }, + "total_detections": total, + "unique_slots": unique_slots_total, + "hourly": hourly, + "rows": rows, + } + + + def to_json(self) -> dict: """Serialize to something the browser can render.""" now = time.time() @@ -182,6 +385,17 @@ class RadarState: self.frames_received += 1 self.last_update = now + # Persist valid targets into the SQLite history (dedup per slot). + # We reference the singleton STORE from the FastAPI lifecycle; this + # indirection keeps RadarState pure (no knowledge of DB engine). + store = globals().get("STORE") + if store is not None: + try: + await store.maybe_record(self.targets, now) + except Exception: + # Never let a DB write failure break the realtime radar loop. + pass + # ============================================================ # Global app state @@ -372,13 +586,25 @@ async def broadcast_to_browsers(): BASE_DIR = Path(__file__).resolve().parent TEMPLATES_DIR = BASE_DIR / "templates" STATIC_DIR = BASE_DIR / "static" +DATA_DIR = BASE_DIR / "data" STATIC_DIR.mkdir(exist_ok=True) TEMPLATES_DIR.mkdir(exist_ok=True) +DATA_DIR.mkdir(exist_ok=True) + +DB_PATH = DATA_DIR / "detections.sqlite3" +STORE: "TargetStore | None" = None # initialized in lifespan, exported for RadarState @asynccontextmanager async def lifespan(app: FastAPI): - """Start background tasks when app starts, cancel them on shutdown.""" + """Open the SQLite store, start background tasks, close store on shutdown.""" + global STORE + STORE = TargetStore(DB_PATH) + try: + await STORE.init_db() + except Exception as e: + log.warning(f"Không thể khởi tạo SQLite history (tiếp tục chạy radar without DB): {e}") + STORE = None tasks = [ asyncio.create_task(esp32_ws_forever(ESP32_HOST)), asyncio.create_task(heartbeat_broadcast()), @@ -387,6 +613,11 @@ async def lifespan(app: FastAPI): for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) + if STORE is not None: + try: + await STORE.close() + finally: + STORE = None app = FastAPI( @@ -430,6 +661,30 @@ async def update_zones(payload: list): return await post_zones_to_esp32(ESP32_HOST, payload) +@app.get("/api/history") +async def get_history( + date: str = Query(..., description="YYYY-MM-DD theo múi giờ máy chủ (local tz)"), + hour_start: int | None = Query(None, ge=0, le=23), + hour_end: int | None = Query(None, ge=0, le=23), + limit: int = Query(500, ge=1, le=10000), +): + """Trả về lịch sử detect target (raw + 24 buckets theo giờ).""" + if STORE is None: + raise HTTPException(status_code=503, detail="SQLite history store chưa sẵn sàng") + try: + return await STORE.query( + date_iso=date, + hour_start=hour_start, + hour_end=hour_end, + limit=limit, + ) + except HTTPException: + raise + except Exception as e: + log.exception(f"GET /api/history failed: {e}") + raise HTTPException(status_code=500, detail=f"Lỗi truy vấn history: {e}") + + # --- WebSocket route for browsers ----------------------------- @app.websocket("/ws") async def browser_ws_endpoint(ws: WebSocket):