refactor code with orangepi-server

This commit is contained in:
admin committed 2026-09-11 15:54:10 +07:00
1 parent 37b551c0ed
commit 649edec6ca
14 files changed
+680

No files matched your search

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