refactor code with orangepi-server

This commit is contained in:
admin committed 2026-09-11 15:54:10 +07:00
1 parent 37b551c0ed
commit 649edec6ca
14 files changed
+680

No files matched your search

+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""
ESP32-S3 LD2453 Radar Visualizer for Orange Pi
===============================================
- Kết nối WebSocket tới ESP32 để nhận dữ liệu radar realtime
- Vẽ zone (3 vùng hình chữ nhật) và vị trí các target (up to 3)
- Hiển thị tọa độ, tốc độ, khoảng cách của từng target
- Hỗ trợ chạy headless (chỉ log) hoặc GUI (tkinter/pygame)
Sử dụng:
python orange_pi_visualizer.py <ESP32_IP> # GUI mode (tkinter)
python orange_pi_visualizer.py <ESP32_IP> --headless # Chỉ in console
python orange_pi_visualizer.py <ESP32_IP> --mode pygame # Dùng pygame (đồ họa đẹp hơn)
"""
import argparse
import asyncio
import json
import sys
import time
from collections import deque
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
# ============================================================
# WebSocket client
# ============================================================
try:
import websockets
except ImportError:
print("Loi: Can install websockets: pip install websockets")
sys.exit(1)
@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
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)
t.distance_mm = float(t_data.get("distance", 0))
t.angle_deg = float(t_data.get("angle", 0))
if t.valid:
t.last_seen = now
self.frames_received += 1
self.last_update = now
# ============================================================
# GUI 1: Tkinter (mặc định, có sẵn trên hầu hết hệ thống)
# ============================================================
class TkinterVisualizer:
# Canvas size (pixel)
CANVAS_W = 900
CANVAS_H = 700
# Radar coordinate range (mm) — map vào canvas
ROOM_X_MIN = -5000 # 5m bên trái radar
ROOM_X_MAX = 5000 # 5m bên phải
ROOM_Y_MIN = 0 # radar
ROOM_Y_MAX = 7000 # 7m hướng về phía trước
# Colors
BG_COLOR = "#1e1e2e"
GRID_COLOR = "#313244"
AXIS_COLOR = "#89b4fa"
ZONE_COLORS = ["#a6e3a1", "#fab387", "#f38ba8", "#89dceb"]
ZONE_OCCUPIED_COLORS = ["#40a02b", "#fe640b", "#d20f39", "#04a5e5"]
TARGET_COLORS = ["#f9e2af", "#cba6f7", "#74c7ec"]
def __init__(self, state: RadarState):
self.state = state
try:
import tkinter as tk
except ImportError:
print("Loi: tkinter khong co san. Hay cai: sudo apt install python3-tk")
sys.exit(1)
self.tk = tk
self.root = tk.Tk()
self.root.title(f"ESP32 LD2453 Radar — {state.node_id}")
self.root.configure(bg=self.BG_COLOR)
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self._running = True
self._build_ui()
def _build_ui(self):
# Header label
self.header_var = self.tk.StringVar(value="Dang cho du lieu...")
lbl = self.tk.Label(
self.root, textvariable=self.header_var,
font=("Consolas", 12, "bold"),
fg="#cdd6f4", bg=self.BG_COLOR, pady=8
)
lbl.pack(fill="x")
# Canvas
self.canvas = self.tk.Canvas(
self.root, width=self.CANVAS_W, height=self.CANVAS_H,
bg=self.BG_COLOR, highlightthickness=0
)
self.canvas.pack(padx=10, pady=5)
# Status bar
self.status_var = self.tk.StringVar(value="Frames: 0")
self.tk.Label(
self.root, textvariable=self.status_var,
font=("Consolas", 10),
fg="#a6adc8", bg=self.BG_COLOR, pady=6
).pack(fill="x")
def _mm_to_pixel(self, x_mm: int, y_mm: int) -> Tuple[float, float]:
"""Map coordinate radar -> canvas pixel.
Radar goc toa do: radar o giua canh duoi. Tang Y la huong vao phia truoc.
"""
x_ratio = (x_mm - self.ROOM_X_MIN) / (self.ROOM_X_MAX - self.ROOM_X_MIN)
# Dao Y: canvas (0,0) o goc tren, room Y=0 o goc duoi (radar)
y_ratio = 1.0 - (y_mm - self.ROOM_Y_MIN) / (self.ROOM_Y_MAX - self.ROOM_Y_MIN)
# De lai margin
margin_x = 60
margin_y = 60
px = margin_x + x_ratio * (self.CANVAS_W - 2 * margin_x)
py = margin_y + y_ratio * (self.CANVAS_H - 2 * margin_y)
return px, py
def _draw_background(self):
c = self.canvas
# Grid lines every 1m
for x_mm in range(self.ROOM_X_MIN, self.ROOM_X_MAX + 1, 1000):
x1, y1 = self._mm_to_pixel(x_mm, self.ROOM_Y_MIN)
x2, y2 = self._mm_to_pixel(x_mm, self.ROOM_Y_MAX)
c.create_line(x1, y1, x2, y2, fill=self.GRID_COLOR)
if x_mm != 0:
c.create_text(x1, self.CANVAS_H - 30, text=f"{x_mm//1000}m", fill="#6c7086", font=("Consolas", 8))
for y_mm in range(self.ROOM_Y_MIN, self.ROOM_Y_MAX + 1, 1000):
x1, y1 = self._mm_to_pixel(self.ROOM_X_MIN, y_mm)
x2, y2 = self._mm_to_pixel(self.ROOM_X_MAX, y_mm)
c.create_line(x1, y1, x2, y2, fill=self.GRID_COLOR)
c.create_text(25, y1, text=f"{y_mm//1000}m", fill="#6c7086", font=("Consolas", 8))
# Radar position (center bottom edge)
rx, ry = self._mm_to_pixel(0, 0)
c.create_text(rx, ry + 25, text="RADAR", fill="#f38ba8", font=("Consolas", 10, "bold"))
c.create_oval(rx - 8, ry - 8, rx + 8, ry + 8, fill="#f38ba8", outline="#f38ba8")
# Y axis line
ax, ay = self._mm_to_pixel(0, self.ROOM_Y_MAX)
c.create_line(rx, ry, ax, ay, fill=self.AXIS_COLOR, dash=(4, 2))
def _draw_zones(self, zones: List[Zone]):
c = self.canvas
for i, z in enumerate(zones):
x1, y1 = self._mm_to_pixel(z.x1, z.y1)
x2, y2 = self._mm_to_pixel(z.x2, z.y2)
# Tkinter can x1<=x2, y1<=y2
px1, px2 = sorted([x1, x2])
py1, py2 = sorted([y1, y2])
color = self.ZONE_OCCUPIED_COLORS[i] if z.occupied else self.ZONE_COLORS[i]
stipple = "gray25" if not z.occupied else ""
c.create_rectangle(
px1, py1, px2, py2,
outline=color, width=2, fill=color, stipple=stipple
)
# Zone label
lx = (px1 + px2) / 2
ly = (py1 + py2) / 2
status = "CO" if z.occupied else "TRONG"
c.create_text(
lx, ly,
text=f"Zone{z.id}\n{status}",
fill="#1e1e2e" if z.occupied else "#45475a",
font=("Consolas", 10, "bold")
)
def _draw_targets(self, targets: List[Target]):
c = self.canvas
now = time.time()
for i, t in enumerate(targets):
if not t.valid:
# Show faded marker if recently seen
if now - t.last_seen < 3.0:
px, py = self._mm_to_pixel(t.x_mm, t.y_mm)
c.create_oval(px - 5, py - 5, px + 5, py + 5, outline="#585b70", dash=(2, 2))
continue
px, py = self._mm_to_pixel(t.x_mm, t.y_mm)
color = self.TARGET_COLORS[i]
# Target dot
r = 8
c.create_oval(px - r, py - r, px + r, py + r, fill=color, outline="white", width=2)
# Velocity vector (speed)
if t.speed_cm_s != 0:
scale = 2.0 # pixel per cm/s
dx = (t.x_mm / max(1, t.distance_mm)) * t.speed_cm_s * scale if t.distance_mm else 0
dy = (t.y_mm / max(1, t.distance_mm)) * t.speed_cm_s * scale if t.distance_mm else 0
c.create_line(px, py, px + dx, py - dy, fill=color, width=2, arrow=self.tk.LAST)
# Label
label = f"T{t.slot}\n{abs(t.x_mm)}, {t.y_mm}mm\n{abs(t.speed_cm_s)}cm/s"
c.create_text(
px + 15, py - 25, anchor="w",
text=label, fill="white",
font=("Consolas", 9, "bold"),
tags=f"tlabel{t.slot}"
)
def draw(self):
self.canvas.delete("all")
self._draw_background()
self._draw_zones(self.state.zones)
self._draw_targets(self.state.targets)
s = self.state
self.header_var.set(
f"Node: {s.node_id} | Sequence: {s.sequence} | Targets: {s.target_count}/3"
)
self.status_var.set(
f"Frames received: {s.frames_received} | "
f"Last update: {time.time() - s.last_update:.1f}s ago"
)
def _on_close(self):
self._running = False
self.root.destroy()
def should_run(self) -> bool:
return self._running
def update(self):
"""Call periodically from main loop."""
if self._running:
self.draw()
self.root.update_idletasks()
self.root.update()
# ============================================================
# Console Headless Mode
# ============================================================
class ConsoleVisualizer:
def __init__(self, state: RadarState):
self.state = state
self._last_print = 0.0
def update(self):
now = time.time()
if now - self._last_print < 0.5:
return
self._last_print = now
s = self.state
print("\033[2J\033[H", end="") # Clear screen
print("=" * 60)
print(f" ESP32 LD2453 — Node: {s.node_id} Seq: {s.sequence}")
print("=" * 60)
print(f" Frames received: {s.frames_received}")
print(f" Target count : {s.target_count}/3")
if s.last_update:
print(f" Last update : {now - s.last_update:.1f}s ago")
print("-" * 60)
for t in s.targets:
if t.valid:
status = "DETECTED"
elif now - t.last_seen < 3.0:
status = f"RECENT ({now-t.last_seen:.0f}s)"
else:
status = "FREE"
print(f" Slot {t.slot}: {status}")
if t.valid:
print(f" X={t.x_mm:5d}mm Y={t.y_mm:5d}mm "
f"Speed={t.speed_cm_s:+3d}cm/s "
f"Dist={int(t.distance_mm):5d}mm "
f"Angle={t.angle_deg:+.1f}°")
print("-" * 60)
for z in s.zones:
occ = "X CO" if z.occupied else " TRONG"
print(f" Zone {z.id}: {occ} [{z.x1:+5d},{z.y1:+5d}] -> [{z.x2:+5d},{z.y2:+5d}]")
print("=" * 60)
def should_run(self) -> bool:
return True
def draw(self):
pass
# ============================================================
# Fetch zones from ESP32 REST API
# ============================================================
async def fetch_zones(state: RadarState, esp32_host: str):
try:
import aiohttp
except ImportError:
print("Canh bao: aiohttp chua cai. Zone se dung default. (pip install aiohttp)")
return
url = f"http://{esp32_host}/zones"
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) 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))
print(f"Da lay {len(data)} zone tu ESP32")
else:
print(f"GET zones tra ve status {resp.status}")
except Exception as e:
print(f"Khong the lay zones: {e}")
# ============================================================
# Update zone occupancy from target positions
# (redundant — ESP32 already reports zones, but kept 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 z.x1 <= t.x_mm <= z.x2 and z.y1 <= t.y_mm <= z.y2:
z.occupied = True
break
# ============================================================
# Main async loop
# ============================================================
async def ws_client_loop(esp32_host: str, visualizer, state: RadarState):
uri = f"ws://{esp32_host}/ws"
reconnect_delay = 2.0
while visualizer.should_run():
print(f"Dang ket noi den {uri}...")
try:
async with websockets.connect(uri, ping_interval=30, ping_timeout=10) as ws:
print(f"Da ket noi WebSocket den ESP32 ({esp32_host})")
# Fetch zones one time after connect
await fetch_zones(state, esp32_host)
async for message in ws:
if not visualizer.should_run():
break
try:
frame = json.loads(message)
state.update_from_frame(frame)
update_zone_occupancy_from_targets(state)
visualizer.update()
except json.JSONDecodeError:
print(f"Loi parse JSON: {message[:80]}...")
continue
# Graceful disconnect
print("WebSocket dong. Dang ket noi lai...")
except websockets.exceptions.ConnectionClosed as e:
print(f"Mat ket noi WebSocket ({e}), dang thu lai sau {reconnect_delay}s...")
await asyncio.sleep(reconnect_delay)
except OSError as e:
print(f"Khong the ket noi: {e}. Thu lai sau {reconnect_delay}s...")
await asyncio.sleep(reconnect_delay)
except KeyboardInterrupt:
print("Dung lai...")
break
except Exception as e:
print(f"Loi WebSocket: {type(e).__name__}: {e}")
await asyncio.sleep(reconnect_delay)
def main():
parser = argparse.ArgumentParser(description="ESP32 LD2453 Radar Visualizer")
parser.add_argument("host", help="ESP32 IP address (e.g. 192.168.1.100)")
parser.add_argument("--headless", action="store_true", help="Chi in ra console, khong can GUI")
parser.add_argument("--mode", choices=["tkinter", "pygame"], default="tkinter",
help="Loaị GUI (mac dinh: tkinter)")
args = parser.parse_args()
state = RadarState()
# Default zones (giong ESP32)
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)
if args.headless:
vis = ConsoleVisualizer(state)
elif args.mode == "pygame":
print("Pygame mode chua implement, chuyen sang tkinter...")
vis = TkinterVisualizer(state)
else:
vis = TkinterVisualizer(state)
print(f"ESP32-S3 LD2453 Radar Visualizer")
print(f"ESP32 host: {args.host}")
print(f"Mode: {'headless console' if args.headless else 'tkinter GUI'}")
try:
asyncio.run(ws_client_loop(args.host, vis, state))
except KeyboardInterrupt:
print("\nThoat.")
if __name__ == "__main__":
main()