Files
esp32s3-ld2453/orangepi-server/visualizer.py
T
2026-09-11 22:09:39 +07:00

511 lines
18 KiB
Python

#!/usr/bin/env python3
"""
ESP32-S3 LD2453 Radar — Web Visualizer for Orange Pi
======================================================
- Kết nối WebSocket tới ESP32 nhận dữ liệu radar realtime
- Mở Web Server (FastAPI) cho phép trình duyệt xem đồ họa đẹp
- Broadcast dữ liệu từ ESP32 tới tất cả các client trình duyệt đang kết nối
- Hỗ trợ REST API lấy zones, cập nhật zones (proxy tới ESP32)
Sử dụng:
python visualizer.py <ESP32_IP> # Mặc định port 8000
python visualizer.py 192.168.1.100 --port 8080 # Tùy chỉnh port
python visualizer.py 192.168.1.100 --host 0.0.0.0 # Mở cho truy cập ngoài
Mở trình duyệt vào: http://<OrangePi_IP>:8000
"""
import argparse
import asyncio
import json
import logging
import math
import sys
import time
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import List, Set
# ------------------------------------------------------------
# Third-party imports (install via requirements.txt)
# ------------------------------------------------------------
try:
import websockets
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import aiohttp
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}")
sys.exit(1)
# ============================================================
# Data structures — keep same semantics as original
# ============================================================
@dataclass
class Target:
slot: int
valid: bool = False
x_mm: int = 0
y_mm: int = 0
speed_cm_s: int = 0
distance_mm: float = 0.0
angle_deg: float = 0.0
last_seen: float = 0.0
@dataclass
class Zone:
id: int
x1: int = 0
y1: int = 0
x2: int = 0
y2: int = 0
occupied: bool = False
@dataclass
class RadarState:
node_id: str = "RADAR_A"
sequence: int = 0
timestamp_ms: int = 0
target_count: int = 0
targets: List[Target] = field(
default_factory=lambda: [Target(i + 1) for i in range(3)]
)
zones: List[Zone] = field(default_factory=lambda: [Zone(i) for i in range(1, 4)])
frames_received: int = 0
last_update: float = 0.0
esp32_connected: bool = False
def to_json(self) -> dict:
"""Serialize to something the browser can render."""
now = time.time()
return {
"node_id": self.node_id,
"sequence": self.sequence,
"timestamp_ms": self.timestamp_ms,
"target_count": self.target_count,
"targets": [
{
"slot": t.slot,
"valid": t.valid,
"x_mm": t.x_mm,
"y_mm": t.y_mm,
"speed_cm_s": t.speed_cm_s,
"distance_mm": round(t.distance_mm, 1),
"angle_deg": round(t.angle_deg, 2),
# seconds since this target was last valid; None if never seen
"last_seen": (
round(now - t.last_seen, 2)
if t.valid or (t.last_seen and t.last_seen > 0)
else None
),
}
for t in self.targets
],
"zones": [
{
"id": z.id,
"x1": z.x1,
"y1": z.y1,
"x2": z.x2,
"y2": z.y2,
"occupied": z.occupied,
}
for z in self.zones
],
"frames_received": self.frames_received,
"last_update": (
round(now - self.last_update, 2)
if self.last_update and self.last_update > 0
else None
),
"esp32_connected": self.esp32_connected,
"server_time": round(now, 2),
}
def update_from_frame(self, frame: dict):
self.node_id = frame.get("node_id", self.node_id)
self.sequence = frame.get("sequence", 0)
self.timestamp_ms = frame.get("timestamp", 0)
self.target_count = frame.get("target_count", 0)
now = time.time()
targets_data = frame.get("targets", [])
for t_data in targets_data:
slot = t_data.get("id", 0)
if 1 <= slot <= 3:
t = self.targets[slot - 1]
t.slot = slot
t.valid = t_data.get("valid", False)
t.x_mm = t_data.get("x", 0)
t.y_mm = t_data.get("y", 0)
t.speed_cm_s = t_data.get("speed", 0)
# If ESP32 parser already gives distance/angle, use them
t.distance_mm = float(t_data.get("distance", 0.0))
t.angle_deg = float(t_data.get("angle", 0.0))
# Derive distance / angle if missing, for convenience
if t.valid and (t.distance_mm == 0 or t.angle_deg == 0):
t.distance_mm = math.hypot(t.x_mm, t.y_mm)
if t.distance_mm > 0:
t.angle_deg = math.degrees(math.atan2(t.x_mm, t.y_mm))
if t.valid:
t.last_seen = now
self.frames_received += 1
self.last_update = now
# ============================================================
# Global app state
# ============================================================
STATE = RadarState()
# Default zones — matches the original ESP32 layout
STATE.zones[0] = Zone(1, x1=-4000, y1=1, x2=-1, y2=4000)
STATE.zones[1] = Zone(2, x1=1, y1=1, x2=4000, y2=4000)
STATE.zones[2] = Zone(3, x1=-4001, y1=4001, x2=4001, y2=6000)
CONNECTED_BROWSERS: Set[WebSocket] = set()
BROWSER_LOCK = asyncio.Lock()
ESP32_HOST: str = "" # populated from CLI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
log = logging.getLogger("radar-web")
# ============================================================
# Helper: update zone occupancy (redundant but useful for validation)
# ============================================================
def _update_zone_occupancy_from_targets(state: RadarState):
for z in state.zones:
z.occupied = False
for t in state.targets:
if not t.valid:
continue
if (min(z.x1, z.x2) <= t.x_mm <= max(z.x1, z.x2)
and min(z.y1, z.y2) <= t.y_mm <= max(z.y1, z.y2)):
z.occupied = True
break
# ============================================================
# Heartbeat: broadcast last known state to browsers every 1s
# even if ESP32 hasn't sent a new frame yet (parser stub scenario).
# ============================================================
async def heartbeat_broadcast():
while True:
try:
await broadcast_to_browsers()
except Exception:
pass
await asyncio.sleep(1.0)
# ============================================================
# ESP32 WebSocket client — runs as a background task
# ============================================================
async def esp32_ws_forever(esp32_host: str):
"""Loop forever: connect to ESP32 /ws, read frames, update STATE, broadcast."""
uri = f"ws://{esp32_host}/ws"
reconnect_delay = 2.0
while True:
try:
STATE.esp32_connected = False
log.info(f"Đang kết nối ESP32 tại {uri} ...")
# Push initial "not connected" state to any browser that joined early
await broadcast_to_browsers()
async with websockets.connect(
uri, ping_interval=30, ping_timeout=10, close_timeout=5,
open_timeout=8, max_size=1_000_000,
) as ws:
STATE.esp32_connected = True
log.info(f"Đã kết nối WebSocket tới ESP32 ({esp32_host})")
# Fetch zones from ESP32 after successful connect
await fetch_zones_from_esp32(esp32_host)
# Broadcast immediately so browsers see zones right after connect
await broadcast_to_browsers()
async for message in ws:
try:
frame = json.loads(message)
except json.JSONDecodeError:
log.warning(f"Lỗi parse JSON từ ESP32: {message[:80]}...")
continue
STATE.update_from_frame(frame)
_update_zone_occupancy_from_targets(STATE)
# Always push the latest state
await broadcast_to_browsers()
log.info("ESP32 WebSocket đóng, đang kết nối lại...")
STATE.esp32_connected = False
await broadcast_to_browsers()
except websockets.exceptions.ConnectionClosed as e:
STATE.esp32_connected = False
log.warning(f"Mất kết nối ESP32 ({e}), thử lại sau {reconnect_delay}s")
await broadcast_to_browsers()
await asyncio.sleep(reconnect_delay)
except OSError as e:
STATE.esp32_connected = False
log.warning(f"Không thể kết nối ESP32: {e}. Thử lại sau {reconnect_delay}s")
await broadcast_to_browsers()
await asyncio.sleep(reconnect_delay)
except asyncio.CancelledError:
log.info("Task ESP32 WS bị hủy (shutdown).")
break
except Exception as e:
STATE.esp32_connected = False
log.exception(f"Lỗi ESP32 WS loop: {type(e).__name__}: {e}")
await asyncio.sleep(reconnect_delay)
async def fetch_zones_from_esp32(esp32_host: str):
"""Fetch zone layout from ESP32 /zones REST endpoint."""
url = f"http://{esp32_host}/zones"
try:
timeout = aiohttp.ClientTimeout(total=3)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
for z_data in data:
idx = z_data.get("id", 0)
if 1 <= idx <= 3:
z = STATE.zones[idx - 1]
z.id = idx
z.x1 = int(z_data.get("x1", z.x1))
z.y1 = int(z_data.get("y1", z.y1))
z.x2 = int(z_data.get("x2", z.x2))
z.y2 = int(z_data.get("y2", z.y2))
z.occupied = bool(z_data.get("occupied", False))
log.info(f"Đã lấy {len(data)} zones từ ESP32")
else:
log.warning(f"GET /zones trả về HTTP {resp.status}")
except Exception as e:
log.warning(f"Không lấy được zones từ ESP32 (sẽ dùng mặc định): {e}")
async def post_zones_to_esp32(esp32_host: str, zones_payload):
"""Forward a zone update to ESP32 /updateZones."""
url = f"http://{esp32_host}/updateZones"
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=zones_payload) as resp:
body = await resp.text()
if resp.status in (200, 204):
# Apply locally immediately
for z_data in zones_payload:
idx = z_data.get("id", 0)
if 1 <= idx <= 3:
z = STATE.zones[idx - 1]
z.id = idx
z.x1 = int(z_data.get("x1", z.x1))
z.y1 = int(z_data.get("y1", z.y1))
z.x2 = int(z_data.get("x2", z.x2))
z.y2 = int(z_data.get("y2", z.y2))
await broadcast_to_browsers()
return {"status": "ok", "http_status": resp.status, "body": body}
else:
raise HTTPException(status_code=resp.status, detail=body)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Lỗi khi gọi ESP32: {e}")
# ============================================================
# Broadcast to all connected browsers
# ============================================================
async def broadcast_to_browsers():
payload = json.dumps(STATE.to_json())
if not CONNECTED_BROWSERS:
return
async with BROWSER_LOCK:
dead: List[WebSocket] = []
for ws in list(CONNECTED_BROWSERS):
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
CONNECTED_BROWSERS.discard(ws)
# ============================================================
# FastAPI app setup
# ============================================================
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
STATIC_DIR.mkdir(exist_ok=True)
TEMPLATES_DIR.mkdir(exist_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start background tasks when app starts, cancel them on shutdown."""
tasks = [
asyncio.create_task(esp32_ws_forever(ESP32_HOST)),
asyncio.create_task(heartbeat_broadcast()),
]
yield
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
app = FastAPI(
title="ESP32 LD2453 Radar Web Visualizer",
lifespan=lifespan,
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# --- HTTP routes ------------------------------------------------
@app.get("/")
async def get_index():
index_file = TEMPLATES_DIR / "index.html"
if not index_file.exists():
return {
"error": "index.html không tồn tại. Hãy đảm bảo thư mục templates/ có index.html."
}
return FileResponse(str(index_file))
@app.get("/api/state")
async def get_state():
"""REST endpoint lấy trạng thái hiện tại (polling dự phòng)."""
return STATE.to_json()
@app.get("/api/zones")
async def get_zones():
return [
{"id": z.id, "x1": z.x1, "y1": z.y1, "x2": z.x2, "y2": z.y2, "occupied": z.occupied}
for z in STATE.zones
]
@app.post("/api/zones")
async def update_zones(payload: list):
"""Nhận cập nhật zones từ trình duyệt và forward tới ESP32."""
if not ESP32_HOST:
raise HTTPException(status_code=500, detail="ESP32 host chưa được cấu hình")
return await post_zones_to_esp32(ESP32_HOST, payload)
# --- WebSocket route for browsers -----------------------------
@app.websocket("/ws")
async def browser_ws_endpoint(ws: WebSocket):
await ws.accept()
# Gửi trạng thái ngay lập tức để client có data lần đầu
try:
await ws.send_text(json.dumps(STATE.to_json()))
except Exception:
return
async with BROWSER_LOCK:
CONNECTED_BROWSERS.add(ws)
log.info(f"Browser kết nối: {ws.client} (tổng: {len(CONNECTED_BROWSERS)})")
try:
# Browser chủ yếu nhận, nhưng chúng ta vẫn đọc để phát hiện ngắt kết nối
while True:
data = await ws.receive_text()
# Có thể mở rộng: nhận lệnh từ trình duyệt (ví dụ: yêu cầu cập nhật zones)
try:
msg = json.loads(data)
if msg.get("type") == "updateZones":
zones = msg.get("zones", [])
await post_zones_to_esp32(ESP32_HOST, zones)
except Exception:
pass
except WebSocketDisconnect:
pass
finally:
async with BROWSER_LOCK:
CONNECTED_BROWSERS.discard(ws)
log.info(f"Browser ngắt kết nối (còn lại: {len(CONNECTED_BROWSERS)})")
# ============================================================
# Entry point
# ============================================================
def main():
global ESP32_HOST
parser = argparse.ArgumentParser(
description="ESP32 LD2453 Radar — Web Visualizer (Xem trên trình duyệt)"
)
parser.add_argument(
"esp32",
help="Địa chỉ IP ESP32 (ví dụ: 192.168.1.100)",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Host lắng nghe của Web Server (mặc định: 0.0.0.0 — cho phép truy cập từ ngoài)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port của Web Server (mặc định: 8000)",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Hiển thị log chi tiết (DEBUG level)",
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
ESP32_HOST = args.esp32.strip()
# Try importing uvicorn — keep it here so help text still shows without install
try:
import uvicorn
except ImportError:
print("Lỗi: Cần cài uvicorn: pip install uvicorn")
sys.exit(1)
print()
print("=" * 62)
print(" ESP32-S3 LD2453 Radar — Web Visualizer")
print("=" * 62)
print(f" ESP32 : ws://{ESP32_HOST}/ws")
print(f" Web UI : http://{args.host}:{args.port}/")
print(f" WS UI : ws://<OrangePi_IP>:{args.port}/ws")
print(f" Browsers: 0 client đang kết nối")
print("=" * 62)
print(" Nhấn Ctrl+C để dừng")
print("=" * 62)
print()
try:
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
except KeyboardInterrupt:
print("\nThoát.")
if __name__ == "__main__":
main()