update giao diện web

This commit is contained in:
admin committed 2026-09-13 17:24:03 +07:00
1 parent 1682808956
commit 4b5e95500f
5 files changed
+615 -48

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.110.0
uvicorn[standard]>=0.29.0 uvicorn[standard]>=0.29.0
websockets>=11.0 websockets>=11.0
aiohttp>=3.8.0 aiohttp>=3.8.0
aiosqlite>=0.18.0
+174 -32
View File
@@ -52,6 +52,19 @@ const I18N = {
edit_zone_y1: "Y1", edit_zone_y1: "Y1",
edit_zone_x2: "X2", edit_zone_x2: "X2",
edit_zone_y2: "Y2", 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: { en: {
page_title: "ESP32 LD2453 — Radar Web Visualizer", page_title: "ESP32 LD2453 — Radar Web Visualizer",
@@ -100,6 +113,19 @@ const I18N = {
edit_zone_y1: "Y1", edit_zone_y1: "Y1",
edit_zone_x2: "X2", edit_zone_x2: "X2",
edit_zone_y2: "Y2", 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); 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 = `<div class="history-empty">${err.message || err}</div>`;
}
}
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 = `<div class="history-empty">${i18n("history_no_data")}</div>`;
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 = `
<div class="detect-time">${hh}:${mm}:${ss}</div>
<div class="detect-slot s${Math.min(3, Math.max(1, slot))}">T${slot}</div>
<div class="detect-meta">
<span class="kv"><span>${i18n("history_xy")}:</span><b>(${x}, ${y})mm</b></span>
<span class="kv"><span>${i18n("angle")}:</span><b>${ang.toFixed(1)}°</b></span>
<span class="kv"><span>${i18n("speed")}:</span><b>${spd > 0 ? "+" : ""}${spd} cm/s</b></span>
</div>
<div class="detect-dist">${(dist/1000).toFixed(2)} m</div>
`;
frag.appendChild(row);
});
listEl.appendChild(frag);
}
/* ---------- Wiring up ---------- */ /* ---------- Wiring up ---------- */
function initUI() { function initUI() {
/* Theme toggle */ /* Theme toggle */
@@ -818,7 +977,6 @@ function initUI() {
localStorage.setItem("lang", State.lang); localStorage.setItem("lang", State.lang);
applyI18n(); applyI18n();
refreshPanels(); refreshPanels();
buildEditorInputs();
}); });
/* Tool toggles */ /* Tool toggles */
@@ -826,38 +984,22 @@ function initUI() {
$("#chkTrails").addEventListener("change", e => State.options.trails = e.target.checked); $("#chkTrails").addEventListener("change", e => State.options.trails = e.target.checked);
$("#chkLabels").addEventListener("change", e => State.options.labels = e.target.checked); $("#chkLabels").addEventListener("change", e => State.options.labels = e.target.checked);
/* Zone editor */ /* Tabs: Radar / History */
$("#btnEditZones").addEventListener("click", () => { $$(".tab-btn").forEach(btn => {
buildEditorInputs(); btn.addEventListener("click", () => switchTab(btn.dataset.tab));
$("#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");
}
}); });
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 */ /* Canvas resize */
window.addEventListener("resize", resizeCanvas); window.addEventListener("resize", resizeCanvas);
+135
View File
@@ -184,6 +184,7 @@ input, button { font-family: inherit; color: inherit; }
.app-main { padding: 12px; gap: 14px; } .app-main { padding: 12px; gap: 14px; }
.status-chip span:last-child { display: none; } .status-chip span:last-child { display: none; }
.status-chip { padding: 6px 8px; } .status-chip { padding: 6px 8px; }
.legend { display: none !important; }
} }
/* ---------- Panels ---------- */ /* ---------- 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); 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-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 */
.toast-container { .toast-container {
+48 -14
View File
@@ -130,6 +130,23 @@
<!-- ======= RIGHT / BOTTOM: INFO PANELS ======= --> <!-- ======= RIGHT / BOTTOM: INFO PANELS ======= -->
<section class="panel panel-side"> <section class="panel panel-side">
<!-- ===================================================== -->
<!-- TOP TABS: Radar / Lịch sử -->
<!-- ===================================================== -->
<div class="tabs-row" role="tablist" aria-label="Views">
<button role="tab" class="tab-btn tab-active" data-tab="radar" aria-selected="true">
<span class="tab-dot" style="background: var(--accent-green);"></span>
<span data-i18n="tab_radar">Radar</span>
</button>
<button role="tab" class="tab-btn" data-tab="history" aria-selected="false">
<span class="tab-dot" style="background: var(--accent-blue);"></span>
<span data-i18n="tab_history">Lịch sử</span>
</button>
</div>
<!-- Tab: Radar (stats + targets + zones) -->
<div class="tab-panel" data-tab-panel="radar">
<!-- Stats card --> <!-- Stats card -->
<div class="card stats-card"> <div class="card stats-card">
<h3 data-i18n="statistics">Thống kê</h3> <h3 data-i18n="statistics">Thống kê</h3>
@@ -161,24 +178,41 @@
</div> </div>
</div> </div>
<!-- Zones list + editor --> <!-- Zones list (editor removed per user request — defaults are final) -->
<div class="card zones-card"> <div class="card zones-card">
<div class="card-header-row">
<h3 data-i18n="zones">Zones</h3> <h3 data-i18n="zones">Zones</h3>
<button class="btn-link" id="btnEditZones" data-i18n="edit_zones">Sửa zones</button>
</div>
<div class="zones-list" id="zonesList"></div> <div class="zones-list" id="zonesList"></div>
<!-- Zone editor (hidden by default) -->
<form class="zone-editor" id="zoneEditor" hidden>
<div class="editor-title" data-i18n="zone_editor_title">Chỉnh sửa tọa độ zone (mm)</div>
<div class="editor-grid" id="editorGrid"></div>
<div class="editor-actions">
<button type="button" class="btn btn-ghost" id="btnCancelZones" data-i18n="cancel">Hủy</button>
<button type="button" class="btn btn-ghost" id="btnResetZones" data-i18n="reset_default">Mặc định</button>
<button type="submit" class="btn btn-primary" data-i18n="save_and_apply">Lưu & Áp dụng</button>
</div> </div>
</form> </div>
<!-- Tab: History (SQLite-backed detections with date/hour filters) -->
<div class="tab-panel" data-tab-panel="history" hidden>
<div class="card history-card">
<div class="history-toolbar">
<div class="history-toolbar-title">
<h3 data-i18n="history_title">Lịch sử phát hiện</h3>
<div class="history-summary" id="historySummary" data-i18n="history_summary_empty">Chưa có dữ liệu</div>
</div>
<div class="history-filters">
<label class="field">
<span data-i18n="history_date">Ngày</span>
<input type="date" id="historyDate">
</label>
<label class="field field-range">
<span data-i18n="history_hour">Giờ</span>
<div class="range">
<input type="number" id="hourStart" min="0" max="23" step="1" placeholder="0">
<span class="sep">→</span>
<input type="number" id="hourEnd" min="0" max="23" step="1" placeholder="23">
</div>
</label>
<button class="btn btn-primary btn-sm" id="btnRefreshHistory" data-i18n="history_refresh">Tải lại</button>
</div>
</div>
<div class="history-hourly" id="historyHourly"><!-- 24-hour bar summary --></div>
<div class="history-list" id="historyList"><!-- Detection cards --></div>
</div>
</div> </div>
</section> </section>
+257 -2
View File
@@ -32,10 +32,15 @@ from typing import List, Set
# ------------------------------------------------------------ # ------------------------------------------------------------
try: try:
import websockets import websockets
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import aiohttp 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: 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"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}") print(f"Chi tiết: {e}")
@@ -81,6 +86,204 @@ class RadarState:
last_update: float = 0.0 last_update: float = 0.0
esp32_connected: bool = False 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
<input type=date> 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: def to_json(self) -> dict:
"""Serialize to something the browser can render.""" """Serialize to something the browser can render."""
now = time.time() now = time.time()
@@ -182,6 +385,17 @@ class RadarState:
self.frames_received += 1 self.frames_received += 1
self.last_update = now 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 # Global app state
@@ -372,13 +586,25 @@ async def broadcast_to_browsers():
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates" TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static" STATIC_DIR = BASE_DIR / "static"
DATA_DIR = BASE_DIR / "data"
STATIC_DIR.mkdir(exist_ok=True) STATIC_DIR.mkdir(exist_ok=True)
TEMPLATES_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 @asynccontextmanager
async def lifespan(app: FastAPI): 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 = [ tasks = [
asyncio.create_task(esp32_ws_forever(ESP32_HOST)), asyncio.create_task(esp32_ws_forever(ESP32_HOST)),
asyncio.create_task(heartbeat_broadcast()), asyncio.create_task(heartbeat_broadcast()),
@@ -387,6 +613,11 @@ async def lifespan(app: FastAPI):
for t in tasks: for t in tasks:
t.cancel() t.cancel()
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
if STORE is not None:
try:
await STORE.close()
finally:
STORE = None
app = FastAPI( app = FastAPI(
@@ -430,6 +661,30 @@ async def update_zones(payload: list):
return await post_zones_to_esp32(ESP32_HOST, payload) 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 ----------------------------- # --- WebSocket route for browsers -----------------------------
@app.websocket("/ws") @app.websocket("/ws")
async def browser_ws_endpoint(ws: WebSocket): async def browser_ws_endpoint(ws: WebSocket):