change to web visualizer

This commit is contained in:
admin committed 2026-09-11 20:14:37 +07:00
1 parent 87be72b52b
commit 417ced90fa
9 files changed
+2580 -680

No files matched your search

-221
View File
@@ -1,221 +0,0 @@
# 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.
+565
View File
@@ -0,0 +1,565 @@
# ESP32-S3 LD2453 Radar — Web Visualizer cho Orange Pi
> **English version** below / Xem bản tiếng Anh ở cuối file.
Hướng dẫn cài đặt và sử dụng **Web Visualizer** trên Orange Pi (hoặc máy tính bất kỳ có Python 3.8+) để hiển thị dữ liệu radar realtime dưới dạng giao diện web đẹp trên trình duyệt.
> ✅ **Không cần giao diện desktop (Tkinter/Pygame) nữa.** Visualizer chạy nền dạng Web Server (FastAPI), bạn chỉ cần mở trình duyệt (trên điện thoại, laptop, PC…) là xem được đồ họa.
---
## 1. Chuẩn bị
### 1.1 Yêu cầu hệ thống
- Orange Pi / Raspberry Pi / PC bất kỳ có **Python 3.8+**
- Cùng mạng LAN với ESP32 (WiFi hoặc cổng LAN)
- Biết IP của ESP32 (xem Serial Monitor khi boot, hoặc vào router xem)
### 1.2 Cấu trúc thư mục
```
orangepi-server/
├── visualizer.py # Web Server (FastAPI): kết nối ESP32 + broadcast tới trình duyệt
├── requirements.txt # Python dependencies
├── templates/
│ └── index.html # Giao diện Web (HTML)
└── static/
├── style.css # Giao diện (CSS): sáng/tối, responsive, song ngữ
└── app.js # Canvas render + WebSocket client trong trình duyệt
```
### 1.3 Copy file lên Orange Pi
Copy cả thư mục `orangepi-server/` lên Orange Pi (clone repo, SCP, USB…).
Ví dụ SCP (chạy từ máy bạn):
```bash
scp -r orangepi-server/ orangepi@<ORANGE_PI_IP>:/home/orangepi/
```
---
## 2. Cài đặt Python dependencies
```bash
cd ~/orangepi-server
# (Tùy chọn nhưng khuyến nghị) Dùng virtual env
python3 -m venv venv
source venv/bin/activate
# Cài dependencies
pip install -r requirements.txt
```
**Cài global (nhanh hơn, không khuyến nghị):**
```bash
pip3 install -r requirements.txt
```
> Không cần `python3-tk` nữa — toàn bộ chạy trên web.
---
## 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
```
Nạp code, mở Serial Monitor lấy IP ESP32. Ví dụ: `192.168.1.100`
### 3.2 Khởi động Web Visualizer trên Orange Pi
```bash
# Nếu dùng venv:
source venv/bin/activate
# Chạy (thay bằng IP ESP32 của bạn):
python3 visualizer.py 192.168.1.100
```
Các tùy chọn lệnh:
```bash
# Tùy chỉnh port (mặc định 8000):
python3 visualizer.py 192.168.1.100 --port 8080
# Giới hạn truy cập chỉ local (không cho từ ngoài):
python3 visualizer.py 192.168.1.100 --host 127.0.0.1
# Log chi tiết:
python3 visualizer.py 192.168.1.100 --verbose
```
Màn hình console sẽ hiển thị:
```
==============================================================
ESP32-S3 LD2453 Radar — Web Visualizer
==============================================================
ESP32 : ws://192.168.1.100/ws
Web UI : http://0.0.0.0:8000/
WS UI : ws://<OrangePi_IP>:8000/ws
==============================================================
```
### 3.3 Mở trình duyệt xem kết quả
Trên **bất kỳ thiết bị nào cùng LAN** (điện thoại, laptop, PC…):
```
http://<IP_OrangePi>:8000/
```
Ví dụ: Orange Pi có IP `192.168.1.50` → mở `http://192.168.1.50:8000/`
---
## 4. Tính năng giao diện Web
### 4.1 Tổng quan
| Tính năng | Mô tả |
|---|---|
| 🎨 **Giao diện đẹp** | Chủ đề sáng/tối (Light/Dark), màu sắc theo chuẩn Catppuccin |
| 🌐 **Song ngữ** | Bật/tắt Tiếng Việt / English ngay trên header |
| 📱 **Responsive** | Xem tốt trên cả mobile, tablet, desktop |
| 🧭 **Radar Canvas** | Hiển thị radar, grid 1m, vòng tròn khoảng cách, hiệu ứng quét (sweep) |
| 🎯 **Targets** | Lên đến 3 target với màu khác nhau, đường dẫn (trail), mũi tên tốc độ, nhãn tọa độ |
| 🟩 **Zones** | 3 vùng hình chữ nhật với màu & trạng thái (Có người / Trống) |
| 📊 **Thống kê** | Frames nhận, target hợp lệ, vùng có người, cập nhật lần cuối |
| 📝 **Chi tiết** | Thông tin chi tiết từng target & zone ở panel bên phải |
| ✏️ **Sửa zones** | Chỉnh tọa độ zones ngay trên web, tự động gửi cập nhật tới ESP32 |
| 🔗 **Multi client** | Nhiều trình duyệt xem cùng lúc, dữ liệu đồng bộ realtime |
### 4.2 Header
- 🟢 / 🟡 / 🔴 Trạng thái kết nối **ESP32** và **WebSocket**
- ☀️ / 🌙 Nút chuyển Sáng / Tối
- `VI / EN` Nút chuyển ngôn ngữ
### 4.3 Radar View (Canvas)
- **Grid**: Mỗi ô = 1 mét, có đánh số (−5m ← 0 → +5m ngang, 0 → 7m thẳng)
- **Vòng tròn khoảng cách**: Các vòng tròn nét đứt cách nhau 1m tính từ radar
- **Trục Y chính**: Nét đứt màu xanh, chỉ hướng thẳng từ radar
- **Radar**: Vị trí radar ở giữa cạnh dưới (điểm đỏ, ghi chữ RADAR)
- **Hiệu ứng quét**: Cảm giác radar thực tế, có thể tắt trong công cụ
- **Zones (3 hình chữ nhật)**:
- Zone 1 (Xanh lá), Zone 2 (Cam), Zone 3 (Đỏ)
- Có người → Màu đậm, nét liền, badge `CÓ NGƯỜI`
- Trống → Màu nhạt, nét đứt, badge `TRỐNG`
- **Targets (3 điểm)**:
- Màu: Vàng (Slot 1), Tím (Slot 2), Xanh (Slot 3)
- Điểm sáng có viền trắng, bao quanh là hiệu ứng glow
- Mũi tên: Tốc độ (độ dài tỉ lệ với cm/s, hướng theo chuyển động)
- Nhãn: `T# | X,Y mm | tốc độ cm/s`
- Đường dẫn (trail): Mờ dần theo thời gian (tối đa 3.5s)
- Target vừa mất dấu: Hiển thị viền đứt mờ trong 3.5s nữa
### 4.4 Công cụ trên Canvas (bên phải tiêu đề)
- ✅ **Hiệu ứng quét** — Bật/tắt hiệu ứng quét radar
- ✅ **Đường dẫn target** — Bật/tắt trail fade
- ✅ **Hiển thị label** — Bật/tắt nhãn trên zones & targets
### 4.5 Panel bên phải
**Thống kê:**
- Frames nhận (tổng cộng)
- Cập nhật cách đây bao nhiêu giây
- Target hợp lệ / 3
- Vùng có người / 3
**Chi tiết Target:**
- Mỗi target có một thẻ màu, hiển thị:
- Trạng thái: `Đã phát hiện` / `Vừa mất dấu` / `Trống`
- `X, Y (mm)`
- `Tốc độ (cm/s)`
- `Khoảng cách (m)`
- `Góc (°)` — 0° = thẳng, âm = trái, dương = phải
**Zones:**
- Danh sách 3 zones với tọa độ
- Nút **Sửa zones** mở form chỉnh sửa từng toạ độ X1/Y1/X2/Y2 (mm)
- **Mặc định**: Khôi phục layout gốc
- **Hủy**: Bỏ sửa
- **Lưu & Áp dụng**: Gửi cập nhật POST `/api/zones` → forward tới ESP32 `/updateZones`, hiển thị toast thành công
---
## 5. Kiến trúc & luồng dữ liệu
```
┌───────────┐ WebSocket /ws ┌──────────────────┐ WebSocket /ws ┌──────────────┐
│ ESP32-S3 │ ◄──────────────────► │ Orange Pi (WS │ ◄──────────────────► │ Trình duyệt │
│ LD2453 │ JSON frame radar │ Server FastAPI) │ JSON state đồng bộ │ (Canvas 2D) │
└───────────┘ └──────────────────┘ └──────────────┘
▲ ▲ ▲
│ REST GET /zones │ GET / → trả về index.html │
│ REST POST /updateZones │ GET /static/* → CSS, JS │
└───────────────────────────────────┘ REST API /api/* │
Visualizer tự động gọi ESP32 khi bạn chỉnh zones trên web │
```
**Các endpoint chính:**
| Method | Endpoint | Mục đích |
|---|---|---|
| GET | `/` | Trả về trang web chính |
| GET | `/api/state` | Lấy trạng thái radar hiện tại (JSON) |
| GET | `/api/zones` | Lấy danh sách zones |
| POST | `/api/zones` | Cập nhật zones (forward tới ESP32 `/updateZones`) |
| WS | `/ws` | Endpoint WebSocket cho trình duyệt (broadcast realtime) |
---
## 6. Tùy chỉnh & chạy nền
### 6.1 Chạy nền bằng tmux
```bash
tmux new -s radar
cd ~/orangepi-server && source venv/bin/activate
python3 visualizer.py 192.168.1.100
# Ctrl+B D → detach (thoát ra mà vẫn chạy)
# tmux attach -t radar → quay lại session
```
### 6.2 systemd service (tự chạy khi boot Orange Pi)
Tạo file `/etc/systemd/system/radar-web.service`:
```ini
[Unit]
Description=LD2453 Radar Web Visualizer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=orangepi
WorkingDirectory=/home/orangepi/orangepi-server
ExecStart=/home/orangepi/orangepi-server/venv/bin/python3 /home/orangepi/orangepi-server/visualizer.py 192.168.1.100 --port 8000
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
Sau đó:
```bash
sudo systemctl daemon-reload
sudo systemctl enable radar-web
sudo systemctl start radar-web
journalctl -u radar-web -f # Xem log realtime
```
### 6.3 Tùy chỉnh giao diện
- **Màu sắc & chủ đề**: chỉnh các biến CSS ở đầu file `static/style.css` (định nghĩa `--z1`, `--t1`, `--bg`…)
- **Range hiển thị**: chỉnh hằng `MAP.X_MIN/X_MAX/Y_MIN/Y_MAX` trong `static/app.js`
- **Mặc định zones**: sửa `DEFAULT_ZONES` trong `static/app.js` và `STATE.zones[0..2]` trong `visualizer.py`
---
## 7. Troubleshooting
### Không mở được trang web
1. **Ping Orange Pi**: `ping <OrangePi_IP>`
2. **Kiểm tra port đang mở**: `ss -tulpn | grep 8000`
3. **Tường lửa?** `sudo ufw allow 8000/tcp` hoặc tạm thời `sudo ufw disable`
4. **Visualizer đang chạy?** `ps aux | grep visualizer`
### Không thấy dữ liệu radar (không có target)
1. **Kiểm tra trạng thái ESP32** (chip màu đỏ/xanh trên cùng bên phải trang web)
2. **Ping ESP32**: `ping 192.168.1.100`
3. **Test REST ESP32**: `curl http://192.168.1.100/status` → phải có JSON `{"status":"ok",...}`
4. **ESP32 đang DIAGNOSTIC_MODE 1?** → phải là `0` mới có WiFi/WebSocket
5. **Parser ESP32 vẫn là stub?** → Target luôn `INVALID` nếu parser chưa triển khai (thông báo ở README gốc)
### Tốc độ cập nhật chậm
- Mặc định radar LD2453 gửi ~2 frame/giây. Đó là giới hạn phần cứng.
- Đảm bảo Orange Pi và ESP32 có tín hiệu WiFi tốt.
### WebSocket không kết nối (trình duyệt báo lỗi)
- Mở DevTools (F12) → Console xem lỗi chi tiết
- Kiểm tra trình duyệt truy cập đúng `http://<IP>:8000` (không phải `https://`)
- Nếu dùng reverse proxy (Caddy/Nginx): cấu hình WebSocket upgrade (header `Upgrade: websocket`)
---
## 8. So sánh với bản cũ (Tkinter/Pygame)
| Đặc điểm | Tkinter cũ | **Web Visualizer mới** |
|---|---|---|
| Cần màn hình gắn Orange Pi? | ✅ Có | ❌ **Không** (xem trên trình duyệt bất kỳ đâu) |
| Xem trên điện thoại? | ❌ Không | ✅ **Có** |
| Nhiều người xem cùng lúc? | ❌ 1 người | ✅ **Không giới hạn** (multi-client) |
| Đồ họa đẹp? | ⭐⭐ cơ bản | ⭐⭐⭐⭐⭐ **Canvas2D + hiệu ứng + sáng/tối** |
| Chỉnh sửa zones? | Edit code Python | ✅ **Form trên web** |
| Song ngữ VI/EN? | ❌ Không | ✅ **Có** |
| Responsive mobile? | ❌ Không | ✅ **Có** |
---
---
# 🇬🇧 English Version
# ESP32-S3 LD2453 Radar — Orange Pi Web Visualizer
Installation and usage guide for the **Web Visualizer** on Orange Pi (or any machine with Python 3.8+) to display realtime radar data via a beautiful web interface — **no Tkinter/Pygame desktop required anymore**.
---
## 1. Requirements
- Orange Pi / Raspberry Pi / Any PC with **Python 3.8+**
- Same LAN as the ESP32 (Wi-Fi or Ethernet)
- ESP32 IP address known (check Serial Monitor at boot, or router admin)
### 1.1 Folder structure
```
orangepi-server/
├── visualizer.py # FastAPI server: connects to ESP32 + broadcasts to browsers
├── requirements.txt # Python dependencies
├── templates/
│ └── index.html # Web UI HTML
└── static/
├── style.css # Styling: light/dark, responsive, bilingual
└── app.js # Canvas renderer + browser WebSocket client
```
### 1.2 Copy files to Orange Pi
Copy the whole `orangepi-server/` folder to the Orange Pi. SCP example:
```bash
scp -r orangepi-server/ orangepi@<ORANGE_PI_IP>:/home/orangepi/
```
---
## 2. Install Python dependencies
```bash
cd ~/orangepi-server
# (Recommended) Use a virtual env
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
**Global install (faster, not recommended):**
```bash
pip3 install -r requirements.txt
```
> No `python3-tk` needed — everything runs in the browser.
---
## 3. Usage
### 3.1 Flash ESP32 in Full Stage mode
In `config.h` (Arduino IDE):
```cpp
#define DIAGNOSTIC_MODE 0 // Full mode: WiFi + WebSocket + REST API
```
Flash the code, grab the ESP32 IP from Serial Monitor. Example: `192.168.1.100`.
### 3.2 Start the Web Visualizer on Orange Pi
```bash
source venv/bin/activate # if using venv
python3 visualizer.py 192.168.1.100
```
CLI options:
```bash
# Custom port (default 8000):
python3 visualizer.py 192.168.1.100 --port 8080
# Restrict to localhost only:
python3 visualizer.py 192.168.1.100 --host 127.0.0.1
# Verbose logging:
python3 visualizer.py 192.168.1.100 --verbose
```
The console will print:
```
==============================================================
ESP32-S3 LD2453 Radar — Web Visualizer
==============================================================
ESP32 : ws://192.168.1.100/ws
Web UI : http://0.0.0.0:8000/
WS UI : ws://<OrangePi_IP>:8000/ws
==============================================================
```
### 3.3 Open the web UI on any device
On **any device on the same LAN** (phone, laptop, desktop…) open:
```
http://<OrangePi_IP>:8000/
```
Example: if Orange Pi IP is `192.168.1.50` → `http://192.168.1.50:8000/`
---
## 4. Web UI features
### 4.1 Highlights
| Feature | Description |
|---|---|
| 🎨 **Beautiful UI** | Light/Dark themes with Catppuccin color palette |
| 🌐 **Bilingual** | Vietnamese / English toggle in header |
| 📱 **Responsive** | Looks great on mobile, tablet, desktop |
| 🧭 **Radar Canvas** | Grid, concentric range rings, sweep animation |
| 🎯 **Targets** | Up to 3 colored targets, trails, velocity arrows, labels |
| 🟩 **Zones** | 3 rectangular zones with occupancy state badges |
| 📊 **Stats** | Frames received, valid targets, occupied zones, latency |
| 📝 **Details** | Per-target and per-zone info in the right panel |
| ✏️ **Zone editor** | Edit zone coordinates directly in the UI → pushes to ESP32 |
| 🔗 **Multi-client** | Any number of browser tabs/screens stay in sync |
### 4.2 Header
- 🟢/🟡/🔴 ESP32 + WebSocket connection status chips
- ☀️/🌙 Theme toggle (Light/Dark)
- `VI / EN` Language toggle
### 4.3 Radar View (Canvas)
- **Grid**: 1m cells with labels (−5m ← 0 → +5m horizontal, 0 → 7m forward)
- **Range rings**: Dashed concentric arcs every 1m from the radar
- **Radar dot**: Red circle at bottom-center with "RADAR" label
- **Sweep effect**: Rotating semi-transparent cone (toggleable)
- **Zones (3 rectangles)**:
- Z1 (Green), Z2 (Orange), Z3 (Red)
- Occupied → solid fill, bold border, `OCCUPIED` badge
- Empty → faint fill, dashed border, `EMPTY` badge
- **Targets (3 slots)**:
- Colors: Yellow (S1), Purple (S2), Cyan (S3)
- Glowing dots with white outline
- Velocity arrow (length ∝ cm/s, points along direction)
- Label: `T# | X,Y mm | speed cm/s`
- Fading trail for each target, persists ~3.5s
- Recently-lost targets show as dashed ghost circles
### 4.4 Tool toggles (top-right of canvas)
- Sweep effect on/off
- Target trails on/off
- Zone/target labels on/off
### 4.5 Side panels (right column)
**Statistics:**
- Total frames received
- Seconds since last frame
- Valid targets / 3
- Occupied zones / 3
**Target details:**
- Colored cards per target with status badge (`Detected`/`Recently seen`/`Free`)
- X/Y (mm), Speed (cm/s), Distance (m), Angle (°)
**Zones:**
- Per-zone list with coordinates and status
- **Edit zones** button opens a form (X1/Y1/X2/Y2 in mm):
- *Reset defaults* → reload original layout
- *Cancel* → close without saving
- *Save & Apply* → POST to `/api/zones` → forwarded to ESP32 `/updateZones` with success toast
---
## 5. Architecture & data flow
```
┌───────────┐ WebSocket /ws ┌──────────────────┐ WebSocket /ws ┌──────────────┐
│ ESP32-S3 │ ◄──────────────────► │ Orange Pi (WS │ ◄──────────────────► │ Browser │
│ LD2453 │ JSON radar frames │ FastAPI Server) │ synced state JSON │ (Canvas 2D) │
└───────────┘ └──────────────────┘ └──────────────┘
▲ ▲ ▲
│ REST /zones /updateZones │ GET / → index.html │
└───────────────────────────────────┘ GET /static/* → CSS, JS │
Zone edits in the UI are forwarded to the ESP32 │
```
**Endpoints:**
| Method | Endpoint | Purpose |
|---|---|---|
| GET | `/` | Main web page |
| GET | `/api/state` | Current radar state (fallback poll) |
| GET | `/api/zones` | Zones list |
| POST | `/api/zones` | Update zones (forwarded to ESP32 `/updateZones`) |
| WS | `/ws` | Realtime broadcast for browser clients |
---
## 6. Background running & customization
### 6.1 Run with tmux
```bash
tmux new -s radar
cd ~/orangepi-server && source venv/bin/activate
python3 visualizer.py 192.168.1.100
# Ctrl+B D → detach; tmux attach -t radar to return
```
### 6.2 systemd service (auto-start at boot)
Create `/etc/systemd/system/radar-web.service`:
```ini
[Unit]
Description=LD2453 Radar Web Visualizer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=orangepi
WorkingDirectory=/home/orangepi/orangepi-server
ExecStart=/home/orangepi/orangepi-server/venv/bin/python3 /home/orangepi/orangepi-server/visualizer.py 192.168.1.100 --port 8000
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
Then:
```bash
sudo systemctl daemon-reload
sudo systemctl enable radar-web
sudo systemctl start radar-web
journalctl -u radar-web -f # Live logs
```
### 6.3 Customization tips
- **Colors/themes**: edit CSS variables at top of `static/style.css` (`--z1`, `--t1`, `--bg`…)
- **Display range**: tweak `MAP.X_MIN/X_MAX/Y_MIN/Y_MAX` constants in `static/app.js`
- **Default zones**: modify `DEFAULT_ZONES` in `static/app.js` and `STATE.zones[0..2]` in `visualizer.py`
---
## 7. Troubleshooting
### Can't open the web page
1. Ping Orange Pi: `ping <OrangePi_IP>`
2. Check open port: `ss -tulpn | grep 8000`
3. Firewall? `sudo ufw allow 8000/tcp`
4. Is visualizer running? `ps aux | grep visualizer`
### No radar targets visible
1. Check ESP32 connection status chip (top-right corner of UI)
2. Ping ESP32: `ping 192.168.1.100`
3. Test ESP32 REST: `curl http://192.168.1.100/status` → JSON expected
4. ESP32 still in `DIAGNOSTIC_MODE 1`? → set to `0` for WiFi/WebSocket
5. ESP32 parser is still stubbed? → Targets always `INVALID` until real byte parser is implemented (see main repo README)
### Slow UI updates
- LD2453 radar sends ~2 frames/second by design (hardware limit)
- Ensure good Wi-Fi signal on both ESP32 and Orange Pi
### Browser WebSocket fails to connect
- Open DevTools → Console for the exact error
- Make sure you visit `http://<IP>:8000` (not `https://`)
- If behind Caddy/Nginx: enable WebSocket upgrade headers (`Upgrade: websocket`)
---
## 8. Comparison with old Tkinter/Pygame version
| Aspect | Old Tkinter | **New Web Visualizer** |
|---|---|---|
| Requires a monitor on Orange Pi? | Yes | ❌ **No** — view anywhere |
| View on phone? | No | ✅ **Yes** |
| Multiple viewers? | No | ✅ **Unlimited clients** |
| Graphics quality | Basic ⭐⭐ | **Canvas + effects + themes ⭐⭐⭐⭐⭐** |
| Zone editor | Edit Python | ✅ **In-UI form** |
| i18n (VI/EN) | No | ✅ **Built-in** |
| Mobile responsive | No | ✅ **Yes** |
@@ -1,2 +0,0 @@
websockets>=11.0
aiohttp>=3.8.0
-457
View File
@@ -1,457 +0,0 @@
#!/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()
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
websockets>=11.0
aiohttp>=3.8.0
+850
View File
@@ -0,0 +1,850 @@
/* ============================================================
ESP32 LD2453 Radar Web Visualizer — Frontend
Canvas rendering, WebSocket client, i18n, theme toggle
============================================================ */
/* ---------- i18n translations ---------- */
const I18N = {
vi: {
page_title: "ESP32 LD2453 — Radar Web Visualizer",
app_title: "Radar LD2453",
app_subtitle: "ESP32-S3 — Phát hiện người theo vùng",
toggle_theme: "Chuyển giao diện sáng/tối",
esp32_connecting: "ESP32: Đang kết nối…",
esp32_connected: "ESP32: Đã kết nối",
esp32_disconnected: "ESP32: Mất kết nối",
ws_connecting: "WS: Đang kết nối…",
ws_connected: "WS: Đã kết nối",
ws_disconnected: "WS: Mất kết nối",
radar_view: "Khung nhìn Radar",
sweep_effect: "Hiệu ứng quét",
target_trails: "Đường dẫn target",
show_labels: "Hiển thị label",
legend: "Chú thích",
target_1: "Target 1 (Slot 1)",
target_2: "Target 2 (Slot 2)",
target_3: "Target 3 (Slot 3)",
zone_1: "Zone 1", zone_2: "Zone 2", zone_3: "Zone 3",
seq: "Seq", targets: "Targets",
statistics: "Thống kê",
frames_received: "Frames nhận",
last_update: "Cập nhật cách đây",
valid_targets: "Target hợp lệ",
zones_occupied: "Vùng có người",
targets_detail: "Chi tiết Target",
detected: "Đã phát hiện",
recent: "Vừa mất dấu",
free: "Trống",
x_coord: "X", y_coord: "Y",
speed: "Tốc độ", distance: "Khoảng cách", angle: "Góc",
zones: "Zones",
edit_zones: "Sửa zones",
zone_editor_title: "Chỉnh sửa tọa độ zone (mm)",
cancel: "Hủy",
reset_default: "Mặc định",
save_and_apply: "Lưu & Áp dụng",
occupied: "CÓ NGƯỜI",
empty: "TRỐNG",
zone_saved: "Zones đã được cập nhật thành công",
zone_save_error: "Lỗi khi lưu zones",
ws_reconnecting: "Đang kết nối lại WebSocket…",
edit_zone_x1: "X1",
edit_zone_y1: "Y1",
edit_zone_x2: "X2",
edit_zone_y2: "Y2",
},
en: {
page_title: "ESP32 LD2453 — Radar Web Visualizer",
app_title: "LD2453 Radar",
app_subtitle: "ESP32-S3 — Zone Presence Detection",
toggle_theme: "Toggle light/dark theme",
esp32_connecting: "ESP32: Connecting…",
esp32_connected: "ESP32: Connected",
esp32_disconnected: "ESP32: Disconnected",
ws_connecting: "WS: Connecting…",
ws_connected: "WS: Connected",
ws_disconnected: "WS: Disconnected",
radar_view: "Radar View",
sweep_effect: "Sweep effect",
target_trails: "Target trails",
show_labels: "Show labels",
legend: "Legend",
target_1: "Target 1 (Slot 1)",
target_2: "Target 2 (Slot 2)",
target_3: "Target 3 (Slot 3)",
zone_1: "Zone 1", zone_2: "Zone 2", zone_3: "Zone 3",
seq: "Seq", targets: "Targets",
statistics: "Statistics",
frames_received: "Frames received",
last_update: "Last update",
valid_targets: "Valid targets",
zones_occupied: "Zones occupied",
targets_detail: "Target details",
detected: "Detected",
recent: "Recently seen",
free: "Free",
x_coord: "X", y_coord: "Y",
speed: "Speed", distance: "Distance", angle: "Angle",
zones: "Zones",
edit_zones: "Edit zones",
zone_editor_title: "Edit zone coordinates (mm)",
cancel: "Cancel",
reset_default: "Reset defaults",
save_and_apply: "Save & Apply",
occupied: "OCCUPIED",
empty: "EMPTY",
zone_saved: "Zones updated successfully",
zone_save_error: "Failed to save zones",
ws_reconnecting: "Reconnecting WebSocket…",
edit_zone_x1: "X1",
edit_zone_y1: "Y1",
edit_zone_x2: "X2",
edit_zone_y2: "Y2",
},
};
/* ---------- Global state ---------- */
const State = {
lang: localStorage.getItem("lang") || "vi",
theme: localStorage.getItem("theme") || "dark",
esp32Connected: false,
browserWsConnected: false,
radar: {
node_id: "RADAR_A", sequence: 0, timestamp_ms: 0, target_count: 0,
targets: [
{ slot:1, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null },
{ slot:2, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null },
{ slot:3, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null },
],
zones: [
{ id:1, x1:-4000, y1:1, x2:-1, y2:4000, occupied:false },
{ id:2, x1:1, y1:1, x2:4000, y2:4000, occupied:false },
{ id:3, x1:-4001, y1:4001, x2:4001, y2:6000, occupied:false },
],
frames_received: 0,
last_update: null,
},
/* Trails for each target: [{x,y,age}, ...] oldest → newest */
trails: [[], [], []],
/* Options toggled by the user */
options: {
sweep: true,
trails: true,
labels: true,
},
};
const DEFAULT_ZONES = [
{ id:1, x1:-4000, y1:1, x2:-1, y2:4000 },
{ id:2, x1:1, y1:1, x2:4000, y2:4000 },
{ id:3, x1:-4001, y1:4001, x2:4001, y2:6000 },
];
/* Radar coordinate → pixel mapping config (mm) */
const MAP = {
X_MIN: -5000, X_MAX: 5000,
Y_MIN: 0, Y_MAX: 7000,
MARGIN_X: 70, MARGIN_Y: 70,
};
/* ---------- Utilities ---------- */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
function t(key) {
return I18N[State.lang][key] || I18N.en[key] || key;
}
function applyI18n() {
document.documentElement.lang = State.lang;
document.body.dataset.lang = State.lang;
document.title = t("page_title");
$$("[data-i18n]").forEach(el => {
const k = el.getAttribute("data-i18n");
el.textContent = t(k);
});
$$("[data-i18n-title]").forEach(el => {
const k = el.getAttribute("data-i18n-title");
el.title = t(k);
});
}
function applyTheme() {
document.body.dataset.theme = State.theme;
}
function toast(message, type = "info") {
const container = $("#toastContainer");
const el = document.createElement("div");
el.className = "toast " + type;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
/* ---------- Canvas rendering ---------- */
const canvas = $("#radarCanvas");
const ctx = canvas.getContext("2d");
let DPR = window.devicePixelRatio || 1;
let canvasW = 0, canvasH = 0;
function resizeCanvas() {
const wrap = canvas.parentElement;
const rect = wrap.getBoundingClientRect();
DPR = window.devicePixelRatio || 1;
canvasW = rect.width;
canvasH = Math.max(rect.height, 520);
canvas.style.width = canvasW + "px";
canvas.style.height = canvasH + "px";
canvas.width = canvasW * DPR;
canvas.height = canvasH * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
/* Convert radar (x_mm, y_mm) → pixel (px, py) on canvas.
Radar origin is at the BOTTOM CENTER (Y grows upward, X is left-right).
Canvas origin (0,0) is TOP LEFT. */
function mmToPixel(x_mm, y_mm) {
const innerW = canvasW - 2 * MAP.MARGIN_X;
const innerH = canvasH - 2 * MAP.MARGIN_Y;
const xRatio = (x_mm - MAP.X_MIN) / (MAP.X_MAX - MAP.X_MIN);
const yRatio = 1.0 - (y_mm - MAP.Y_MIN) / (MAP.Y_MAX - MAP.Y_MIN);
return {
x: MAP.MARGIN_X + xRatio * innerW,
y: MAP.MARGIN_Y + yRatio * innerH,
};
}
function getCssVar(name) {
return getComputedStyle(document.body).getPropertyValue(name).trim();
}
/* Draw radar grid, axes, rings */
function drawBackground() {
ctx.clearRect(0, 0, canvasW, canvasH);
const gridColor = getCssVar("--bg-elev-1");
const axisColor = getCssVar("--accent-blue");
const labelColor = getCssVar("--text-dim");
/* Grid lines every 1m (1000 mm) */
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.font = "11px 'JetBrains Mono', Consolas, monospace";
ctx.fillStyle = labelColor;
/* Vertical lines (X) */
for (let x_mm = MAP.X_MIN; x_mm <= MAP.X_MAX; x_mm += 1000) {
const p1 = mmToPixel(x_mm, MAP.Y_MIN);
const p2 = mmToPixel(x_mm, MAP.Y_MAX);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
if (x_mm !== 0) {
ctx.textAlign = "center";
ctx.fillText(`${(x_mm/1000).toFixed(0)}m`, p1.x, canvasH - 30);
}
}
/* Horizontal lines (Y) */
for (let y_mm = MAP.Y_MIN; y_mm <= MAP.Y_MAX; y_mm += 1000) {
const p1 = mmToPixel(MAP.X_MIN, y_mm);
const p2 = mmToPixel(MAP.X_MAX, y_mm);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.textAlign = "right";
ctx.fillText(`${(y_mm/1000).toFixed(0)}m`, 44, p1.y + 4);
}
/* Concentric range rings (every 1m) from radar position */
const radar = mmToPixel(0, 0);
ctx.strokeStyle = gridColor;
ctx.setLineDash([3, 4]);
for (let r = 1000; r <= MAP.Y_MAX; r += 1000) {
const edge = mmToPixel(0, r);
const radiusPx = Math.abs(edge.y - radar.y);
ctx.beginPath();
ctx.arc(radar.x, radar.y, radiusPx, Math.PI, 0);
ctx.stroke();
}
ctx.setLineDash([]);
/* Y-axis (central axis) */
const axisTop = mmToPixel(0, MAP.Y_MAX);
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(radar.x, radar.y);
ctx.lineTo(axisTop.x, axisTop.y);
ctx.stroke();
ctx.setLineDash([]);
/* Radar icon */
const radarColor = getCssVar("--accent-red");
ctx.fillStyle = radarColor;
ctx.beginPath();
ctx.arc(radar.x, radar.y, 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,.7)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = radarColor;
ctx.textAlign = "center";
ctx.font = "bold 11px 'JetBrains Mono', Consolas, monospace";
ctx.fillText("RADAR", radar.x, radar.y + 26);
}
/* Sweep animation: rotating cone */
let sweepAngle = 0;
function drawSweep(dt) {
if (!State.options.sweep) return;
const sweepSpeed = 1.4; // rad/s
sweepAngle += dt * sweepSpeed;
sweepAngle = sweepAngle % (2 * Math.PI);
const radar = mmToPixel(0, 0);
const outer = mmToPixel(0, MAP.Y_MAX);
const radiusPx = Math.abs(outer.y - radar.y);
const color = getCssVar("--accent-blue");
const startA = sweepAngle - Math.PI - 0.35;
const endA = sweepAngle - Math.PI;
/* Only draw if within top semi-circle (Y >= 0 → angles -π → 0 in canvas) */
const grad = ctx.createRadialGradient(radar.x, radar.y, 0, radar.x, radar.y, radiusPx);
grad.addColorStop(0, hexToRgba(color, 0.45));
grad.addColorStop(0.5, hexToRgba(color, 0.18));
grad.addColorStop(1, hexToRgba(color, 0.0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(radar.x, radar.y);
ctx.arc(radar.x, radar.y, radiusPx, startA, endA);
ctx.closePath();
ctx.fill();
/* Sweep line */
const lineGrad = ctx.createLinearGradient(
radar.x, radar.y,
radar.x + Math.cos(endA) * radiusPx, radar.y + Math.sin(endA) * radiusPx
);
lineGrad.addColorStop(0, color);
lineGrad.addColorStop(1, hexToRgba(color, 0));
ctx.strokeStyle = lineGrad;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(radar.x, radar.y);
ctx.lineTo(radar.x + Math.cos(endA) * radiusPx, radar.y + Math.sin(endA) * radiusPx);
ctx.stroke();
}
function hexToRgba(hex, alpha) {
if (!hex.startsWith("#")) return `rgba(255,255,255,${alpha})`;
const h = hex.slice(1);
const bigint = parseInt(h.length === 3
? h.split("").map(c => c+c).join("")
: h, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r},${g},${b},${alpha})`;
}
/* Zones (rectangles) */
function drawZones(zones) {
const zColors = [getCssVar("--z1"), getCssVar("--z2"), getCssVar("--z3")];
const zStrong = [getCssVar("--z1-strong"), getCssVar("--z2-strong"), getCssVar("--z3-strong")];
zones.forEach((z, i) => {
const p1 = mmToPixel(z.x1, z.y1);
const p2 = mmToPixel(z.x2, z.y2);
const left = Math.min(p1.x, p2.x);
const top = Math.min(p1.y, p2.y);
const w = Math.abs(p2.x - p1.x);
const h = Math.abs(p2.y - p1.y);
const fill = z.occupied ? hexToRgba(zStrong[i], 0.45) : hexToRgba(zColors[i], 0.15);
const edge = z.occupied ? zStrong[i] : zColors[i];
ctx.fillStyle = fill;
ctx.strokeStyle = edge;
ctx.lineWidth = z.occupied ? 2.5 : 1.5;
if (!z.occupied) ctx.setLineDash([6, 4]);
roundRect(ctx, left, top, w, h, 8);
ctx.fill();
ctx.stroke();
ctx.setLineDash([]);
/* Label in the center of zone */
if (State.options.labels) {
const cx = left + w / 2;
const cy = top + h / 2;
ctx.save();
ctx.font = "bold 11px 'Inter', system-ui, sans-serif";
ctx.textAlign = "center";
const occText = z.occupied ? t("occupied") : t("empty");
ctx.fillStyle = z.occupied ? "#ffffff" : hexToRgba("#000000", 0.55);
const label = `Z${z.id} · ${occText}`;
const metrics = ctx.measureText(label);
const labelW = metrics.width + 12;
const labelH = 20;
ctx.fillStyle = z.occupied ? hexToRgba(zStrong[i], 0.92) : hexToRgba("#ffffff", 0.85);
roundRect(ctx, cx - labelW/2, cy - labelH/2, labelW, labelH, 6);
ctx.fill();
ctx.fillStyle = z.occupied ? "#ffffff" : zStrong[i];
ctx.fillText(label, cx, cy + 4);
ctx.restore();
}
});
}
/* Rounded rectangle helper */
function roundRect(c, x, y, w, h, r) {
r = Math.min(r, w/2, h/2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
/* Target trails + dots + velocity arrows */
function drawTargets(targets, dt) {
const tColors = [getCssVar("--t1"), getCssVar("--t2"), getCssVar("--t3")];
/* Update trails */
targets.forEach((t, idx) => {
const trail = State.trails[idx];
if (t.valid) {
trail.push({ x: t.x_mm, y: t.y_mm, age: 0 });
if (trail.length > 60) trail.shift();
}
/* Age each point */
for (let i = trail.length - 1; i >= 0; i--) {
trail[i].age += dt;
if (trail[i].age > 3.5) trail.splice(i, 1);
}
});
/* Draw trails */
if (State.options.trails) {
targets.forEach((_t, idx) => {
const trail = State.trails[idx];
if (trail.length < 2) return;
const color = tColors[idx];
for (let i = 1; i < trail.length; i++) {
const a = trail[i - 1];
const b = trail[i];
const alpha = Math.max(0, 1 - b.age / 3.5);
const pa = mmToPixel(a.x, a.y);
const pb = mmToPixel(b.x, b.y);
ctx.strokeStyle = hexToRgba(color, alpha * 0.6);
ctx.lineWidth = 2 + alpha * 1.5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
});
}
/* Draw target dots, arrows, labels */
targets.forEach((t, idx) => {
const nowSec = performance.now() / 1000;
if (!t.valid) {
/* Faded ghost if recently seen */
if (t.last_seen != null && t.last_seen < 3.5) {
const p = mmToPixel(t.x_mm, t.y_mm);
const alpha = 1 - t.last_seen / 3.5;
ctx.strokeStyle = hexToRgba(tColors[idx], alpha * 0.5);
ctx.setLineDash([3, 3]);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
return;
}
const p = mmToPixel(t.x_mm, t.y_mm);
const color = tColors[idx];
/* Outer glow */
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 28);
glow.addColorStop(0, hexToRgba(color, 0.4));
glow.addColorStop(1, hexToRgba(color, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(p.x, p.y, 28, 0, Math.PI * 2);
ctx.fill();
/* Dot */
ctx.fillStyle = color;
ctx.strokeStyle = "rgba(255,255,255,.85)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 9, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
/* Velocity arrow */
if (Math.abs(t.speed_cm_s) >= 1 && t.distance_mm > 1) {
const speedScale = 2.0; // px per cm/s
const ux = t.x_mm / t.distance_mm;
const uy = t.y_mm / t.distance_mm;
/* Speed sign: positive = moving towards radar? Match convention in backend.
We'll render direction assuming speed > 0 = forward along (ux,uy) */
const len = t.speed_cm_s * speedScale;
const ax = p.x + ux * len;
/* In canvas Y is inverted; uy in radar space is up → in canvas negative Y */
const ay = p.y - uy * len;
drawArrow(ctx, p.x, p.y, ax, ay, color, 2);
}
/* Label */
if (State.options.labels) {
const lang = State.lang;
const lines = [
`T${t.slot}`,
`${t.x_mm >= 0 ? "+":""}${t.x_mm}, ${t.y_mm} mm`,
`${t.speed_cm_s >= 0 ? "+":""}${t.speed_cm_s} cm/s`,
];
ctx.save();
ctx.font = "bold 11px 'JetBrains Mono', Consolas, monospace";
const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
const labelW = maxW + 14;
const labelH = lines.length * 14 + 10;
const lx = p.x + 16;
const ly = p.y - labelH - 4;
ctx.fillStyle = hexToRgba("#000000", 0.72);
ctx.strokeStyle = hexToRgba(color, 0.9);
ctx.lineWidth = 1.5;
roundRect(ctx, lx, ly, labelW, labelH, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.textAlign = "left";
lines.forEach((line, i) => {
ctx.fillText(line, lx + 7, ly + 16 + i * 14);
});
ctx.restore();
}
});
}
/* Draw a nice arrow from (x1,y1) to (x2,y2) */
function drawArrow(c, x1, y1, x2, y2, color, lw) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const headLen = 8;
c.strokeStyle = color;
c.fillStyle = color;
c.lineWidth = lw;
c.beginPath();
c.moveTo(x1, y1);
c.lineTo(x2, y2);
c.stroke();
c.beginPath();
c.moveTo(x2, y2);
c.lineTo(x2 - headLen * Math.cos(angle - Math.PI/6), y2 - headLen * Math.sin(angle - Math.PI/6));
c.lineTo(x2 - headLen * Math.cos(angle + Math.PI/6), y2 - headLen * Math.sin(angle + Math.PI/6));
c.closePath();
c.fill();
}
/* ---------- Side panels ---------- */
function refreshPanels() {
const s = State.radar;
/* Node info overlay */
$("#nodeId").textContent = s.node_id;
$("#seqNum").textContent = s.sequence.toLocaleString();
$("#tgtCount").textContent = `${s.target_count}/3`;
/* Stats */
$("#statFrames").textContent = s.frames_received.toLocaleString();
$("#statLastUpdate").textContent = s.last_update != null ? s.last_update.toFixed(1) : "—";
const validCt = s.targets.filter(t => t.valid).length;
$("#statValid").textContent = `${validCt} / 3`;
const occCt = s.zones.filter(z => z.occupied).length;
$("#statZones").textContent = `${occCt} / 3`;
/* Targets list */
const tl = $("#targetsList");
tl.innerHTML = "";
s.targets.forEach((t, idx) => {
const row = document.createElement("div");
let cls = "target-row";
let statusKey = "free";
if (t.valid) { cls += ""; statusKey = "detected"; }
else if (t.last_seen != null && t.last_seen < 3.5) { cls += " recent"; statusKey = "recent"; }
else { cls += " invalid"; }
row.className = cls;
const distanceKm = t.distance_mm ? (t.distance_mm/1000).toFixed(2) : "—";
const angleTxt = (t.angle_deg != null) ? `${t.angle_deg.toFixed(1)}°` : "—";
const speedTxt = (t.speed_cm_s != null) ? `${t.speed_cm_s >= 0 ? "+":""}${t.speed_cm_s}` : "—";
row.innerHTML = `
<div class="target-slot s${idx+1}">T${t.slot}</div>
<div class="target-detail">
<span class="status ${statusKey}">${t(statusKey)}</span>
<div class="target-metrics">
<div>${t("x_coord")}: <b>${t.x_mm} mm</b></div>
<div>${t("y_coord")}: <b>${t.y_mm} mm</b></div>
<div>${t("speed")}: <b>${speedTxt} cm/s</b></div>
<div>${t("distance")}: <b>${distanceKm} m</b></div>
<div style="grid-column: span 2;">${t("angle")}: <b>${angleTxt}</b></div>
</div>
</div>`;
tl.appendChild(row);
});
/* Zones list */
const zl = $("#zonesList");
zl.innerHTML = "";
s.zones.forEach((z, idx) => {
const row = document.createElement("div");
row.className = "zone-row" + (z.occupied ? " occupied" : "");
row.innerHTML = `
<div class="zone-swatch z${idx+1}"></div>
<div class="zone-info">
<div class="zone-name">${t("zone_"+(idx+1))} · Z${z.id}</div>
<div class="zone-coord">[${z.x1}, ${z.y1}] → [${z.x2}, ${z.y2}]</div>
</div>
<div class="zone-badge ${z.occupied ? "occupied" : "empty"}">${z.occupied ? t("occupied") : t("empty")}</div>
`;
zl.appendChild(row);
});
}
/* ---------- Zone editor ---------- */
function buildEditorInputs() {
const grid = $("#editorGrid");
grid.innerHTML = "";
State.radar.zones.forEach((z, idx) => {
const block = document.createElement("div");
block.className = "zone-editor-block";
block.innerHTML = `
<label>${t("zone_"+(idx+1))} — Z${z.id}</label>
<div class="coord-grid">
<div class="coord-field">
<label>${t("edit_zone_x1")}</label>
<input type="number" data-zone="${idx}" data-field="x1" value="${z.x1}">
</div>
<div class="coord-field">
<label>${t("edit_zone_y1")}</label>
<input type="number" data-zone="${idx}" data-field="y1" value="${z.y1}">
</div>
<div class="coord-field">
<label>${t("edit_zone_x2")}</label>
<input type="number" data-zone="${idx}" data-field="x2" value="${z.x2}">
</div>
<div class="coord-field">
<label>${t("edit_zone_y2")}</label>
<input type="number" data-zone="${idx}" data-field="y2" value="${z.y2}">
</div>
</div>`;
grid.appendChild(block);
});
}
function collectEditorPayload() {
const inputs = $$("#editorGrid input");
const zones = State.radar.zones.map(z => ({ id: z.id, x1: z.x1, y1: z.y1, x2: z.x2, y2: z.y2 }));
inputs.forEach(inp => {
const zi = parseInt(inp.dataset.zone);
const f = inp.dataset.field;
const v = parseInt(inp.value);
if (!Number.isNaN(v)) zones[zi][f] = v;
});
return zones;
}
/* ---------- WebSocket to backend ---------- */
let wsReconnectTimer = null;
function connectWS() {
const proto = (location.protocol === "https:") ? "wss" : "ws";
const url = `${proto}://${location.host}/ws`;
updateWsStatus("connecting");
let ws;
try { ws = new WebSocket(url); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => {
State.browserWsConnected = true;
updateWsStatus("ok");
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
onBackendFrame(data);
} catch (e) { console.warn("WS parse error", e); }
};
ws.onclose = () => {
State.browserWsConnected = false;
updateWsStatus("err");
toast(t("ws_reconnecting"), "warning");
scheduleReconnect();
};
ws.onerror = () => {
try { ws.close(); } catch (_) {}
};
}
function scheduleReconnect() {
if (wsReconnectTimer) return;
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectWS();
}, 2000);
}
function updateWsStatus(kind) {
const chip = $("#statusWs");
const dot = chip.querySelector(".status-dot");
const txt = chip.querySelector("span:last-child");
dot.className = "status-dot " + (kind === "ok" ? "status-ok" : kind === "err" ? "status-err" : "status-warn");
txt.textContent = kind === "ok" ? t("ws_connected") : kind === "err" ? t("ws_disconnected") : t("ws_connecting");
}
function updateEsp32Status(connected) {
const chip = $("#statusEsp32");
const dot = chip.querySelector(".status-dot");
const txt = chip.querySelector("span:last-child");
dot.className = "status-dot " + (connected ? "status-ok" : "status-err");
txt.textContent = connected ? t("esp32_connected") : t("esp32_disconnected");
}
/* Called on every broadcast frame from backend */
function onBackendFrame(data) {
State.esp32Connected = !!data.esp32_connected;
updateEsp32Status(State.esp32Connected);
State.radar.node_id = data.node_id || State.radar.node_id;
State.radar.sequence = data.sequence | 0;
State.radar.timestamp_ms = data.timestamp_ms | 0;
State.radar.target_count = data.target_count | 0;
State.radar.frames_received = data.frames_received | 0;
State.radar.last_update = data.last_update;
if (Array.isArray(data.targets)) {
State.radar.targets = data.targets.map(t => ({
slot: t.slot | 0,
valid: !!t.valid,
x_mm: t.x_mm | 0,
y_mm: t.y_mm | 0,
speed_cm_s: t.speed_cm_s | 0,
distance_mm: Number(t.distance_mm) || 0,
angle_deg: Number(t.angle_deg) || 0,
last_seen: (t.last_seen == null) ? null : Number(t.last_seen),
}));
}
if (Array.isArray(data.zones)) {
data.zones.forEach((z, i) => {
if (i >= State.radar.zones.length) return;
const s = State.radar.zones[i];
s.id = z.id | 0;
s.x1 = z.x1 | 0; s.y1 = z.y1 | 0;
s.x2 = z.x2 | 0; s.y2 = z.y2 | 0;
s.occupied = !!z.occupied;
});
}
refreshPanels();
}
/* ---------- Animation loop ---------- */
let lastFrameTs = performance.now();
function loop() {
const now = performance.now();
const dt = Math.min(0.1, (now - lastFrameTs) / 1000);
lastFrameTs = now;
drawBackground();
drawZones(State.radar.zones);
drawSweep(dt);
drawTargets(State.radar.targets, dt);
requestAnimationFrame(loop);
}
/* ---------- Wiring up ---------- */
function initUI() {
/* Theme toggle */
$("#btnTheme").addEventListener("click", () => {
State.theme = State.theme === "dark" ? "light" : "dark";
localStorage.setItem("theme", State.theme);
applyTheme();
});
/* Language toggle */
$("#btnLang").addEventListener("click", () => {
State.lang = State.lang === "vi" ? "en" : "vi";
localStorage.setItem("lang", State.lang);
applyI18n();
refreshPanels();
buildEditorInputs();
});
/* Tool toggles */
$("#chkSweep").addEventListener("change", e => State.options.sweep = e.target.checked);
$("#chkTrails").addEventListener("change", e => State.options.trails = e.target.checked);
$("#chkLabels").addEventListener("change", e => State.options.labels = e.target.checked);
/* Zone editor */
$("#btnEditZones").addEventListener("click", () => {
buildEditorInputs();
$("#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(t("zone_saved"), "success");
$("#zoneEditor").hidden = true;
} catch (err) {
console.error(err);
toast(t("zone_save_error") + ": " + err.message, "error");
}
});
/* Canvas resize */
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
}
/* ---------- Bootstrap ---------- */
document.addEventListener("DOMContentLoaded", () => {
applyTheme();
applyI18n();
initUI();
refreshPanels();
/* Try to pre-load state via REST in case WS is slow */
fetch("/api/state").then(r => r.ok ? r.json() : null).then(data => {
if (data) onBackendFrame(data);
}).catch(() => {});
connectWS();
loop();
});
+488
View File
@@ -0,0 +1,488 @@
/* ============================================================
ESP32 LD2453 — Radar Web Visualizer
Beautiful UI with light/dark themes, responsive layout
============================================================ */
/* ---------- Design tokens ---------- */
:root {
/* Catppuccin Mocha (dark) */
--bg: #1e1e2e;
--bg-elev-1: #313244;
--bg-elev-2: #45475a;
--surface: #181825;
--border: #45475a;
--text: #cdd6f4;
--text-muted: #a6adc8;
--text-dim: #6c7086;
--accent-blue: #89b4fa;
--accent-mauve: #cba6f7;
--accent-pink: #f5c2e7;
--accent-red: #f38ba8;
--accent-peach: #fab387;
--accent-yellow: #f9e2af;
--accent-green: #a6e3a1;
--accent-teal: #94e2d5;
--accent-sky: #74c7ec;
--z1: #a6e3a1;
--z1-strong: #40a02b;
--z2: #fab387;
--z2-strong: #fe640b;
--z3: #f38ba8;
--z3-strong: #d20f39;
--t1: #f9e2af;
--t2: #cba6f7;
--t3: #74c7ec;
--status-ok: #a6e3a1;
--status-warn: #f9e2af;
--status-err: #f38ba8;
--radius: 14px;
--radius-sm: 8px;
--radius-lg: 20px;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2);
--header-h: 72px;
}
[data-theme="light"] {
/* Catppuccin Latte (light) */
--bg: #eff1f5;
--bg-elev-1: #e6e9ef;
--bg-elev-2: #dce0e8;
--surface: #ffffff;
--border: #ccd0da;
--text: #4c4f69;
--text-muted: #6c6f85;
--text-dim: #8c8fa1;
--accent-blue: #1e66f5;
--accent-mauve: #8839ef;
--accent-pink: #ea76cb;
--accent-red: #d20f39;
--accent-peach: #fe640b;
--accent-yellow: #df8e1d;
--accent-green: #40a02b;
--accent-teal: #179299;
--accent-sky: #04a5e5;
--z1: #40a02b;
--z1-strong: #2d7a1e;
--z2: #fe640b;
--z2-strong: #cc4f05;
--z3: #d20f39;
--z3-strong: #9c0a29;
--t1: #df8e1d;
--t2: #8839ef;
--t3: #04a5e5;
--shadow: 0 10px 30px rgba(76, 79, 105, 0.12);
--shadow-sm: 0 2px 6px rgba(76, 79, 105, 0.08);
}
/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background .3s ease, color .3s ease;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
h1, h2, h3, h4 { margin: 0; font-weight: 600; }
input, button { font-family: inherit; color: inherit; }
/* ---------- Header ---------- */
.app-header {
position: sticky; top: 0; z-index: 50;
height: var(--header-h);
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
box-shadow: var(--shadow-sm);
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-radar-dot { fill: var(--accent-red); }
.logo-arc { fill: none; stroke: var(--accent-blue); stroke-width: 4; stroke-linecap: round; }
.logo-ring { fill: none; stroke: var(--accent-blue); stroke-width: 2; opacity: .5; }
.logo-text h1 { font-size: 20px; letter-spacing: .2px; }
.logo-text .subtitle { font-size: 12px; color: var(--text-muted); display: block; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* Status chip */
.status-chip {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px;
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px; font-weight: 500;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--status-warn);
box-shadow: 0 0 0 3px rgba(249, 226, 175, 0.15);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.status-ok { background: var(--status-ok); box-shadow: 0 0 0 3px rgba(166, 227, 161, .2); }
.status-dot.status-err { background: var(--status-err); box-shadow: 0 0 0 3px rgba(243, 139, 168, .2); }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
/* Icon buttons */
.icon-btn, .lang-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px;
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all .2s ease;
color: var(--text);
}
.icon-btn:hover, .lang-btn:hover { background: var(--bg-elev-2); transform: translateY(-1px); }
.icon-btn svg { fill: none; stroke: var(--text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.icon-btn .icon-moon { display: none; }
[data-theme="dark"] .icon-btn .icon-sun { display: none; }
[data-theme="dark"] .icon-btn .icon-moon { display: block; }
[data-theme="light"] .icon-btn .icon-sun { display: block; }
[data-theme="light"] .icon-btn .icon-moon { display: none; }
/* Lang button */
.lang-btn { width: auto; padding: 0 10px; gap: 4px; font-size: 12px; font-weight: 700; }
.lang-vi, .lang-en { opacity: .45; transition: opacity .2s; }
[data-lang="vi"] .lang-vi { color: var(--accent-blue); opacity: 1; }
[data-lang="en"] .lang-en { color: var(--accent-blue); opacity: 1; }
.lang-sep { color: var(--text-dim); font-weight: 400; }
/* ---------- Main layout ---------- */
.app-main {
display: grid;
grid-template-columns: 1fr 380px;
gap: 20px;
padding: 20px;
max-width: 1600px;
margin: 0 auto;
}
@media (max-width: 1024px) {
.app-main { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.app-header { padding: 0 14px; height: 64px; }
.logo-text h1 { font-size: 16px; }
.logo-text .subtitle { font-size: 10px; }
.app-main { padding: 12px; gap: 14px; }
.status-chip span:last-child { display: none; }
.status-chip { padding: 6px 8px; }
}
/* ---------- Panels ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex; flex-direction: column;
box-shadow: var(--shadow);
}
.panel-header {
padding: 16px 20px;
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--bg-elev-1), transparent);
}
.panel-header h2 { font-size: 16px; }
.panel-tools { display: flex; gap: 8px; flex-wrap: wrap; }
.chip-toggle {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
cursor: pointer;
transition: all .2s ease;
user-select: none;
}
.chip-toggle:hover { background: var(--bg-elev-2); }
.chip-toggle input { accent-color: var(--accent-blue); }
/* Canvas wrapper */
.canvas-wrap {
position: relative;
flex: 1;
min-height: 520px;
background:
radial-gradient(ellipse at center bottom, rgba(137, 180, 250, 0.06), transparent 60%),
var(--bg);
}
#radarCanvas {
display: block;
width: 100%;
height: 100%;
min-height: 520px;
}
/* Legend */
.legend {
position: absolute;
top: 12px; left: 12px;
background: rgba(24, 24, 37, .78);
color: #cdd6f4;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 12px;
min-width: 160px;
box-shadow: var(--shadow-sm);
}
[data-theme="light"] .legend { background: rgba(255,255,255,.85); color: #4c4f69; }
.legend-title { font-weight: 700; margin-bottom: 8px; color: var(--accent-blue); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.legend-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.legend-divider { height: 1px; background: var(--border); margin: 8px 0; }
.swatch { width: 12px; height: 12px; border-radius: 50%; border: 2px solid rgba(255,255,255,.6); }
.swatch-t1 { background: var(--t1); }
.swatch-t2 { background: var(--t2); }
.swatch-t3 { background: var(--t3); }
.swatch-zone { width: 14px; height: 10px; border-radius: 3px; border: 1.5px solid; }
.swatch-zone.z1 { background: color-mix(in srgb, var(--z1) 30%, transparent); border-color: var(--z1); }
.swatch-zone.z2 { background: color-mix(in srgb, var(--z2) 30%, transparent); border-color: var(--z2); }
.swatch-zone.z3 { background: color-mix(in srgb, var(--z3) 30%, transparent); border-color: var(--z3); }
/* Node info */
.node-info {
position: absolute;
bottom: 12px; left: 50%; transform: translateX(-50%);
display: inline-flex; align-items: center; gap: 10px;
padding: 8px 16px;
background: rgba(24, 24, 37, .78);
color: #cdd6f4;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid var(--border);
border-radius: 999px;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
box-shadow: var(--shadow-sm);
}
[data-theme="light"] .node-info { background: rgba(255,255,255,.85); color: #4c4f69; }
.node-info .node-id { font-weight: 700; color: var(--accent-blue); }
.node-info .sep { color: var(--text-dim); }
/* ---------- Side panel ---------- */
.panel-side { display: flex; flex-direction: column; gap: 16px; padding: 16px; background: transparent; border: none; box-shadow: none; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.card h3 { font-size: 14px; margin-bottom: 12px; color: var(--accent-blue); }
.card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.card-header-row h3 { margin-bottom: 0; }
.btn-link {
background: none; border: none; color: var(--accent-blue);
font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 8px;
border-radius: 6px;
transition: background .2s;
}
.btn-link:hover { background: var(--bg-elev-1); }
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat {
padding: 10px 12px;
background: var(--bg-elev-1);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.stat-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .3px; }
.stat-value { font-size: 22px; font-weight: 700; font-family: "JetBrains Mono", "Consolas", monospace; }
.stat-value small { font-size: 12px; font-weight: 400; color: var(--text-muted); margin-left: 2px; }
/* Targets list */
.targets-list { display: flex; flex-direction: column; gap: 8px; }
.target-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-elev-1);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: all .2s;
}
.target-row.invalid { opacity: .45; filter: grayscale(.5); }
.target-row.recent { opacity: .8; }
.target-slot {
width: 34px; height: 34px; border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 13px; color: #1e1e2e;
border: 2px solid rgba(255,255,255,.6);
}
[data-theme="light"] .target-slot { color: #ffffff; }
.target-slot.s1 { background: var(--t1); }
.target-slot.s2 { background: var(--t2); }
.target-slot.s3 { background: var(--t3); }
.target-detail { font-size: 12px; font-family: "JetBrains Mono", "Consolas", monospace; }
.target-detail .status {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700; margin-bottom: 6px;
text-transform: uppercase; letter-spacing: .4px;
}
.target-detail .status.detected { background: color-mix(in srgb, var(--status-ok) 25%, transparent); color: var(--accent-green); }
.target-detail .status.recent { background: color-mix(in srgb, var(--status-warn) 25%, transparent); color: var(--accent-yellow); }
.target-detail .status.free { background: color-mix(in srgb, var(--text-dim) 20%, transparent); color: var(--text-muted); }
.target-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; color: var(--text-muted); }
.target-metrics b { color: var(--text); font-weight: 600; }
/* Zones list */
.zones-list { display: flex; flex-direction: column; gap: 8px; }
.zone-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-elev-1);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: all .25s;
}
.zone-row.occupied { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
.zone-swatch {
width: 10px; height: 100%; min-height: 36px; border-radius: 6px;
border: 1.5px solid;
}
.zone-row.occupied .zone-swatch { opacity: 1; }
.zone-swatch.z1 { background: color-mix(in srgb, var(--z1) 30%, transparent); border-color: var(--z1); }
.zone-swatch.z2 { background: color-mix(in srgb, var(--z2) 30%, transparent); border-color: var(--z2); }
.zone-swatch.z3 { background: color-mix(in srgb, var(--z3) 30%, transparent); border-color: var(--z3); }
.zone-row.occupied .zone-swatch.z1 { background: color-mix(in srgb, var(--z1) 65%, transparent); }
.zone-row.occupied .zone-swatch.z2 { background: color-mix(in srgb, var(--z2) 65%, transparent); }
.zone-row.occupied .zone-swatch.z3 { background: color-mix(in srgb, var(--z3) 65%, transparent); }
.zone-info .zone-name { font-weight: 700; font-size: 13px; margin-bottom: 2px; }
.zone-info .zone-coord { font-size: 11px; color: var(--text-muted); font-family: "JetBrains Mono", monospace; }
.zone-badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: .4px;
}
.zone-badge.occupied { background: color-mix(in srgb, var(--status-ok) 25%, transparent); color: var(--accent-green); }
.zone-badge.empty { background: color-mix(in srgb, var(--text-dim) 20%, transparent); color: var(--text-muted); }
/* Zone editor */
.zone-editor {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
display: flex; flex-direction: column; gap: 12px;
animation: slideDown .3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.editor-title { font-size: 12px; font-weight: 600; color: var(--accent-blue); }
.editor-grid {
display: flex; flex-direction: column; gap: 12px;
}
.zone-editor-block {
padding: 10px;
background: var(--bg-elev-1);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.zone-editor-block label {
font-size: 12px; font-weight: 600; display: block; margin-bottom: 6px;
color: var(--text);
}
.zone-editor-block .coord-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.zone-editor-block .coord-grid label {
font-size: 10px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: .3px;
margin-bottom: 2px;
}
.zone-editor-block .coord-field { display: flex; flex-direction: column; }
.zone-editor-block input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
font-family: "JetBrains Mono", monospace; font-size: 12px;
color: var(--text);
transition: border-color .15s;
}
.zone-editor-block input:focus { outline: none; border-color: var(--accent-blue); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-blue) 20%, transparent); }
.editor-actions { display: flex; gap: 8px; justify-content: flex-end; }
.btn {
padding: 8px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-elev-1);
color: var(--text);
font-size: 12px; font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn:hover { transform: translateY(-1px); }
.btn-ghost:hover { background: var(--bg-elev-2); }
.btn-primary {
background: linear-gradient(135deg, var(--accent-blue), var(--accent-mauve));
color: #fff;
border-color: 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); }
/* Toast */
.toast-container {
position: fixed; bottom: 20px; right: 20px;
display: flex; flex-direction: column; gap: 8px;
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 12px 16px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-left: 4px solid var(--accent-blue);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
font-size: 13px; font-weight: 500;
min-width: 260px;
animation: toastIn .3s ease, toastOut .3s ease 2.7s forwards;
pointer-events: auto;
}
.toast.success { border-left-color: var(--accent-green); }
.toast.error { border-left-color: var(--accent-red); }
.toast.warning { border-left-color: var(--accent-yellow); }
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="page_title">ESP32 LD2453 — Radar Web Visualizer</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='70' r='8' fill='%23f38ba8'/%3E%3Cpath d='M20 70 A30 30 0 0 1 80 70' fill='none' stroke='%2389b4fa' stroke-width='3'/%3E%3Ccircle cx='50' cy='70' r='22' fill='none' stroke='%2389b4fa' stroke-width='1.5' opacity='0.4'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-theme="dark" data-lang="vi">
<!-- ============================================================= -->
<!-- HEADER -->
<!-- ============================================================= -->
<header class="app-header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 100 100" width="34" height="34" aria-hidden="true">
<circle cx="50" cy="70" r="9" class="logo-radar-dot" />
<path d="M20 70 A30 30 0 0 1 80 70" class="logo-arc" />
<circle cx="50" cy="70" r="22" class="logo-ring" />
</svg>
<div class="logo-text">
<h1 data-i18n="app_title">Radar LD2453</h1>
<span class="subtitle" data-i18n="app_subtitle">ESP32-S3 — Zone Presence Detection</span>
</div>
</div>
</div>
<div class="header-right">
<!-- Connection status -->
<div class="status-chip" id="statusEsp32">
<span class="status-dot status-warn"></span>
<span data-i18n="esp32_connecting">ESP32: Đang kết nối…</span>
</div>
<div class="status-chip" id="statusWs">
<span class="status-dot status-warn"></span>
<span data-i18n="ws_connecting">WS: Đang kết nối…</span>
</div>
<!-- Theme toggle -->
<button class="icon-btn" id="btnTheme" title="Toggle theme" data-i18n-title="toggle_theme">
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<!-- Language toggle -->
<button class="lang-btn" id="btnLang" title="Change language">
<span class="lang-vi">VI</span>
<span class="lang-sep">/</span>
<span class="lang-en">EN</span>
</button>
</div>
</header>
<!-- ============================================================= -->
<!-- MAIN LAYOUT -->
<!-- ============================================================= -->
<main class="app-main">
<!-- ======= LEFT / TOP: RADAR CANVAS ======= -->
<section class="panel panel-canvas">
<div class="panel-header">
<h2 data-i18n="radar_view">Radar View</h2>
<div class="panel-tools">
<label class="chip-toggle">
<input type="checkbox" id="chkSweep" checked />
<span data-i18n="sweep_effect">Hiệu ứng quét</span>
</label>
<label class="chip-toggle">
<input type="checkbox" id="chkTrails" checked />
<span data-i18n="target_trails">Đường dẫn target</span>
</label>
<label class="chip-toggle">
<input type="checkbox" id="chkLabels" checked />
<span data-i18n="show_labels">Hiển thị label</span>
</label>
</div>
</div>
<div class="canvas-wrap">
<canvas id="radarCanvas"></canvas>
<!-- Legend overlay -->
<div class="legend">
<div class="legend-title" data-i18n="legend">Chú thích</div>
<div class="legend-item">
<span class="swatch swatch-t1"></span>
<span data-i18n="target_1">Target 1 (Slot 1)</span>
</div>
<div class="legend-item">
<span class="swatch swatch-t2"></span>
<span data-i18n="target_2">Target 2 (Slot 2)</span>
</div>
<div class="legend-item">
<span class="swatch swatch-t3"></span>
<span data-i18n="target_3">Target 3 (Slot 3)</span>
</div>
<div class="legend-divider"></div>
<div class="legend-item">
<span class="swatch-zone z1"></span>
<span data-i18n="zone_1">Zone 1</span>
</div>
<div class="legend-item">
<span class="swatch-zone z2"></span>
<span data-i18n="zone_2">Zone 2</span>
</div>
<div class="legend-item">
<span class="swatch-zone z3"></span>
<span data-i18n="zone_3">Zone 3</span>
</div>
</div>
<!-- Node info overlay -->
<div class="node-info" id="nodeInfo">
<span class="node-id" id="nodeId">RADAR_A</span>
<span class="sep">|</span>
<span data-i18n="seq">Seq</span>: <span id="seqNum">0</span>
<span class="sep">|</span>
<span data-i18n="targets">Targets</span>: <span id="tgtCount">0/3</span>
</div>
</div>
</section>
<!-- ======= RIGHT / BOTTOM: INFO PANELS ======= -->
<section class="panel panel-side">
<!-- Stats card -->
<div class="card stats-card">
<h3 data-i18n="statistics">Thống kê</h3>
<div class="stats-grid">
<div class="stat">
<div class="stat-label" data-i18n="frames_received">Frames nhận</div>
<div class="stat-value" id="statFrames">0</div>
</div>
<div class="stat">
<div class="stat-label" data-i18n="last_update">Cập nhật cách đây</div>
<div class="stat-value"><span id="statLastUpdate">—</span><small>s</small></div>
</div>
<div class="stat">
<div class="stat-label" data-i18n="valid_targets">Target hợp lệ</div>
<div class="stat-value" id="statValid">0 / 3</div>
</div>
<div class="stat">
<div class="stat-label" data-i18n="zones_occupied">Vùng có người</div>
<div class="stat-value" id="statZones">0 / 3</div>
</div>
</div>
</div>
<!-- Targets list -->
<div class="card targets-card">
<h3 data-i18n="targets_detail">Chi tiết Target</h3>
<div class="targets-list" id="targetsList">
<!-- Populated by JS -->
</div>
</div>
<!-- Zones list + editor -->
<div class="card zones-card">
<div class="card-header-row">
<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>
<!-- 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>
</form>
</div>
</section>
</main>
<!-- ============================================================= -->
<!-- TOAST -->
<!-- ============================================================= -->
<div id="toastContainer" class="toast-container"></div>
<!-- ============================================================= -->
<!-- SCRIPTS -->
<!-- ============================================================= -->
<script src="/static/app.js"></script>
</body>
</html>
+476
View File
@@ -0,0 +1,476 @@
#!/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."""
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),
"last_seen": round(time.time() - t.last_seen, 2) if t.last_seen 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(time.time() - self.last_update, 2) if self.last_update else None,
"esp32_connected": self.esp32_connected,
}
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
# ============================================================
# 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} ...")
async with websockets.connect(
uri, ping_interval=30, ping_timeout=10, close_timeout=5
) 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)
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)
await broadcast_to_browsers()
log.info("ESP32 WebSocket đóng, đang kết nối lại...")
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 ESP32 client task when app starts, cancel it on shutdown."""
task = asyncio.create_task(esp32_ws_forever(ESP32_HOST))
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
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()