refactor code with orangepi-server
This commit is contained in:
1 parent
37b551c0ed
commit
649edec6ca
14 files changed
+680
No files matched your search
@@ -0,0 +1,221 @@
|
||||
# ESP32-S3 LD2453 Radar — Orange Pi Visualizer
|
||||
|
||||
Hướng dẫn cài đặt và sử dụng script Python trên Orange Pi để visualize dữ liệu radar realtime.
|
||||
|
||||
---
|
||||
|
||||
## 1. Chuẩn bị
|
||||
|
||||
### 1.1 Yêu cầu hệ thống
|
||||
- Orange Pi (mọi model đều được, cần Python 3.8+)
|
||||
- Kết nối mạng cùng LAN với ESP32 (WiFi hoặc LAN cổng)
|
||||
- Biết IP của ESP32 (xem Serial Monitor khi boot, hoặc vào router check)
|
||||
|
||||
### 1.2 Cài đặt thư viện hệ thống (nếu dùng GUI tkinter)
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3-tk python3-pip
|
||||
```
|
||||
|
||||
### 1.3 Copy file lên Orange Pi
|
||||
Copy 2 file này vào Orange Pi:
|
||||
- `orange_pi_visualizer.py`
|
||||
- `orange_pi_requirements.txt`
|
||||
|
||||
Ví dụ dùng SCP:
|
||||
```bash
|
||||
scp orange_pi_visualizer.py orange_pi_requirements.txt orangepi@<ORANGE_PI_IP>:/home/orangepi/
|
||||
```
|
||||
|
||||
Hoặc dùng USB, hoặc clone repo trực tiếp.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cài đặt Python dependencies
|
||||
|
||||
```bash
|
||||
cd /home/orangepi
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
pip install -r orange_pi_requirements.txt
|
||||
```
|
||||
|
||||
**Hoặc cài global (không khuyến nghị nhưng nhanh):**
|
||||
```bash
|
||||
pip3 install websockets aiohttp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sử dụng
|
||||
|
||||
### 3.1 Bật ESP32 ở chế độ Full Stage
|
||||
Trong `config.h` trên Arduino IDE:
|
||||
```cpp
|
||||
#define DIAGNOSTIC_MODE 0 // Full mode: WiFi + WebSocket + REST API
|
||||
```
|
||||
Flash code, mở Serial Monitor lấy IP ESP32. Ví dụ: `192.168.1.100`
|
||||
|
||||
### 3.2 Chạy Visualizer trên Orange Pi
|
||||
|
||||
#### Chế độ GUI (Tkinter) — Mặc định
|
||||
Cần môi trường desktop (có màn hình gắn vào Orange Pi):
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 orange_pi_visualizer.py 192.168.1.100
|
||||
```
|
||||
|
||||
#### Chế độ Headless (Console)
|
||||
Dùng khi Orange Pi không có màn hình, chạy SSH remote:
|
||||
```bash
|
||||
python3 orange_pi_visualizer.py 192.168.1.100 --headless
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mô tả giao diện
|
||||
|
||||
### 4.1 GUI Tkinter
|
||||
- **Canvas 900x700**: Biểu diễn không gian phòng theo tỷ lệ thực (mm)
|
||||
- **Trục X (ngang)**: -5m ← 0 (radar) → +5m
|
||||
- **Trục Y (dọc)**: 0 (radar ở dưới cùng) ↑ đến 7m (phía trước)
|
||||
- **Grid**: Mỗi ô = 1 mét
|
||||
- **Radar**: Hình tròn màu đỏ ở cạnh dưới giữa
|
||||
- **3 Zones**:
|
||||
- Zone 1 (Xanh lá): Bên trái radar (-4m → -1mm, 1mm → 4m)
|
||||
- Zone 2 (Cam): Bên phải radar (1mm → 4m, 1mm → 4m)
|
||||
- Zone 3 (Đỏ): Khu vực xa hơn (từ 4m → 6m, toàn bộ bề ngang)
|
||||
- Khi có người: Màu đậm, chữ CO
|
||||
- Khi trống: Màu nhạt, gạch nét, chữ TRONG
|
||||
- **Targets (3 điểm)**:
|
||||
- Màu: Vàng (Slot1), Tím (Slot2), Xanh (Slot3)
|
||||
- Mũi tên: Tốc độ (độ dài tỉ lệ với tốc độ cm/s)
|
||||
- Label: `T1 X,Y mm speed cm/s`
|
||||
|
||||
### 4.2 Console (headless)
|
||||
Cập nhật 2 lần/giây, hiển thị:
|
||||
```
|
||||
============================================================
|
||||
ESP32 LD2453 — Node: RADAR_A Seq: 12345
|
||||
============================================================
|
||||
Frames received: 1200
|
||||
Target count : 2/3
|
||||
Last update : 0.1s ago
|
||||
------------------------------------------------------------
|
||||
Slot 1: DETECTED
|
||||
X= 850mm Y= 2340mm Speed=+12cm/s Dist= 2488mm Angle=+19.9°
|
||||
Slot 2: FREE
|
||||
Slot 3: RECENT (2s)
|
||||
------------------------------------------------------------
|
||||
Zone 1: TRONG [-4000, 1] -> [ -1, 4000]
|
||||
Zone 2: X CO [ 1, 1] -> [ 4000, 4000]
|
||||
Zone 3: TRONG [-4001, 4001] -> [ 4001, 6000]
|
||||
============================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Tùy chỉnh
|
||||
|
||||
### 5.1 Thay đổi zone layout
|
||||
Cách 1: Từ giao diện web ESP32
|
||||
- Mở trình duyệt vào `http://<ESP32_IP>/zones` xem hiện tại
|
||||
- Cập nhật bằng POST request:
|
||||
```bash
|
||||
curl -X POST http://192.168.1.100/updateZones -H "Content-Type: application/json" -d '[
|
||||
{"id":1,"x1":-3000,"y1":500,"x2":-1,"y2":3000},
|
||||
{"id":2,"x1":1,"y1":500,"x2":3000,"y2":3000},
|
||||
{"id":3,"x1":-3000,"y1":3001,"x2":3000,"y2":5000}
|
||||
]'
|
||||
```
|
||||
Script visualizer sẽ tự động lấy zones mới sau khi reconnect WebSocket (hoặc restart script).
|
||||
|
||||
Cách 2: Edit hằng số đầu file `orange_pi_visualizer.py`:
|
||||
```python
|
||||
# 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)
|
||||
```
|
||||
|
||||
### 5.2 Thay đổi kích thước/range hiển thị
|
||||
Trong class `TkinterVisualizer`:
|
||||
```python
|
||||
CANVAS_W = 900
|
||||
CANVAS_H = 700
|
||||
ROOM_X_MIN = -5000
|
||||
ROOM_X_MAX = 5000
|
||||
ROOM_Y_MIN = 0
|
||||
ROOM_Y_MAX = 7000
|
||||
```
|
||||
|
||||
### 5.3 Chạy nền (background) trên Orange Pi
|
||||
Dùng `tmux` hoặc `systemd`:
|
||||
|
||||
#### tmux
|
||||
```bash
|
||||
tmux new -s radar
|
||||
python3 orange_pi_visualizer.py 192.168.1.100 --headless
|
||||
# Ctrl+B D de detach
|
||||
# tmux attach -t radar de quay lai
|
||||
```
|
||||
|
||||
#### systemd service (tự chạy khi boot)
|
||||
Tạo file `/etc/systemd/system/radar-vis.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LD2453 Radar Visualizer
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orangepi
|
||||
WorkingDirectory=/home/orangepi
|
||||
ExecStart=/home/orangepi/venv/bin/python3 /home/orangepi/orange_pi_visualizer.py 192.168.1.100 --headless
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
Sau đó:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable radar-vis
|
||||
sudo systemctl start radar-vis
|
||||
journalctl -u radar-vis -f # xem log realtime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### "No such file directory python3-tk"
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y python3-tk
|
||||
```
|
||||
|
||||
### Không kết nối được WebSocket
|
||||
1. Ping ESP32: `ping 192.168.1.100`
|
||||
2. Test REST API: `curl http://192.168.1.100/status` → phải trả về `{"status":"ok",...}`
|
||||
3. Tường lửa Orange Pi? `sudo ufw status`
|
||||
4. ESP32 ở chế độ `DIAGNOSTIC_MODE 1`? → phải `0` mới có WiFi/WebSocket.
|
||||
|
||||
### Tốc độ cập nhật chậm
|
||||
- Mặc định LD2453 gửi ~2 fps. Đây là giới hạn của radar.
|
||||
- Để giảm lag GUI: tăng giảm `_last_print` trong ConsoleVisualizer (dòng `now - self._last_print < 0.5`)
|
||||
|
||||
### Target số không chính xác/khớp
|
||||
- Parser trên ESP32 đang trong giai đoạn đầu. Nếu thấy dữ liệu kỳ lạ (tọa độ không hợp lý, target nhảy lung tung):
|
||||
1. Bật `DEBUG_RADAR_RAW 1` trên ESP32, lấy RAW dump
|
||||
2. Gửi cho việc điều chỉnh lại byte layout.
|
||||
|
||||
---
|
||||
|
||||
## 7. Đối chiếu với dự án gốc ESP32-LD2450
|
||||
File này dựa trên pattern của `https://github.com/thanhtantran/ESP32-LD2450`:
|
||||
- Cùng WebSocket path `/ws`
|
||||
- Cùng format JSON (`node_id`, `sequence`, `targets[]` với `x`, `y`, `speed`)
|
||||
- Cùng REST API `/zones` + `/updateZones` + `/status`
|
||||
- Thêm vào đây: `target_count` tổng hợp, fields `distance` & `angle` cho tiện tính toán.
|
||||
@@ -0,0 +1,2 @@
|
||||
websockets>=11.0
|
||||
aiohttp>=3.8.0
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user