change to web visualizer
This commit is contained in:
1 parent
87be72b52b
commit
417ced90fa
9 files changed
+2580
-680
No files matched your search
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
websockets>=11.0
|
||||||
|
aiohttp>=3.8.0
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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); } }
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user