From 417ced90fac67070a5c4fbd83871329446e2cf54 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Fri, 11 Sep 2026 20:14:37 +0700 Subject: [PATCH] change to web visualizer --- orangepi-server/ORANGE_PI_GUIDE.md | 221 ------ orangepi-server/README.md | 565 ++++++++++++++ orangepi-server/orange_pi_requirements.txt | 2 - orangepi-server/orange_pi_visualizer.py | 457 ----------- orangepi-server/requirements.txt | 4 + orangepi-server/static/app.js | 850 +++++++++++++++++++++ orangepi-server/static/style.css | 488 ++++++++++++ orangepi-server/templates/index.html | 197 +++++ orangepi-server/visualizer.py | 476 ++++++++++++ 9 files changed, 2580 insertions(+), 680 deletions(-) delete mode 100644 orangepi-server/ORANGE_PI_GUIDE.md create mode 100644 orangepi-server/README.md delete mode 100644 orangepi-server/orange_pi_requirements.txt delete mode 100644 orangepi-server/orange_pi_visualizer.py create mode 100644 orangepi-server/requirements.txt create mode 100644 orangepi-server/static/app.js create mode 100644 orangepi-server/static/style.css create mode 100644 orangepi-server/templates/index.html create mode 100644 orangepi-server/visualizer.py diff --git a/orangepi-server/ORANGE_PI_GUIDE.md b/orangepi-server/ORANGE_PI_GUIDE.md deleted file mode 100644 index 83f0a56..0000000 --- a/orangepi-server/ORANGE_PI_GUIDE.md +++ /dev/null @@ -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@:/home/orangepi/ -``` - -Hoặc dùng USB, hoặc clone repo trực tiếp. - ---- - -## 2. Cài đặt Python dependencies - -```bash -cd /home/orangepi -python3 -m venv venv -source venv/bin/activate - -pip install -r orange_pi_requirements.txt -``` - -**Hoặc cài global (không khuyến nghị nhưng nhanh):** -```bash -pip3 install websockets aiohttp -``` - ---- - -## 3. Sử dụng - -### 3.1 Bật ESP32 ở chế độ Full Stage -Trong `config.h` trên Arduino IDE: -```cpp -#define DIAGNOSTIC_MODE 0 // Full mode: WiFi + WebSocket + REST API -``` -Flash code, mở Serial Monitor lấy IP ESP32. Ví dụ: `192.168.1.100` - -### 3.2 Chạy Visualizer trên Orange Pi - -#### Chế độ GUI (Tkinter) — Mặc định -Cần môi trường desktop (có màn hình gắn vào Orange Pi): -```bash -source venv/bin/activate -python3 orange_pi_visualizer.py 192.168.1.100 -``` - -#### Chế độ Headless (Console) -Dùng khi Orange Pi không có màn hình, chạy SSH remote: -```bash -python3 orange_pi_visualizer.py 192.168.1.100 --headless -``` - ---- - -## 4. Mô tả giao diện - -### 4.1 GUI Tkinter -- **Canvas 900x700**: Biểu diễn không gian phòng theo tỷ lệ thực (mm) - - **Trục X (ngang)**: -5m ← 0 (radar) → +5m - - **Trục Y (dọc)**: 0 (radar ở dưới cùng) ↑ đến 7m (phía trước) -- **Grid**: Mỗi ô = 1 mét -- **Radar**: Hình tròn màu đỏ ở cạnh dưới giữa -- **3 Zones**: - - Zone 1 (Xanh lá): Bên trái radar (-4m → -1mm, 1mm → 4m) - - Zone 2 (Cam): Bên phải radar (1mm → 4m, 1mm → 4m) - - Zone 3 (Đỏ): Khu vực xa hơn (từ 4m → 6m, toàn bộ bề ngang) - - Khi có người: Màu đậm, chữ CO - - Khi trống: Màu nhạt, gạch nét, chữ TRONG -- **Targets (3 điểm)**: - - Màu: Vàng (Slot1), Tím (Slot2), Xanh (Slot3) - - Mũi tên: Tốc độ (độ dài tỉ lệ với tốc độ cm/s) - - Label: `T1 X,Y mm speed cm/s` - -### 4.2 Console (headless) -Cập nhật 2 lần/giây, hiển thị: -``` -============================================================ - ESP32 LD2453 — Node: RADAR_A Seq: 12345 -============================================================ - Frames received: 1200 - Target count : 2/3 - Last update : 0.1s ago ------------------------------------------------------------- - Slot 1: DETECTED - X= 850mm Y= 2340mm Speed=+12cm/s Dist= 2488mm Angle=+19.9° - Slot 2: FREE - Slot 3: RECENT (2s) ------------------------------------------------------------- - Zone 1: TRONG [-4000, 1] -> [ -1, 4000] - Zone 2: X CO [ 1, 1] -> [ 4000, 4000] - Zone 3: TRONG [-4001, 4001] -> [ 4001, 6000] -============================================================ -``` - ---- - -## 5. Tùy chỉnh - -### 5.1 Thay đổi zone layout -Cách 1: Từ giao diện web ESP32 -- Mở trình duyệt vào `http:///zones` xem hiện tại -- Cập nhật bằng POST request: -```bash -curl -X POST http://192.168.1.100/updateZones -H "Content-Type: application/json" -d '[ - {"id":1,"x1":-3000,"y1":500,"x2":-1,"y2":3000}, - {"id":2,"x1":1,"y1":500,"x2":3000,"y2":3000}, - {"id":3,"x1":-3000,"y1":3001,"x2":3000,"y2":5000} -]' -``` -Script visualizer sẽ tự động lấy zones mới sau khi reconnect WebSocket (hoặc restart script). - -Cách 2: Edit hằng số đầu file `orange_pi_visualizer.py`: -```python -# Default zones (giong ESP32) -state.zones[0] = Zone(1, x1=-4000, y1=1, x2=-1, y2=4000) -state.zones[1] = Zone(2, x1=1, y1=1, x2=4000, y2=4000) -state.zones[2] = Zone(3, x1=-4001, y1=4001, x2=4001, y2=6000) -``` - -### 5.2 Thay đổi kích thước/range hiển thị -Trong class `TkinterVisualizer`: -```python -CANVAS_W = 900 -CANVAS_H = 700 -ROOM_X_MIN = -5000 -ROOM_X_MAX = 5000 -ROOM_Y_MIN = 0 -ROOM_Y_MAX = 7000 -``` - -### 5.3 Chạy nền (background) trên Orange Pi -Dùng `tmux` hoặc `systemd`: - -#### tmux -```bash -tmux new -s radar -python3 orange_pi_visualizer.py 192.168.1.100 --headless -# Ctrl+B D de detach -# tmux attach -t radar de quay lai -``` - -#### systemd service (tự chạy khi boot) -Tạo file `/etc/systemd/system/radar-vis.service`: -```ini -[Unit] -Description=LD2453 Radar Visualizer -After=network-online.target - -[Service] -Type=simple -User=orangepi -WorkingDirectory=/home/orangepi -ExecStart=/home/orangepi/venv/bin/python3 /home/orangepi/orange_pi_visualizer.py 192.168.1.100 --headless -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` -Sau đó: -```bash -sudo systemctl daemon-reload -sudo systemctl enable radar-vis -sudo systemctl start radar-vis -journalctl -u radar-vis -f # xem log realtime -``` - ---- - -## 6. Troubleshooting - -### "No such file directory python3-tk" -```bash -sudo apt update && sudo apt install -y python3-tk -``` - -### Không kết nối được WebSocket -1. Ping ESP32: `ping 192.168.1.100` -2. Test REST API: `curl http://192.168.1.100/status` → phải trả về `{"status":"ok",...}` -3. Tường lửa Orange Pi? `sudo ufw status` -4. ESP32 ở chế độ `DIAGNOSTIC_MODE 1`? → phải `0` mới có WiFi/WebSocket. - -### Tốc độ cập nhật chậm -- Mặc định LD2453 gửi ~2 fps. Đây là giới hạn của radar. -- Để giảm lag GUI: tăng giảm `_last_print` trong ConsoleVisualizer (dòng `now - self._last_print < 0.5`) - -### Target số không chính xác/khớp -- Parser trên ESP32 đang trong giai đoạn đầu. Nếu thấy dữ liệu kỳ lạ (tọa độ không hợp lý, target nhảy lung tung): - 1. Bật `DEBUG_RADAR_RAW 1` trên ESP32, lấy RAW dump - 2. Gửi cho việc điều chỉnh lại byte layout. - ---- - -## 7. Đối chiếu với dự án gốc ESP32-LD2450 -File này dựa trên pattern của `https://github.com/thanhtantran/ESP32-LD2450`: -- Cùng WebSocket path `/ws` -- Cùng format JSON (`node_id`, `sequence`, `targets[]` với `x`, `y`, `speed`) -- Cùng REST API `/zones` + `/updateZones` + `/status` -- Thêm vào đây: `target_count` tổng hợp, fields `distance` & `angle` cho tiện tính toán. diff --git a/orangepi-server/README.md b/orangepi-server/README.md new file mode 100644 index 0000000..4ddd12b --- /dev/null +++ b/orangepi-server/README.md @@ -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@:/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://: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://: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 ` +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://: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@:/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://:8000/ws +============================================================== +``` + +### 3.3 Open the web UI on any device +On **any device on the same LAN** (phone, laptop, desktop…) open: +``` +http://: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 ` +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://: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** | diff --git a/orangepi-server/orange_pi_requirements.txt b/orangepi-server/orange_pi_requirements.txt deleted file mode 100644 index f7998f5..0000000 --- a/orangepi-server/orange_pi_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -websockets>=11.0 -aiohttp>=3.8.0 diff --git a/orangepi-server/orange_pi_visualizer.py b/orangepi-server/orange_pi_visualizer.py deleted file mode 100644 index 8586dcd..0000000 --- a/orangepi-server/orange_pi_visualizer.py +++ /dev/null @@ -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 # GUI mode (tkinter) - python orange_pi_visualizer.py --headless # Chỉ in console - python orange_pi_visualizer.py --mode pygame # Dùng pygame (đồ họa đẹp hơn) -""" - -import argparse -import asyncio -import json -import sys -import time -from collections import deque -from dataclasses import dataclass, field -from typing import List, Optional, Tuple - -# ============================================================ -# WebSocket client -# ============================================================ -try: - import websockets -except ImportError: - print("Loi: Can install websockets: pip install websockets") - sys.exit(1) - - -@dataclass -class Target: - slot: int - valid: bool = False - x_mm: int = 0 - y_mm: int = 0 - speed_cm_s: int = 0 - distance_mm: float = 0.0 - angle_deg: float = 0.0 - last_seen: float = 0.0 - - -@dataclass -class Zone: - id: int - x1: int = 0 - y1: int = 0 - x2: int = 0 - y2: int = 0 - occupied: bool = False - - -@dataclass -class RadarState: - node_id: str = "RADAR_A" - sequence: int = 0 - timestamp_ms: int = 0 - target_count: int = 0 - targets: List[Target] = field(default_factory=lambda: [Target(i + 1) for i in range(3)]) - zones: List[Zone] = field(default_factory=lambda: [Zone(i) for i in range(1, 4)]) - frames_received: int = 0 - last_update: float = 0.0 - - def update_from_frame(self, frame: dict): - self.node_id = frame.get("node_id", self.node_id) - self.sequence = frame.get("sequence", 0) - self.timestamp_ms = frame.get("timestamp", 0) - self.target_count = frame.get("target_count", 0) - now = time.time() - - targets_data = frame.get("targets", []) - for t_data in targets_data: - slot = t_data.get("id", 0) - if 1 <= slot <= 3: - t = self.targets[slot - 1] - t.slot = slot - t.valid = t_data.get("valid", False) - t.x_mm = t_data.get("x", 0) - t.y_mm = t_data.get("y", 0) - t.speed_cm_s = t_data.get("speed", 0) - t.distance_mm = float(t_data.get("distance", 0)) - t.angle_deg = float(t_data.get("angle", 0)) - if t.valid: - t.last_seen = now - - self.frames_received += 1 - self.last_update = now - - -# ============================================================ -# GUI 1: Tkinter (mặc định, có sẵn trên hầu hết hệ thống) -# ============================================================ -class TkinterVisualizer: - # Canvas size (pixel) - CANVAS_W = 900 - CANVAS_H = 700 - - # Radar coordinate range (mm) — map vào canvas - ROOM_X_MIN = -5000 # 5m bên trái radar - ROOM_X_MAX = 5000 # 5m bên phải - ROOM_Y_MIN = 0 # radar - ROOM_Y_MAX = 7000 # 7m hướng về phía trước - - # Colors - BG_COLOR = "#1e1e2e" - GRID_COLOR = "#313244" - AXIS_COLOR = "#89b4fa" - ZONE_COLORS = ["#a6e3a1", "#fab387", "#f38ba8", "#89dceb"] - ZONE_OCCUPIED_COLORS = ["#40a02b", "#fe640b", "#d20f39", "#04a5e5"] - TARGET_COLORS = ["#f9e2af", "#cba6f7", "#74c7ec"] - - def __init__(self, state: RadarState): - self.state = state - try: - import tkinter as tk - except ImportError: - print("Loi: tkinter khong co san. Hay cai: sudo apt install python3-tk") - sys.exit(1) - self.tk = tk - self.root = tk.Tk() - self.root.title(f"ESP32 LD2453 Radar — {state.node_id}") - self.root.configure(bg=self.BG_COLOR) - self.root.protocol("WM_DELETE_WINDOW", self._on_close) - self._running = True - - self._build_ui() - - def _build_ui(self): - # Header label - self.header_var = self.tk.StringVar(value="Dang cho du lieu...") - lbl = self.tk.Label( - self.root, textvariable=self.header_var, - font=("Consolas", 12, "bold"), - fg="#cdd6f4", bg=self.BG_COLOR, pady=8 - ) - lbl.pack(fill="x") - - # Canvas - self.canvas = self.tk.Canvas( - self.root, width=self.CANVAS_W, height=self.CANVAS_H, - bg=self.BG_COLOR, highlightthickness=0 - ) - self.canvas.pack(padx=10, pady=5) - - # Status bar - self.status_var = self.tk.StringVar(value="Frames: 0") - self.tk.Label( - self.root, textvariable=self.status_var, - font=("Consolas", 10), - fg="#a6adc8", bg=self.BG_COLOR, pady=6 - ).pack(fill="x") - - def _mm_to_pixel(self, x_mm: int, y_mm: int) -> Tuple[float, float]: - """Map coordinate radar -> canvas pixel. - Radar goc toa do: radar o giua canh duoi. Tang Y la huong vao phia truoc. - """ - x_ratio = (x_mm - self.ROOM_X_MIN) / (self.ROOM_X_MAX - self.ROOM_X_MIN) - # Dao Y: canvas (0,0) o goc tren, room Y=0 o goc duoi (radar) - y_ratio = 1.0 - (y_mm - self.ROOM_Y_MIN) / (self.ROOM_Y_MAX - self.ROOM_Y_MIN) - # De lai margin - margin_x = 60 - margin_y = 60 - px = margin_x + x_ratio * (self.CANVAS_W - 2 * margin_x) - py = margin_y + y_ratio * (self.CANVAS_H - 2 * margin_y) - return px, py - - def _draw_background(self): - c = self.canvas - # Grid lines every 1m - for x_mm in range(self.ROOM_X_MIN, self.ROOM_X_MAX + 1, 1000): - x1, y1 = self._mm_to_pixel(x_mm, self.ROOM_Y_MIN) - x2, y2 = self._mm_to_pixel(x_mm, self.ROOM_Y_MAX) - c.create_line(x1, y1, x2, y2, fill=self.GRID_COLOR) - if x_mm != 0: - c.create_text(x1, self.CANVAS_H - 30, text=f"{x_mm//1000}m", fill="#6c7086", font=("Consolas", 8)) - for y_mm in range(self.ROOM_Y_MIN, self.ROOM_Y_MAX + 1, 1000): - x1, y1 = self._mm_to_pixel(self.ROOM_X_MIN, y_mm) - x2, y2 = self._mm_to_pixel(self.ROOM_X_MAX, y_mm) - c.create_line(x1, y1, x2, y2, fill=self.GRID_COLOR) - c.create_text(25, y1, text=f"{y_mm//1000}m", fill="#6c7086", font=("Consolas", 8)) - - # Radar position (center bottom edge) - rx, ry = self._mm_to_pixel(0, 0) - c.create_text(rx, ry + 25, text="RADAR", fill="#f38ba8", font=("Consolas", 10, "bold")) - c.create_oval(rx - 8, ry - 8, rx + 8, ry + 8, fill="#f38ba8", outline="#f38ba8") - - # Y axis line - ax, ay = self._mm_to_pixel(0, self.ROOM_Y_MAX) - c.create_line(rx, ry, ax, ay, fill=self.AXIS_COLOR, dash=(4, 2)) - - def _draw_zones(self, zones: List[Zone]): - c = self.canvas - for i, z in enumerate(zones): - x1, y1 = self._mm_to_pixel(z.x1, z.y1) - x2, y2 = self._mm_to_pixel(z.x2, z.y2) - # Tkinter can x1<=x2, y1<=y2 - px1, px2 = sorted([x1, x2]) - py1, py2 = sorted([y1, y2]) - color = self.ZONE_OCCUPIED_COLORS[i] if z.occupied else self.ZONE_COLORS[i] - stipple = "gray25" if not z.occupied else "" - c.create_rectangle( - px1, py1, px2, py2, - outline=color, width=2, fill=color, stipple=stipple - ) - # Zone label - lx = (px1 + px2) / 2 - ly = (py1 + py2) / 2 - status = "CO" if z.occupied else "TRONG" - c.create_text( - lx, ly, - text=f"Zone{z.id}\n{status}", - fill="#1e1e2e" if z.occupied else "#45475a", - font=("Consolas", 10, "bold") - ) - - def _draw_targets(self, targets: List[Target]): - c = self.canvas - now = time.time() - for i, t in enumerate(targets): - if not t.valid: - # Show faded marker if recently seen - if now - t.last_seen < 3.0: - px, py = self._mm_to_pixel(t.x_mm, t.y_mm) - c.create_oval(px - 5, py - 5, px + 5, py + 5, outline="#585b70", dash=(2, 2)) - continue - - px, py = self._mm_to_pixel(t.x_mm, t.y_mm) - color = self.TARGET_COLORS[i] - - # Target dot - r = 8 - c.create_oval(px - r, py - r, px + r, py + r, fill=color, outline="white", width=2) - - # Velocity vector (speed) - if t.speed_cm_s != 0: - scale = 2.0 # pixel per cm/s - dx = (t.x_mm / max(1, t.distance_mm)) * t.speed_cm_s * scale if t.distance_mm else 0 - dy = (t.y_mm / max(1, t.distance_mm)) * t.speed_cm_s * scale if t.distance_mm else 0 - c.create_line(px, py, px + dx, py - dy, fill=color, width=2, arrow=self.tk.LAST) - - # Label - label = f"T{t.slot}\n{abs(t.x_mm)}, {t.y_mm}mm\n{abs(t.speed_cm_s)}cm/s" - c.create_text( - px + 15, py - 25, anchor="w", - text=label, fill="white", - font=("Consolas", 9, "bold"), - tags=f"tlabel{t.slot}" - ) - - def draw(self): - self.canvas.delete("all") - self._draw_background() - self._draw_zones(self.state.zones) - self._draw_targets(self.state.targets) - - s = self.state - self.header_var.set( - f"Node: {s.node_id} | Sequence: {s.sequence} | Targets: {s.target_count}/3" - ) - self.status_var.set( - f"Frames received: {s.frames_received} | " - f"Last update: {time.time() - s.last_update:.1f}s ago" - ) - - def _on_close(self): - self._running = False - self.root.destroy() - - def should_run(self) -> bool: - return self._running - - def update(self): - """Call periodically from main loop.""" - if self._running: - self.draw() - self.root.update_idletasks() - self.root.update() - - -# ============================================================ -# Console Headless Mode -# ============================================================ -class ConsoleVisualizer: - def __init__(self, state: RadarState): - self.state = state - self._last_print = 0.0 - - def update(self): - now = time.time() - if now - self._last_print < 0.5: - return - self._last_print = now - - s = self.state - print("\033[2J\033[H", end="") # Clear screen - print("=" * 60) - print(f" ESP32 LD2453 — Node: {s.node_id} Seq: {s.sequence}") - print("=" * 60) - print(f" Frames received: {s.frames_received}") - print(f" Target count : {s.target_count}/3") - if s.last_update: - print(f" Last update : {now - s.last_update:.1f}s ago") - print("-" * 60) - for t in s.targets: - if t.valid: - status = "DETECTED" - elif now - t.last_seen < 3.0: - status = f"RECENT ({now-t.last_seen:.0f}s)" - else: - status = "FREE" - print(f" Slot {t.slot}: {status}") - if t.valid: - print(f" X={t.x_mm:5d}mm Y={t.y_mm:5d}mm " - f"Speed={t.speed_cm_s:+3d}cm/s " - f"Dist={int(t.distance_mm):5d}mm " - f"Angle={t.angle_deg:+.1f}°") - print("-" * 60) - for z in s.zones: - occ = "X CO" if z.occupied else " TRONG" - print(f" Zone {z.id}: {occ} [{z.x1:+5d},{z.y1:+5d}] -> [{z.x2:+5d},{z.y2:+5d}]") - print("=" * 60) - - def should_run(self) -> bool: - return True - - def draw(self): - pass - - -# ============================================================ -# Fetch zones from ESP32 REST API -# ============================================================ -async def fetch_zones(state: RadarState, esp32_host: str): - try: - import aiohttp - except ImportError: - print("Canh bao: aiohttp chua cai. Zone se dung default. (pip install aiohttp)") - return - - url = f"http://{esp32_host}/zones" - try: - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session: - async with session.get(url) as resp: - if resp.status == 200: - data = await resp.json() - for z_data in data: - idx = z_data.get("id", 0) - if 1 <= idx <= 3: - z = state.zones[idx - 1] - z.id = idx - z.x1 = int(z_data.get("x1", z.x1)) - z.y1 = int(z_data.get("y1", z.y1)) - z.x2 = int(z_data.get("x2", z.x2)) - z.y2 = int(z_data.get("y2", z.y2)) - z.occupied = bool(z_data.get("occupied", False)) - print(f"Da lay {len(data)} zone tu ESP32") - else: - print(f"GET zones tra ve status {resp.status}") - except Exception as e: - print(f"Khong the lay zones: {e}") - - -# ============================================================ -# Update zone occupancy from target positions -# (redundant — ESP32 already reports zones, but kept for validation) -# ============================================================ -def update_zone_occupancy_from_targets(state: RadarState): - for z in state.zones: - z.occupied = False - for t in state.targets: - if not t.valid: - continue - if z.x1 <= t.x_mm <= z.x2 and z.y1 <= t.y_mm <= z.y2: - z.occupied = True - break - - -# ============================================================ -# Main async loop -# ============================================================ -async def ws_client_loop(esp32_host: str, visualizer, state: RadarState): - uri = f"ws://{esp32_host}/ws" - reconnect_delay = 2.0 - - while visualizer.should_run(): - print(f"Dang ket noi den {uri}...") - try: - async with websockets.connect(uri, ping_interval=30, ping_timeout=10) as ws: - print(f"Da ket noi WebSocket den ESP32 ({esp32_host})") - # Fetch zones one time after connect - await fetch_zones(state, esp32_host) - - async for message in ws: - if not visualizer.should_run(): - break - try: - frame = json.loads(message) - state.update_from_frame(frame) - update_zone_occupancy_from_targets(state) - visualizer.update() - except json.JSONDecodeError: - print(f"Loi parse JSON: {message[:80]}...") - continue - # Graceful disconnect - print("WebSocket dong. Dang ket noi lai...") - except websockets.exceptions.ConnectionClosed as e: - print(f"Mat ket noi WebSocket ({e}), dang thu lai sau {reconnect_delay}s...") - await asyncio.sleep(reconnect_delay) - except OSError as e: - print(f"Khong the ket noi: {e}. Thu lai sau {reconnect_delay}s...") - await asyncio.sleep(reconnect_delay) - except KeyboardInterrupt: - print("Dung lai...") - break - except Exception as e: - print(f"Loi WebSocket: {type(e).__name__}: {e}") - await asyncio.sleep(reconnect_delay) - - -def main(): - parser = argparse.ArgumentParser(description="ESP32 LD2453 Radar Visualizer") - parser.add_argument("host", help="ESP32 IP address (e.g. 192.168.1.100)") - parser.add_argument("--headless", action="store_true", help="Chi in ra console, khong can GUI") - parser.add_argument("--mode", choices=["tkinter", "pygame"], default="tkinter", - help="Loaị GUI (mac dinh: tkinter)") - args = parser.parse_args() - - state = RadarState() - # Default zones (giong ESP32) - state.zones[0] = Zone(1, x1=-4000, y1=1, x2=-1, y2=4000) - state.zones[1] = Zone(2, x1=1, y1=1, x2=4000, y2=4000) - state.zones[2] = Zone(3, x1=-4001, y1=4001, x2=4001, y2=6000) - - if args.headless: - vis = ConsoleVisualizer(state) - elif args.mode == "pygame": - print("Pygame mode chua implement, chuyen sang tkinter...") - vis = TkinterVisualizer(state) - else: - vis = TkinterVisualizer(state) - - print(f"ESP32-S3 LD2453 Radar Visualizer") - print(f"ESP32 host: {args.host}") - print(f"Mode: {'headless console' if args.headless else 'tkinter GUI'}") - - try: - asyncio.run(ws_client_loop(args.host, vis, state)) - except KeyboardInterrupt: - print("\nThoat.") - - -if __name__ == "__main__": - main() diff --git a/orangepi-server/requirements.txt b/orangepi-server/requirements.txt new file mode 100644 index 0000000..9d041a6 --- /dev/null +++ b/orangepi-server/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +websockets>=11.0 +aiohttp>=3.8.0 diff --git a/orangepi-server/static/app.js b/orangepi-server/static/app.js new file mode 100644 index 0000000..74f412d --- /dev/null +++ b/orangepi-server/static/app.js @@ -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 = ` +
T${t.slot}
+
+ ${t(statusKey)} +
+
${t("x_coord")}: ${t.x_mm} mm
+
${t("y_coord")}: ${t.y_mm} mm
+
${t("speed")}: ${speedTxt} cm/s
+
${t("distance")}: ${distanceKm} m
+
${t("angle")}: ${angleTxt}
+
+
`; + 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 = ` +
+
+
${t("zone_"+(idx+1))} · Z${z.id}
+
[${z.x1}, ${z.y1}] → [${z.x2}, ${z.y2}]
+
+
${z.occupied ? t("occupied") : t("empty")}
+ `; + 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 = ` + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
`; + 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(); +}); diff --git a/orangepi-server/static/style.css b/orangepi-server/static/style.css new file mode 100644 index 0000000..375e2c2 --- /dev/null +++ b/orangepi-server/static/style.css @@ -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); } } diff --git a/orangepi-server/templates/index.html b/orangepi-server/templates/index.html new file mode 100644 index 0000000..e60ec63 --- /dev/null +++ b/orangepi-server/templates/index.html @@ -0,0 +1,197 @@ + + + + + + ESP32 LD2453 — Radar Web Visualizer + + + + + + + + +
+
+ +
+ +
+ +
+ + ESP32: Đang kết nối… +
+
+ + WS: Đang kết nối… +
+ + + + + + +
+
+ + + + +
+ + +
+
+

Radar View

+
+ + + +
+
+ +
+ + + +
+
Chú thích
+
+ + Target 1 (Slot 1) +
+
+ + Target 2 (Slot 2) +
+
+ + Target 3 (Slot 3) +
+
+
+ + Zone 1 +
+
+ + Zone 2 +
+
+ + Zone 3 +
+
+ + +
+ RADAR_A + | + Seq: 0 + | + Targets: 0/3 +
+
+
+ + +
+ + +
+

Thống kê

+
+
+
Frames nhận
+
0
+
+
+
Cập nhật cách đây
+
—s
+
+
+
Target hợp lệ
+
0 / 3
+
+
+
Vùng có người
+
0 / 3
+
+
+
+ + +
+

Chi tiết Target

+
+ +
+
+ + +
+
+

Zones

+ +
+
+ + + +
+
+ +
+ + + + +
+ + + + + + + diff --git a/orangepi-server/visualizer.py b/orangepi-server/visualizer.py new file mode 100644 index 0000000..99ca17e --- /dev/null +++ b/orangepi-server/visualizer.py @@ -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 # 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://: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://:{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()