diff --git a/LD2453Parser.cpp b/esp32s3-ld2453/LD2453Parser.cpp similarity index 100% rename from LD2453Parser.cpp rename to esp32s3-ld2453/LD2453Parser.cpp diff --git a/LD2453Parser.h b/esp32s3-ld2453/LD2453Parser.h similarity index 100% rename from LD2453Parser.h rename to esp32s3-ld2453/LD2453Parser.h diff --git a/OLEDDisplay.cpp b/esp32s3-ld2453/OLEDDisplay.cpp similarity index 100% rename from OLEDDisplay.cpp rename to esp32s3-ld2453/OLEDDisplay.cpp diff --git a/OLEDDisplay.h b/esp32s3-ld2453/OLEDDisplay.h similarity index 100% rename from OLEDDisplay.h rename to esp32s3-ld2453/OLEDDisplay.h diff --git a/WebServerManager.cpp b/esp32s3-ld2453/WebServerManager.cpp similarity index 100% rename from WebServerManager.cpp rename to esp32s3-ld2453/WebServerManager.cpp diff --git a/WebServerManager.h b/esp32s3-ld2453/WebServerManager.h similarity index 100% rename from WebServerManager.h rename to esp32s3-ld2453/WebServerManager.h diff --git a/ZoneManager.cpp b/esp32s3-ld2453/ZoneManager.cpp similarity index 100% rename from ZoneManager.cpp rename to esp32s3-ld2453/ZoneManager.cpp diff --git a/ZoneManager.h b/esp32s3-ld2453/ZoneManager.h similarity index 100% rename from ZoneManager.h rename to esp32s3-ld2453/ZoneManager.h diff --git a/config.example.h b/esp32s3-ld2453/config.example.h similarity index 100% rename from config.example.h rename to esp32s3-ld2453/config.example.h diff --git a/config.h b/esp32s3-ld2453/config.h similarity index 100% rename from config.h rename to esp32s3-ld2453/config.h diff --git a/esp32-s3-ld2453.ino b/esp32s3-ld2453/esp32s3-ld2453.ino similarity index 100% rename from esp32-s3-ld2453.ino rename to esp32s3-ld2453/esp32s3-ld2453.ino diff --git a/orangepi-server/ORANGE_PI_GUIDE.md b/orangepi-server/ORANGE_PI_GUIDE.md new file mode 100644 index 0000000..83f0a56 --- /dev/null +++ b/orangepi-server/ORANGE_PI_GUIDE.md @@ -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@:/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:///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. diff --git a/orangepi-server/orange_pi_requirements.txt b/orangepi-server/orange_pi_requirements.txt new file mode 100644 index 0000000..f7998f5 --- /dev/null +++ b/orangepi-server/orange_pi_requirements.txt @@ -0,0 +1,2 @@ +websockets>=11.0 +aiohttp>=3.8.0 diff --git a/orangepi-server/orange_pi_visualizer.py b/orangepi-server/orange_pi_visualizer.py new file mode 100644 index 0000000..8586dcd --- /dev/null +++ b/orangepi-server/orange_pi_visualizer.py @@ -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 # GUI mode (tkinter) + python orange_pi_visualizer.py --headless # Chỉ in console + python orange_pi_visualizer.py --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()