6 Commits
15 changed files with 3835 additions and 608 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ __pycache__/
.DS_Store
UGV01MCU_V0.9/build/
certs/
+112 -49
View File
@@ -1,89 +1,148 @@
# UGV01
UGV01 履带差速小车串口控制工程,包含串口控制、手机优先的 Web 遥控界面、Wi‑Fi AP 接入、车辆校正与开机自启部署能力。
UGV01 履带差速小车串口控制工程,提供底盘协议封装、手机优先的 Web 遥控界面、实时反馈显示、Wi‑Fi AP 接入、低延迟 FPV 视频、RTK/WTB 状态接入和 systemd 自启部署。
当前环境确认:
## 环境
- 串口: `/dev/ttyS7` (Pi 上的 `uart7`)
- 默认波特率: `115200`
- 底盘串口: 优先使用 USB `CP2102N`,自动回退到 `/dev/ttyUSB0` / `/dev/ttyS7`
- 波特率: `115200`
- Python: `/opt/miniconda3/envs/py310/bin/python`
- 仓库: `http://107.173.157.41:3000/clear/ugv01`
- Release: `http://107.173.157.41:3000/clear/ugv01/releases`
## 当前版本
- 当前发布版本: `1.2.2`
- 控制方式: 基于底盘协议 `T=1` 的差速控制
- Web 界面: 手机横屏优先的双摇杆控制台
- FPV 方式: UVC 双目相机左视图实时视频
- RTK 模式: `WTB2`
## 功能
- 左右轮速度控制 `T=1`
- 左右轮闭环速度控制 `T=1`
- 左右电机 PWM 控制 `T=11`
- ROS 风格速度控制 `T=13`
- OLED 文本设置与恢复 `T=3`
- 获取 IMU 数据 `T=126`
- 获取底盘信息反馈 `T=130`
- 获取底盘反馈 `T=130`
- 串口连续反馈开关 `T=131`
- 串口回声开关 `T=143`
- 发送任意原始 JSON 指令
- 手机优先的 Web 双摇杆控制台
- AP 模式接入:`Clear_UGV01`
- 电压 / 3S 电池百分比显示
- 长按摇杆持续续发控制心跳
## Release
- Current release: `1.0.1`
- Repository: `http://107.173.157.41:3000/clear/ugv01`
- Release page: `http://107.173.157.41:3000/clear/ugv01/releases`
- 手机 Web 双摇杆遥控
- 高 / 中 / 低三挡速度切换
- 原地转向支持
- WebSocket 实时控制与控制心跳续发
- 页面切后台、断线、松手自动停车
- MCU 连续反馈流接入
- 顶部状态条显示在线状态与 3S 电池估算百分比
- 车载 AP 接入: `Clear_UGV01`
- 浏览器端 Xbox 手柄支持
- HTTPS 访问支持与本地 CA 证书下载
- 双目 UVC 相机左视图 FPV
- 相机原始参数与压缩参数通过 `camera_config.json` 配置
- FPV 优先走 WebRTC 低延迟视频,失败时回退 MJPEG
- FPV 画面内集成透明双摇杆、边界引导线与全屏按钮
- Web 端新增 Tailscale IP 状态卡片
- 顶部支持视频模式 `WebRTC / MJPEG` 手动切换
- 顶部支持速度挡位切换与控制权切换
- 控制权支持 `模拟摇杆 / 远程手柄 / WTB`
- 接入 RTK 综合数据与独立 WTB 路线状态卡片
## Web 控制
启动 Web 服务:
启动:
```bash
/opt/miniconda3/envs/py310/bin/python /root/clear_tmp/ugv01/run_web.py
```
然后在手机浏览器打开:
浏览器访问:
```text
http://树莓派IP:8000
https://树莓派IP:8000
```
如果通过车载 AP 接入:
通过车载 AP 访问:
```text
SSID: Clear_UGV01
Password: Bone1644
URL: http://192.168.50.1:8000
URL: https://192.168.50.1:8000
```
Web 控制特性:
STA 网络访问示例:
- 左摇杆控制方向
```text
https://192.168.101.177:8000
```
本地 CA 证书下载:
```text
https://192.168.50.1:8000/ca.crt
```
界面特点:
- 顶部 FPV 视频窗口显示双目相机左视图
- 左摇杆控制方向,可原地转向
- 右摇杆控制油门
- 油门决定速度上限,方向控制不会再把车速额外抬高
- 油门接近零时,方向摇杆可触发原地掉头
- 高速 / 中速 / 低速三挡切换
- WebSocket 实时控制
- 摇杆长按时持续发送控制心跳,避免 MCU 超时自动停车
- 页面切后台、断线、松手自动停车
- 后端带串口互斥与超时看门狗,适合持续控制
- 顶部显示电压与 3S 电池估算百分比
- 单按钮循环切换低速 / 中速 / 高速
- 顶部状态条显示连接、电量、视频模式与手柄状态
- 顶部状态条集成视频模式、速度挡位与控制权切换
- 布局针对手机横屏收紧优化
- 底部提供 AP / STA 开启、关闭与参数修改卡片
- AP / STA 设置卡片显示当前 IP 地址
- 底部新增 Tailscale IP 卡片
- 支持检测并接入 Xbox 手柄
- 手柄只映射两个摇杆:左摇杆方向,右摇杆油门
- WTB 卡片显示电机速度、映射速度、路线点与状态位
- RTK 卡片显示 GPS、姿态、角速度、加速度与航点信息
systemd 服务模板:
## FPV 相机
- [ugv01-web.service](/root/clear_tmp/ugv01/ugv01-web.service)
当前默认按以下参数打开双目 UVC 相机:
系统服务:
- `ugv01-web.service`: Web 控制服务
- `ugv01-ap.service`: 并发 AP 服务
## 快速开始
```bash
/opt/miniconda3/envs/py310/bin/python /root/clear_tmp/ugv01/cli.py imu
```text
原始输入: 2560x720 @ 30fps, MJPG
左视图裁切: 1280x720
推流输出: 640x360 @ 20fps
```
```bash
/opt/miniconda3/envs/py310/bin/python /root/clear_tmp/ugv01/cli.py speed --left 0.05 --right 0.05 --duration 0.5
页面中仅显示左视图作为 FPV。浏览器支持时,页面会优先建立 WebRTC 视频通道以降低延迟,失败时自动回退到 MJPEG。
相机配置文件:
```text
/root/clear_tmp/ugv01/camera_config.json
```
当前配置项:
- `device`: 相机设备节点
- `input.width / height / fps / format`: 原始采集参数
- `left_view.x / y / width / height`: 左视图裁切区域
- `stream.width / height / fps / jpeg_quality`: 压缩后推流参数
调试接口:
```text
/api/camera/status
/video/fpv.mjpg
```
视频模式:
- `WebRTC`: 默认低延迟视频模式
- `MJPEG`: 兼容模式,可在顶部直接切换
## systemd
- Web 服务模板: [ugv01-web.service](/root/clear_tmp/ugv01/ugv01-web.service)
- 系统服务:
- `ugv01-web.service`
- `ugv01-ap.service`
## 常用命令
读取 IMU:
@@ -98,7 +157,7 @@ systemd 服务模板:
/opt/miniconda3/envs/py310/bin/python /root/clear_tmp/ugv01/cli.py base-feedback
```
左右轮闭环速度控制:
左右轮速度控制:
```bash
/opt/miniconda3/envs/py310/bin/python /root/clear_tmp/ugv01/cli.py speed --left 0.10 --right 0.10 --duration 1.0
@@ -124,7 +183,11 @@ ROS 风格控制:
## 说明
- 电机控制默认不会自动读取返回值,因为这类控制指令通常没有应答。
- `speed` 和 `ros` 支持 `--duration`,到时后会自动发送停车指令,方便测试。
- 如果后续需要做业务集成,可以直接导入 `ugv01.controller.UGV01Controller`。
- 当前电量百分比为基于 `3S / 12.6V 满电` 的估算值,MCU 原始反馈字段是电压 `v`。
- Web 主界面显示的是精简后的关键信息;如需完整 IMU 或底盘原始字段,可通过接口继续扩展。
- `speed` 和 `ros` 支持 `--duration`,到时后会自动发送停车指令。
- AP / STA 设置页默认不回显现有密码;密码框留空表示保持原密码不变。
- HTTPS 当前使用本地 CA 签发的证书;若希望浏览器将其视为受信任来源,需要安装并信任 `ca.crt`。
- 相机采集采用独立后台线程,控制、反馈和网络设置仍保持原有服务链路。
- 底盘 MCU 自带 AP 已通过命令关闭,并设置为开机关闭。
- 已清理调试期加入的黑匣子、异常抓拍与调试接口,保留正式运行逻辑。
+21
View File
@@ -0,0 +1,21 @@
{
"device": "/dev/video0",
"input": {
"width": 2560,
"height": 720,
"fps": 30,
"format": "MJPG"
},
"left_view": {
"x": 0,
"y": 0,
"width": 1280,
"height": 720
},
"stream": {
"width": 640,
"height": 360,
"fps": 20,
"jpeg_quality": 50
}
}
+24 -1
View File
@@ -1,6 +1,29 @@
#!/opt/miniconda3/envs/py310/bin/python
from pathlib import Path
import uvicorn
if __name__ == "__main__":
uvicorn.run("ugv01.webapp:app", host="0.0.0.0", port=8000, reload=False)
base_dir = Path(__file__).resolve().parent
cert_dir = base_dir / "certs"
cert_file = cert_dir / "ugv01-server.crt"
key_file = cert_dir / "ugv01-server.key"
kwargs = {
"app": "ugv01.webapp:app",
"host": "0.0.0.0",
"port": 8000,
"reload": False,
"workers": 1,
"timeout_keep_alive": 5,
"ws_ping_interval": 10.0,
"ws_ping_timeout": 10.0,
}
if cert_file.exists() and key_file.exists():
kwargs["ssl_certfile"] = str(cert_file)
kwargs["ssl_keyfile"] = str(key_file)
uvicorn.run(
**kwargs,
)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/root/clear_tmp/ugv01"
CERT_DIR="${ROOT_DIR}/certs"
CA_KEY="${CERT_DIR}/ugv01-root-ca.key"
CA_CERT="${CERT_DIR}/ugv01-root-ca.crt"
SERVER_KEY="${CERT_DIR}/ugv01-server.key"
SERVER_CSR="${CERT_DIR}/ugv01-server.csr"
SERVER_CERT="${CERT_DIR}/ugv01-server.crt"
SERVER_EXT="${CERT_DIR}/server-ext.cnf"
mkdir -p "${CERT_DIR}"
chmod 700 "${CERT_DIR}"
IP_LIST=()
while read -r _iface cidr; do
ip="${cidr%%/*}"
if [[ -n "${ip}" ]]; then
IP_LIST+=("${ip}")
fi
done < <(ip -4 -o addr show up scope global | awk '{print $2, $4}')
{
echo "authorityKeyIdentifier=keyid,issuer"
echo "basicConstraints=CA:FALSE"
echo "keyUsage = digitalSignature, keyEncipherment"
echo "extendedKeyUsage = serverAuth"
echo "subjectAltName = @alt_names"
echo
echo "[alt_names]"
echo "DNS.1 = localhost"
echo "DNS.2 = orangepi4pro"
echo "IP.1 = 127.0.0.1"
index=2
for ip in "${IP_LIST[@]}"; do
echo "IP.${index} = ${ip}"
index=$((index + 1))
done
} > "${SERVER_EXT}"
if [[ ! -f "${CA_KEY}" || ! -f "${CA_CERT}" ]]; then
openssl genrsa -out "${CA_KEY}" 4096
openssl req -x509 -new -nodes -key "${CA_KEY}" -sha256 -days 3650 \
-subj "/CN=UGV01 Local Root CA" \
-out "${CA_CERT}"
fi
openssl genrsa -out "${SERVER_KEY}" 2048
openssl req -new -key "${SERVER_KEY}" \
-subj "/CN=UGV01 HTTPS" \
-out "${SERVER_CSR}"
openssl x509 -req -in "${SERVER_CSR}" \
-CA "${CA_CERT}" -CAkey "${CA_KEY}" -CAcreateserial \
-out "${SERVER_CERT}" -days 825 -sha256 \
-extfile "${SERVER_EXT}"
chmod 600 "${CA_KEY}" "${SERVER_KEY}"
chmod 644 "${CA_CERT}" "${SERVER_CERT}"
echo "CA cert: ${CA_CERT}"
echo "Server cert: ${SERVER_CERT}"
+272
View File
@@ -0,0 +1,272 @@
import json
import threading
import time
from pathlib import Path
from typing import Any
import cv2
def _decode_fourcc(value: float) -> str:
code = int(value)
if code <= 0:
return ""
return "".join(chr((code >> shift) & 0xFF) for shift in (0, 8, 16, 24)).strip()
class CameraConfigError(RuntimeError):
pass
class UGV01CameraService:
def __init__(self, config_path: str | Path) -> None:
self.config_path = Path(config_path)
self.config = self._load_config()
self._stop_event = threading.Event()
self._capture_thread: threading.Thread | None = None
self._encode_thread: threading.Thread | None = None
self._frame_condition = threading.Condition()
self._state_lock = threading.Lock()
self._latest_left_frame = None
self._left_seq = 0
self._latest_jpeg: bytes | None = None
self._latest_jpeg_at = 0.0
self._last_error = ""
self._device_open = False
self._actual_input = {
"width": None,
"height": None,
"fps": None,
"format": "",
}
self._actual_stream = {
"width": None,
"height": None,
"fps": None,
"jpeg_quality": self.config["stream"]["jpeg_quality"],
}
def start(self) -> None:
with self._state_lock:
if self._capture_thread and self._capture_thread.is_alive():
return
self._stop_event.clear()
self._capture_thread = threading.Thread(
target=self._capture_loop,
name="ugv01-camera-capture",
daemon=True,
)
self._encode_thread = threading.Thread(
target=self._encode_loop,
name="ugv01-camera-encode",
daemon=True,
)
self._capture_thread.start()
self._encode_thread.start()
def shutdown(self) -> None:
self._stop_event.set()
with self._frame_condition:
self._frame_condition.notify_all()
if self._capture_thread:
self._capture_thread.join(timeout=2.0)
if self._encode_thread:
self._encode_thread.join(timeout=2.0)
def snapshot(self) -> dict[str, Any]:
with self._state_lock:
return {
"ok": self._latest_jpeg is not None,
"device_open": self._device_open,
"config": self.config,
"input_actual": dict(self._actual_input),
"stream_actual": dict(self._actual_stream),
"frame_ready": self._latest_jpeg is not None,
"frame_updated_at": self._latest_jpeg_at,
"last_error": self._last_error,
}
def wait_for_frame(self, last_timestamp: float, timeout: float = 1.0) -> tuple[bytes | None, float]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and not self._stop_event.is_set():
with self._state_lock:
if self._latest_jpeg is not None and self._latest_jpeg_at > last_timestamp:
return self._latest_jpeg, self._latest_jpeg_at
time.sleep(0.01)
with self._state_lock:
return self._latest_jpeg, self._latest_jpeg_at
def wait_for_left_frame(self, last_seq: int, timeout: float = 1.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and not self._stop_event.is_set():
with self._frame_condition:
self._frame_condition.wait_for(
lambda: self._left_seq != last_seq or self._stop_event.is_set(),
timeout=0.1,
)
if self._left_seq != last_seq and self._latest_left_frame is not None:
return self._latest_left_frame.copy(), self._left_seq
with self._state_lock:
if self._latest_left_frame is None:
return None, last_seq
return self._latest_left_frame.copy(), self._left_seq
def _capture_loop(self) -> None:
while not self._stop_event.is_set():
capture = None
try:
capture = self._open_capture()
consecutive_failures = 0
while not self._stop_event.is_set():
ok, frame = capture.read()
if not ok or frame is None:
consecutive_failures += 1
self._set_error(f"camera read failed ({consecutive_failures})")
if consecutive_failures >= 5:
break
time.sleep(0.04)
continue
consecutive_failures = 0
left = self._crop_left_view(frame, self.config["left_view"])
if left is None:
self._set_error("left view crop failed")
continue
with self._frame_condition:
self._latest_left_frame = left
self._left_seq += 1
self._frame_condition.notify_all()
except Exception as exc:
self._set_error(str(exc))
time.sleep(1.0)
finally:
if capture is not None:
capture.release()
with self._state_lock:
self._device_open = False
time.sleep(0.3)
def _encode_loop(self) -> None:
last_seq = 0
last_encoded_at = 0.0
stream_interval = 1.0 / max(1, int(self.config["stream"]["fps"]))
output = self.config["stream"]
jpeg_quality = int(output["jpeg_quality"])
while not self._stop_event.is_set():
with self._frame_condition:
self._frame_condition.wait_for(
lambda: self._left_seq != last_seq or self._stop_event.is_set(),
timeout=0.5,
)
if self._stop_event.is_set():
break
if self._left_seq == last_seq or self._latest_left_frame is None:
continue
left = self._latest_left_frame.copy()
last_seq = self._left_seq
now = time.monotonic()
if now - last_encoded_at < stream_interval:
continue
if left.shape[1] != output["width"] or left.shape[0] != output["height"]:
left = cv2.resize(left, (int(output["width"]), int(output["height"])), interpolation=cv2.INTER_AREA)
ok, encoded = cv2.imencode(".jpg", left, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality])
if not ok:
self._set_error("jpeg encode failed")
continue
updated_at = time.time()
last_encoded_at = now
with self._state_lock:
self._latest_jpeg = encoded.tobytes()
self._latest_jpeg_at = updated_at
self._actual_stream["width"] = int(left.shape[1])
self._actual_stream["height"] = int(left.shape[0])
self._actual_stream["fps"] = self.config["stream"]["fps"]
self._actual_stream["jpeg_quality"] = jpeg_quality
self._last_error = ""
def _open_capture(self):
device = self.config["device"]
capture = cv2.VideoCapture(device, cv2.CAP_V4L2)
if not capture.isOpened():
capture.release()
raise CameraConfigError(f"failed to open camera: {device}")
fmt = self.config["input"]["format"]
if fmt:
fourcc = cv2.VideoWriter_fourcc(*fmt[:4])
capture.set(cv2.CAP_PROP_FOURCC, fourcc)
capture.set(cv2.CAP_PROP_FRAME_WIDTH, int(self.config["input"]["width"]))
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, int(self.config["input"]["height"]))
capture.set(cv2.CAP_PROP_FPS, int(self.config["input"]["fps"]))
capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
actual_width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
actual_height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
actual_fps = capture.get(cv2.CAP_PROP_FPS)
actual_format = _decode_fourcc(capture.get(cv2.CAP_PROP_FOURCC))
with self._state_lock:
self._device_open = True
self._actual_input["width"] = actual_width
self._actual_input["height"] = actual_height
self._actual_input["fps"] = round(actual_fps, 3) if actual_fps else None
self._actual_input["format"] = actual_format
self._last_error = ""
return capture
@staticmethod
def _crop_left_view(frame, crop: dict[str, int]):
y = int(crop["y"])
x = int(crop["x"])
height = int(crop["height"])
width = int(crop["width"])
if frame is None or frame.ndim < 2:
return None
if y < 0 or x < 0 or y + height > frame.shape[0] or x + width > frame.shape[1]:
return None
return frame[y : y + height, x : x + width]
def _load_config(self) -> dict[str, Any]:
if not self.config_path.exists():
raise CameraConfigError(f"camera config not found: {self.config_path}")
with self.config_path.open("r", encoding="utf-8") as handle:
config = json.load(handle)
self._validate_config(config)
return config
@staticmethod
def _validate_config(config: dict[str, Any]) -> None:
try:
input_cfg = config["input"]
left_cfg = config["left_view"]
stream_cfg = config["stream"]
input_width = int(input_cfg["width"])
input_height = int(input_cfg["height"])
input_fps = int(input_cfg["fps"])
left_x = int(left_cfg["x"])
left_y = int(left_cfg["y"])
left_width = int(left_cfg["width"])
left_height = int(left_cfg["height"])
if input_width <= 0 or input_height <= 0 or input_fps <= 0:
raise CameraConfigError("input width, height and fps must be positive")
if left_cfg["width"] <= 0 or left_cfg["height"] <= 0:
raise CameraConfigError("left_view width and height must be positive")
if left_x < 0 or left_y < 0 or (left_x + left_width) > input_width or (left_y + left_height) > input_height:
raise CameraConfigError("left_view crop must stay inside the input frame")
if stream_cfg["width"] <= 0 or stream_cfg["height"] <= 0 or stream_cfg["fps"] <= 0:
raise CameraConfigError("stream width, height and fps must be positive")
quality = int(stream_cfg["jpeg_quality"])
if quality < 30 or quality > 100:
raise CameraConfigError("jpeg_quality must be between 30 and 100")
except KeyError as exc:
raise CameraConfigError(f"missing camera config key: {exc}") from exc
def _set_error(self, message: str) -> None:
with self._state_lock:
self._last_error = message
+404 -144
View File
@@ -1,20 +1,38 @@
import json
from pathlib import Path
import threading
import time
from typing import Any
from typing import Callable
import serial
PREFERRED_UGV_PORTS = [
"/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_38c9f2697182ed11b27fb75f9d1cc348-if00-port0",
"/dev/ttyUSB0",
"/dev/ttyS7",
]
def resolve_ugv_port(port: str | None = None) -> str:
if port:
return port
for candidate in PREFERRED_UGV_PORTS:
if Path(candidate).exists():
return candidate
return "/dev/ttyS7"
class UGV01Controller:
def __init__(
self,
port: str = "/dev/ttyS7",
port: str | None = None,
baudrate: int = 115200,
timeout: float = 0.3,
write_timeout: float = 0.3,
timeout: float = 0.25,
write_timeout: float = 0.25,
) -> None:
self.port = port
self.port = resolve_ugv_port(port)
self.baudrate = baudrate
self.timeout = timeout
self.write_timeout = write_timeout
@@ -64,8 +82,8 @@ class UGV01Controller:
self,
payload: dict[str, Any],
*,
settle_time: float = 0.15,
read_window: float = 0.8,
settle_time: float = 0.12,
read_window: float = 0.4,
) -> list[Any]:
with self._lock:
ser = self._ensure_open()
@@ -76,30 +94,28 @@ class UGV01Controller:
time.sleep(settle_time)
chunks: list[bytes] = []
deadline = time.time() + read_window
while time.time() < deadline:
waiting = ser.in_waiting
data = ser.read(waiting or 1)
deadline = time.monotonic() + read_window
while time.monotonic() < deadline:
data = ser.read(ser.in_waiting or 1)
if data:
chunks.append(data)
else:
time.sleep(0.03)
time.sleep(0.02)
raw = b"".join(chunks).decode("utf-8", errors="replace")
return self._parse_lines(raw)
return self._parse_lines(b"".join(chunks).decode("utf-8", errors="replace"))
@staticmethod
def _parse_lines(raw: str) -> list[Any]:
result: list[Any] = []
items: list[Any] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
result.append(json.loads(line))
items.append(json.loads(line))
except json.JSONDecodeError:
result.append(line)
return result
items.append(line)
return items
@staticmethod
def _clip(value: float, low: float, high: float) -> float:
@@ -133,11 +149,14 @@ class UGV01Controller:
def ros_ctrl(self, x: float, z: float) -> None:
self.send({"T": 13, "X": x, "Z": z})
def oled_text(self, line_num: int, text: str) -> None:
self.send({"T": 3, "lineNum": self._clip_int(line_num, 0, 3), "Text": text})
def set_heartbeat_delay(self, delay_ms: int) -> None:
self.send({"T": 136, "cmd": max(0, int(delay_ms))})
def oled_restore(self) -> None:
self.send({"T": -3})
def serial_feedback(self, enabled: bool) -> None:
self.send({"T": 131, "cmd": 1 if enabled else 0})
def set_feedback_flow_interval(self, delay_ms: int) -> None:
self.send({"T": 142, "cmd": max(0, int(delay_ms))})
def imu(self) -> list[Any]:
return self.request({"T": 126})
@@ -145,15 +164,14 @@ class UGV01Controller:
def base_feedback(self) -> list[Any]:
return self.request({"T": 130})
def serial_feedback(self, enabled: bool) -> None:
self.send({"T": 131, "cmd": 1 if enabled else 0})
def serial_echo(self, enabled: bool) -> None:
self.send({"T": 143, "cmd": 1 if enabled else 0})
def set_heartbeat_delay(self, delay_ms: int) -> None:
self.send({"T": 136, "cmd": max(0, int(delay_ms))})
def read_available(self) -> list[Any]:
with self._lock:
ser = self._ensure_open()
waiting = ser.in_waiting
if waiting <= 0:
return []
raw = ser.read(waiting).decode("utf-8", errors="replace")
return self._parse_lines(raw)
class UGV01MotionService:
def __init__(
@@ -161,18 +179,21 @@ class UGV01MotionService:
controller: UGV01Controller,
*,
low_speed: float = 0.14,
medium_speed: float = 0.26,
high_speed: float = 0.40,
turn_gain: float = 0.75,
idle_timeout: float = 0.45,
min_send_interval: float = 0.06,
throttle_deadband: float = 0.06,
medium_speed: float = 0.24,
high_speed: float = 0.36,
steering_mix: float = 0.7,
throttle_deadband: float = 0.05,
steering_deadband: float = 0.05,
min_effective_speed: float = 0.095,
neutral_output_epsilon: float = 0.02,
motor_floor_entry_speed: float = 0.05,
pivot_threshold: float = 0.08,
pivot_turn_gain: float = 0.85,
min_drive_speed: float = 0.10,
pivot_speed_scale: float = 0.7,
drive_floor_ramp_threshold: float = 0.35,
steering_ramp_threshold: float = 0.25,
max_output_step: float = 0.045,
idle_timeout: float = 0.35,
heartbeat_interval: float = 0.05,
mcu_heartbeat_delay_ms: int = 800,
idle_feedback_interval_ms: int = 50,
active_feedback_interval_ms: int = 50,
) -> None:
self.controller = controller
self.speed_profiles = {
@@ -180,161 +201,400 @@ class UGV01MotionService:
"medium": medium_speed,
"high": high_speed,
}
self.turn_gain = turn_gain
self.idle_timeout = idle_timeout
self.min_send_interval = min_send_interval
self.steering_mix = steering_mix
self.throttle_deadband = throttle_deadband
self.steering_deadband = steering_deadband
self.min_effective_speed = min_effective_speed
self.neutral_output_epsilon = neutral_output_epsilon
self.motor_floor_entry_speed = motor_floor_entry_speed
self.pivot_threshold = pivot_threshold
self.pivot_turn_gain = pivot_turn_gain
self._state_lock = threading.Lock()
self._watchdog_stop = threading.Event()
self._watchdog_thread: threading.Thread | None = None
self.min_drive_speed = min_drive_speed
self.pivot_speed_scale = pivot_speed_scale
self.drive_floor_ramp_threshold = drive_floor_ramp_threshold
self.steering_ramp_threshold = steering_ramp_threshold
self.max_output_step = max_output_step
self.idle_timeout = idle_timeout
self.heartbeat_interval = heartbeat_interval
self.mcu_heartbeat_delay_ms = mcu_heartbeat_delay_ms
self.idle_feedback_interval_ms = idle_feedback_interval_ms
self.active_feedback_interval_ms = active_feedback_interval_ms
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._worker: threading.Thread | None = None
self._last_payload = (0.0, 0.0)
self._last_command_at = 0.0
self._last_send_at = 0.0
self._last_payload: tuple[float, float] | None = None
self._stopped = True
self._control_source = "idle"
self._control_owner: str | None = None
self._last_gear = "medium"
self._last_steering = 0.0
self._last_throttle = 0.0
self._feedback_flow_interval_ms = idle_feedback_interval_ms
def start(self) -> None:
self.controller.open()
with self._state_lock:
if self._watchdog_thread and self._watchdog_thread.is_alive():
self.controller.set_heartbeat_delay(self.mcu_heartbeat_delay_ms)
self._set_feedback_flow_interval(self.idle_feedback_interval_ms)
with self._lock:
if self._worker and self._worker.is_alive():
return
self._watchdog_stop.clear()
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
name="ugv01-motion-watchdog",
self._stop_event.clear()
self._worker = threading.Thread(
target=self._heartbeat_loop,
name="ugv01-heartbeat",
daemon=True,
)
self._watchdog_thread.start()
self._worker.start()
def shutdown(self) -> None:
self._watchdog_stop.set()
thread = self._watchdog_thread
if thread:
thread.join(timeout=1.0)
self._stop_event.set()
worker = self._worker
if worker:
worker.join(timeout=1.0)
self.force_stop()
self.controller.close()
def force_stop(self) -> None:
with self._state_lock:
with self._lock:
self.controller.stop()
self._last_payload = (0.0, 0.0)
self._last_send_at = time.monotonic()
self._last_command_at = self._last_send_at
self._last_command_at = time.monotonic()
self._stopped = True
self._control_source = "idle"
self._control_owner = None
self._last_steering = 0.0
self._last_throttle = 0.0
self._sync_feedback_flow_interval_locked()
def apply_joystick(self, steering: float, throttle: float, gear: str) -> dict[str, float | str]:
def stop_if_owner(self, owner: str) -> None:
with self._lock:
if self._control_owner != owner:
return
self.force_stop()
def set_preferred_gear(self, gear: str) -> dict[str, Any]:
gear_name = gear if gear in self.speed_profiles else "medium"
with self._lock:
self._last_gear = gear_name
return self.status()
def apply_joystick(self, steering: float, throttle: float, gear: str, owner: str) -> dict[str, float | str]:
payload = self._compute_payload(steering, throttle, gear)
with self._lock:
now = time.monotonic()
if (
self._control_source == "web"
and self._control_owner is not None
and self._control_owner != owner
and not self._stopped
and (now - self._last_command_at) < self.idle_timeout
):
return {
"gear": gear if gear in self.speed_profiles else "medium",
"steering": steering,
"throttle": throttle,
"left": self._last_payload[0],
"right": self._last_payload[1],
"accepted": False,
}
left, right = self._slew_limit(payload["left"], payload["right"])
self.controller.speed(left, right)
self._last_payload = (left, right)
self._last_command_at = now
self._stopped = left == 0.0 and right == 0.0
self._control_source = "web" if not self._stopped else "idle"
self._control_owner = owner if not self._stopped else None
self._last_gear = payload["gear"]
self._last_steering = float(payload["steering"])
self._last_throttle = float(payload["throttle"])
self._sync_feedback_flow_interval_locked()
payload["left"] = left
payload["right"] = right
payload["accepted"] = True
return payload
def apply_wtb(self, left_norm: float, right_norm: float, gear: str) -> dict[str, float | str]:
gear_name = gear if gear in self.speed_profiles else "medium"
max_speed = self.speed_profiles[gear_name]
steering = self._clip(steering, -1.0, 1.0)
throttle = self._clip(throttle, -1.0, 1.0)
steering = self._apply_axis_deadband(steering, self.steering_deadband)
throttle = self._apply_axis_deadband(throttle, self.throttle_deadband)
left, right = self._mix_drive(steering, throttle, max_speed)
left = self._clip(left, -0.5, 0.5)
right = self._clip(right, -0.5, 0.5)
if abs(left) <= self.neutral_output_epsilon:
left = 0.0
if abs(right) <= self.neutral_output_epsilon:
right = 0.0
left = self._apply_motor_floor(left)
right = self._apply_motor_floor(right)
payload = (round(left, 3), round(right, 3))
now = time.monotonic()
with self._state_lock:
self._last_command_at = now
should_send = (
self._last_payload != payload
or (now - self._last_send_at) >= self.min_send_interval
)
if should_send:
self.controller.speed(payload[0], payload[1])
self._last_payload = payload
self._last_send_at = now
self._stopped = payload == (0.0, 0.0)
requested_left = round(self._clip(left_norm, -1.0, 1.0) * max_speed, 3)
requested_right = round(self._clip(right_norm, -1.0, 1.0) * max_speed, 3)
with self._lock:
left, right = self._slew_limit(requested_left, requested_right)
self.controller.speed(left, right)
self._last_payload = (left, right)
self._last_command_at = time.monotonic()
self._stopped = left == 0.0 and right == 0.0
self._control_source = "wtb" if not self._stopped else "idle"
self._control_owner = "wtb" if not self._stopped else None
self._last_gear = gear_name
self._last_steering = 0.0
self._last_throttle = 0.0
self._sync_feedback_flow_interval_locked()
return {
"gear": gear_name,
"steering": steering,
"throttle": throttle,
"left": payload[0],
"right": payload[1],
"left": left,
"right": right,
}
def status(self) -> dict[str, Any]:
with self._state_lock:
last_payload = self._last_payload or (0.0, 0.0)
with self._lock:
age = max(0.0, time.monotonic() - self._last_command_at) if self._last_command_at else 0.0
return {
"port": self.controller.port,
"baudrate": self.controller.baudrate,
"idle_timeout": self.idle_timeout,
"left": last_payload[0],
"right": last_payload[1],
"mcu_heartbeat_delay_ms": self.mcu_heartbeat_delay_ms,
"left": self._last_payload[0],
"right": self._last_payload[1],
"stopped": self._stopped,
"control_source": self._control_source,
"control_owner": self._control_owner,
"gear": self._last_gear,
"steering": self._last_steering,
"throttle": self._last_throttle,
"feedback_flow_interval_ms": self._feedback_flow_interval_ms,
"command_age": round(age, 3),
"speed_profiles": self.speed_profiles,
"min_effective_speed": self.min_effective_speed,
"motor_floor_entry_speed": self.motor_floor_entry_speed,
"pivot_threshold": self.pivot_threshold,
}
def _watchdog_loop(self) -> None:
while not self._watchdog_stop.wait(0.08):
with self._state_lock:
if self._stopped or not self._last_command_at:
def raw_state(self) -> dict[str, Any]:
with self._lock:
return {
"left": self._last_payload[0],
"right": self._last_payload[1],
"stopped": self._stopped,
"control_source": self._control_source,
"control_owner": self._control_owner,
"gear": self._last_gear,
"steering": self._last_steering,
"throttle": self._last_throttle,
"command_age": max(0.0, time.monotonic() - self._last_command_at) if self._last_command_at else 0.0,
}
def _heartbeat_loop(self) -> None:
while not self._stop_event.wait(self.heartbeat_interval):
with self._lock:
now = time.monotonic()
if (now - self._last_command_at) >= self.idle_timeout:
self.controller.stop()
self._last_payload = (0.0, 0.0)
self._stopped = True
self._control_source = "idle"
self._control_owner = None
self._last_steering = 0.0
self._last_throttle = 0.0
self._sync_feedback_flow_interval_locked()
continue
expired = (time.monotonic() - self._last_command_at) >= self.idle_timeout
if not expired:
if self._stopped:
continue
self.controller.stop()
self._last_payload = (0.0, 0.0)
self._last_send_at = time.monotonic()
self._stopped = True
self.controller.speed(self._last_payload[0], self._last_payload[1])
def _set_feedback_flow_interval(self, delay_ms: int) -> None:
delay_ms = max(0, int(delay_ms))
if delay_ms == self._feedback_flow_interval_ms:
return
self.controller.set_feedback_flow_interval(delay_ms)
self._feedback_flow_interval_ms = delay_ms
def _sync_feedback_flow_interval_locked(self) -> None:
self._set_feedback_flow_interval(self.idle_feedback_interval_ms)
def _compute_payload(self, steering: float, throttle: float, gear: str) -> dict[str, float | str]:
gear_name = gear if gear in self.speed_profiles else "medium"
steering = self._normalize_axis(steering, self.steering_deadband)
throttle = self._normalize_axis(throttle, self.throttle_deadband)
max_speed = self.speed_profiles[gear_name]
if throttle == 0.0 and steering == 0.0:
left = 0.0
right = 0.0
elif throttle == 0.0:
pivot_speed = max(self.min_drive_speed, abs(steering) * max_speed * self.pivot_speed_scale)
if steering > 0.0:
left = pivot_speed
right = -pivot_speed
else:
left = -pivot_speed
right = pivot_speed
else:
throttle_strength = abs(throttle)
floor_ratio = min(1.0, throttle_strength / self.drive_floor_ramp_threshold)
dynamic_floor = self.min_drive_speed * floor_ratio
base = max(dynamic_floor, throttle_strength * max_speed)
steering_ratio = min(1.0, throttle_strength / self.steering_ramp_threshold)
turn = abs(steering) * self.steering_mix * steering_ratio
outer = base
inner = max(0.0, base * (1.0 - turn))
if steering >= 0.0:
left = outer
right = inner
else:
left = inner
right = outer
if throttle < 0.0:
left = -left
right = -right
left = round(self._clip(left, -0.5, 0.5), 3)
right = round(self._clip(right, -0.5, 0.5), 3)
return {
"gear": gear_name,
"steering": steering,
"throttle": throttle,
"left": left,
"right": right,
}
@staticmethod
def _clip(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
@staticmethod
def _apply_axis_deadband(value: float, deadband: float) -> float:
def _normalize_axis(value: float, deadband: float) -> float:
value = max(-1.0, min(1.0, value))
if abs(value) <= deadband:
return 0.0
sign = 1.0 if value > 0 else -1.0
scaled = (abs(value) - deadband) / (1.0 - deadband)
return sign * scaled
def _apply_motor_floor(self, value: float) -> float:
if abs(value) < 1e-6:
return 0.0
if abs(value) < self.motor_floor_entry_speed:
return 0.0
if abs(value) < self.min_effective_speed:
return self.min_effective_speed if value > 0 else -self.min_effective_speed
return value
def _slew_limit(self, target_left: float, target_right: float) -> tuple[float, float]:
current_left, current_right = self._last_payload
if abs(target_left - target_right) <= 0.005:
current_common = (current_left + current_right) / 2.0
target_common = (target_left + target_right) / 2.0
common_delta = self._clip(target_common - current_common, -self.max_output_step, self.max_output_step)
common = round(current_common + common_delta, 3)
if abs(target_common) < self.max_output_step and abs(common) < self.max_output_step:
common = round(target_common, 3)
return common, common
left_delta = self._clip(target_left - current_left, -self.max_output_step, self.max_output_step)
right_delta = self._clip(target_right - current_right, -self.max_output_step, self.max_output_step)
left = round(current_left + left_delta, 3)
right = round(current_right + right_delta, 3)
if abs(target_left) < self.max_output_step and abs(left) < self.max_output_step:
left = target_left
if abs(target_right) < self.max_output_step and abs(right) < self.max_output_step:
right = target_right
return left, right
def _mix_drive(self, steering: float, throttle: float, max_speed: float) -> tuple[float, float]:
if abs(throttle) < self.pivot_threshold:
pivot = steering * max_speed * self.pivot_turn_gain
return pivot, -pivot
def latest_dict(items: list[Any], target_t: int) -> dict[str, Any] | None:
for item in reversed(items):
if isinstance(item, dict) and item.get("T") == target_t:
return item
return None
base = throttle * max_speed
steer_amount = abs(steering)
direction = 1.0 if base >= 0 else -1.0
fast_side = abs(base)
slow_side = abs(base) * (1.0 - 2.0 * steer_amount)
if steering >= 0:
left = fast_side
right = slow_side
else:
left = slow_side
right = fast_side
def summarize_base_feedback(item: dict[str, Any] | None) -> dict[str, Any]:
if not item:
return {
"left_speed": None,
"right_speed": None,
"roll": None,
"pitch": None,
"yaw": None,
"voltage": None,
"battery_percent": None,
}
return left * direction, right * direction
voltage = item.get("v")
return {
"left_speed": item.get("L"),
"right_speed": item.get("R"),
"roll": item.get("r"),
"pitch": item.get("p"),
"yaw": item.get("y"),
"voltage": voltage,
"battery_percent": estimate_battery_percent(voltage) if isinstance(voltage, (int, float)) else None,
}
def estimate_battery_percent(voltage: float) -> int:
points = [
(9.30, 0),
(9.60, 2),
(9.90, 6),
(10.20, 12),
(10.50, 20),
(10.80, 32),
(11.10, 45),
(11.25, 54),
(11.40, 62),
(11.55, 71),
(11.70, 79),
(11.85, 86),
(12.00, 91),
(12.15, 95),
(12.30, 97),
(12.45, 99),
(12.60, 100),
]
if voltage <= points[0][0]:
return points[0][1]
if voltage >= points[-1][0]:
return points[-1][1]
for (v1, p1), (v2, p2) in zip(points, points[1:]):
if v1 <= voltage <= v2:
ratio = (voltage - v1) / (v2 - v1)
return round(p1 + ratio * (p2 - p1))
return 0
class UGV01FeedbackService:
def __init__(
self,
controller: UGV01Controller,
*,
flow_interval_ms: int = 50,
state_supplier: Callable[[], dict[str, Any]] | None = None,
) -> None:
self.controller = controller
self.flow_interval_ms = flow_interval_ms
self.state_supplier = state_supplier
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._worker: threading.Thread | None = None
self._latest_base: dict[str, Any] | None = None
self._base_items: list[Any] = []
self._updated_at = 0.0
def start(self) -> None:
self.controller.open()
self.controller.set_feedback_flow_interval(self.flow_interval_ms)
self.controller.serial_feedback(True)
with self._lock:
if self._worker and self._worker.is_alive():
return
self._stop_event.clear()
self._worker = threading.Thread(
target=self._read_loop,
name="ugv01-feedback-stream",
daemon=True,
)
self._worker.start()
def shutdown(self) -> None:
self._stop_event.set()
worker = self._worker
if worker:
worker.join(timeout=1.0)
self.controller.serial_feedback(False)
def snapshot(self) -> dict[str, Any]:
with self._lock:
summary = summarize_base_feedback(self._latest_base)
return {
"ok": self._latest_base is not None,
"summary": summary,
"items": list(self._base_items),
"updated_at": self._updated_at,
}
def _read_loop(self) -> None:
while not self._stop_event.wait(0.03):
items = self.controller.read_available()
if not items:
continue
latest = latest_dict(items, 1001)
if latest is None:
continue
with self._lock:
self._latest_base = latest
self._base_items = [latest]
self._updated_at = time.time()
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
class NetworkManagerError(RuntimeError):
pass
@dataclass
class WifiConnection:
name: str
uuid: str
conn_type: str
device: str
active: bool
class UGV01NetworkManager:
AP_CONNECTION_NAME = "Clear_UGV01"
AP_IFACE = "ap0"
STA_IFACE = "wlan0"
AP_SERVICE_SCRIPT = "/usr/local/sbin/ugv01-ap.sh"
def _run(self, args: list[str], *, check: bool = True) -> str:
completed = subprocess.run(args, capture_output=True, text=True)
if check and completed.returncode != 0:
message = completed.stderr.strip() or completed.stdout.strip() or "command failed"
raise NetworkManagerError(message)
return completed.stdout
def _nmcli_fields(self, fields: list[str], *rest: str) -> list[list[str]]:
output = self._run(["nmcli", "-t", "-f", ",".join(fields), *rest], check=False)
rows: list[list[str]] = []
for line in output.splitlines():
if not line.strip():
continue
rows.append(line.split(":"))
return rows
def list_connections(self) -> list[WifiConnection]:
rows = self._nmcli_fields(["NAME", "UUID", "TYPE", "DEVICE", "ACTIVE"], "connection", "show")
connections: list[WifiConnection] = []
for row in rows:
while len(row) < 5:
row.append("")
connections.append(
WifiConnection(
name=row[0],
uuid=row[1],
conn_type=row[2],
device=row[3],
active=row[4] == "yes",
)
)
return connections
def device_status(self) -> list[dict[str, str]]:
rows = self._nmcli_fields(["DEVICE", "TYPE", "STATE", "CONNECTION"], "device", "status")
devices: list[dict[str, str]] = []
for row in rows:
while len(row) < 4:
row.append("")
devices.append(
{
"device": row[0],
"type": row[1],
"state": row[2],
"connection": row[3],
}
)
return devices
def _connection_value(self, connection_name: str, field: str) -> str:
output = self._run(["nmcli", "-s", "-g", field, "connection", "show", connection_name], check=False)
return output.strip()
def _wifi_connection_names(self) -> set[str]:
return {
connection.name
for connection in self.list_connections()
if connection.conn_type == "802-11-wireless"
}
def _ipv4_address(self, iface: str) -> str:
output = self._run(["ip", "-4", "-o", "addr", "show", "dev", iface], check=False)
for line in output.splitlines():
parts = line.split()
if "inet" in parts:
address = parts[parts.index("inet") + 1]
return address.split("/", 1)[0]
return ""
def get_ap_config(self) -> dict[str, object]:
connection_names = self._wifi_connection_names()
configured = self.AP_CONNECTION_NAME in connection_names
ssid = self._connection_value(self.AP_CONNECTION_NAME, "802-11-wireless.ssid") if configured else ""
device = next((item for item in self.device_status() if item["device"] == self.AP_IFACE), None)
return {
"connection_name": self.AP_CONNECTION_NAME,
"enabled": bool(device and device["state"] == "connected"),
"device": self.AP_IFACE,
"ssid": ssid,
"ip_address": self._ipv4_address(self.AP_IFACE),
}
def get_sta_config(self) -> dict[str, object]:
device = next((item for item in self.device_status() if item["device"] == self.STA_IFACE), None)
current_name = device["connection"] if device and device["connection"] else ""
current_ssid = ""
if current_name and current_name != "--":
current_ssid = self._connection_value(current_name, "802-11-wireless.ssid")
return {
"enabled": bool(device and device["state"] == "connected"),
"device": self.STA_IFACE,
"connection_name": current_name,
"ssid": current_ssid or current_name,
"ip_address": self._ipv4_address(self.STA_IFACE),
}
def scan_sta_networks(self, *, rescan: str = "no") -> list[dict[str, str]]:
output = self._run(
[
"nmcli",
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
self.STA_IFACE,
"--rescan",
rescan,
],
check=False,
)
networks: list[dict[str, str]] = []
seen: set[tuple[str, str, str]] = set()
for line in output.splitlines():
if not line.strip():
continue
row = line.split(":")
while len(row) < 3:
row.append("")
key = (row[0], row[1], row[2])
if not row[0].strip() or key in seen:
continue
seen.add(key)
networks.append({"ssid": row[0], "signal": row[1], "security": row[2]})
return networks
def snapshot(self) -> dict[str, object]:
return {"ap": self.get_ap_config(), "sta": self.get_sta_config()}
def apply_ap(self, *, enabled: bool, ssid: str, password: str) -> dict[str, object]:
ssid = ssid.strip()
if enabled:
if not ssid:
raise NetworkManagerError("AP SSID 不能为空")
if self.AP_CONNECTION_NAME not in self._wifi_connection_names():
self._run(
[
"nmcli",
"connection",
"add",
"type",
"wifi",
"ifname",
self.AP_IFACE,
"con-name",
self.AP_CONNECTION_NAME,
"autoconnect",
"yes",
"ssid",
ssid,
]
)
current_password = self._connection_value(self.AP_CONNECTION_NAME, "802-11-wireless-security.psk")
effective_password = password or current_password
if len(effective_password) < 8:
raise NetworkManagerError("AP 密码至少 8 位")
self._run(
[
"nmcli",
"connection",
"modify",
self.AP_CONNECTION_NAME,
"connection.interface-name",
self.AP_IFACE,
"802-11-wireless.mode",
"ap",
"802-11-wireless.band",
"bg",
"802-11-wireless.ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
effective_password,
"ipv4.method",
"shared",
"ipv4.addresses",
"192.168.50.1/24",
"ipv6.method",
"ignore",
"connection.autoconnect",
"yes",
]
)
self._run([self.AP_SERVICE_SCRIPT, "restart"])
else:
self._run([self.AP_SERVICE_SCRIPT, "stop"], check=False)
return self.get_ap_config()
def apply_sta(self, *, enabled: bool, ssid: str, password: str) -> dict[str, object]:
ssid = ssid.strip()
if not enabled:
self._run(["nmcli", "device", "disconnect", self.STA_IFACE], check=False)
return self.get_sta_config()
if not ssid:
raise NetworkManagerError("STA SSID 不能为空")
connection_names = self._wifi_connection_names()
if ssid in connection_names:
modify_args = ["nmcli", "connection", "modify", ssid, "connection.interface-name", self.STA_IFACE]
if password:
modify_args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password])
self._run(modify_args)
self._run(["nmcli", "connection", "up", ssid, "ifname", self.STA_IFACE])
else:
args = [
"nmcli",
"device",
"wifi",
"connect",
ssid,
"ifname",
self.STA_IFACE,
"name",
ssid,
]
if password:
args.extend(["password", password])
self._run(args)
return self.get_sta_config()
+450
View File
@@ -0,0 +1,450 @@
from __future__ import annotations
import math
import os
import select
import struct
import termios
import threading
import time
from typing import Any
RTK_DEVICE_PATH = "/dev/serial/by-id/usb-AirM2M_EigenComm_Compo_000000000001-if06"
FRAME_SIZE = 11
BARO_PRESSURE_SCALE = 1.0
BARO_ALTITUDE_SCALE = 0.01
class RTKService:
def __init__(self, device_path: str = RTK_DEVICE_PATH, baudrate: int = 115200, default_mode: str = "WTB2"):
self.device_path = device_path
self.baudrate = baudrate
self.default_mode = default_mode.upper()
self._lock = threading.Lock()
self._io_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._fd: int | None = None
self._buffer = bytearray()
self._state: dict[str, Any] = {
"ok": False,
"device_path": device_path,
"resolved_path": None,
"connected": False,
"configured_mode": self.default_mode,
"current_mode": "NA",
"last_mode_ack": None,
"last_error": None,
"updated_at": 0.0,
"last_text": None,
"frame_counters": {
"0x51": 0,
"0x52": 0,
"0x53": 0,
"0x55": 0,
"0x56": 0,
"0x57": 0,
"0x58": 0,
"0x59": 0,
"0x5A": 0,
},
"accel": {
"updated_at": 0.0,
"x_mps2": None,
"y_mps2": None,
"z_mps2": None,
"temperature_c": None,
},
"gyro": {
"updated_at": 0.0,
"x_rads": None,
"y_rads": None,
"z_rads": None,
},
"attitude": {
"updated_at": 0.0,
"roll_deg": None,
"pitch_deg": None,
"yaw_deg": None,
},
"battery": {
"updated_at": 0.0,
"battery_voltage_v": None,
"in_voltage_v": None,
},
"baro": {
"updated_at": 0.0,
"pressure_raw": None,
"altitude_raw": None,
"pressure_pa": None,
"altitude_m": None,
},
"gps": {
"updated_at": 0.0,
"longitude": None,
"latitude": None,
"altitude_m": None,
"satellites": None,
"fix_quality": None,
"speed_knots": None,
"speed_mps": None,
},
"waypoint": {
"updated_at": 0.0,
"target_longitude": None,
"target_latitude": None,
"target_altitude_m": None,
"distance_m": None,
"total_distance_m": None,
},
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="ugv01-rtk", daemon=True)
self._thread.start()
def shutdown(self) -> None:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
self._thread = None
self._close_port()
def snapshot(self) -> dict[str, Any]:
with self._lock:
state = {
**self._state,
"frame_counters": dict(self._state["frame_counters"]),
"accel": dict(self._state["accel"]),
"gyro": dict(self._state["gyro"]),
"attitude": dict(self._state["attitude"]),
"battery": dict(self._state["battery"]),
"baro": dict(self._state["baro"]),
"gps": dict(self._state["gps"]),
"waypoint": dict(self._state["waypoint"]),
}
state["updated_age_s"] = max(0.0, time.time() - state["updated_at"]) if state["updated_at"] else None
return self._sanitize(state)
def switch_mode(self, mode: str) -> dict[str, Any]:
normalized = mode.strip().upper()
if normalized not in {"CFG", "WTB2", "COMBINED"}:
raise ValueError("unsupported RTK mode")
with self._lock:
self._state["configured_mode"] = normalized
self._send_command(normalized)
return self.snapshot()
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._open_port()
with self._lock:
configured_mode = self._state["configured_mode"]
self._send_command(configured_mode)
self._read_loop()
except Exception as exc:
with self._lock:
self._state["ok"] = False
self._state["connected"] = False
self._state["last_error"] = str(exc)
self._close_port()
time.sleep(1.0)
def _open_port(self) -> None:
self._close_port()
fd = os.open(self.device_path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(fd)
attrs[0] = 0
attrs[1] = 0
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
attrs[3] = 0
attrs[4] = termios.B115200
attrs[5] = termios.B115200
attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 0
termios.tcsetattr(fd, termios.TCSANOW, attrs)
termios.tcflush(fd, termios.TCIOFLUSH)
self._buffer.clear()
with self._lock:
self._fd = fd
self._state["ok"] = True
self._state["connected"] = True
self._state["resolved_path"] = os.path.realpath(self.device_path)
self._state["last_error"] = None
def _close_port(self) -> None:
fd = self._fd
self._fd = None
if fd is not None:
try:
os.close(fd)
except OSError:
pass
def _send_command(self, mode: str) -> None:
fd = self._fd
if fd is None:
return
normalized = mode.upper()
if normalized in {"WTB2", "COMBINED"}:
command = b"AT+WTB2\r\n"
else:
command = b"AT+CFG\r\n"
with self._io_lock:
try:
os.write(fd, command)
except OSError as exc:
with self._lock:
self._state["last_error"] = f"write failed: {exc}"
def _read_loop(self) -> None:
while not self._stop_event.is_set():
fd = self._fd
if fd is None:
return
readable, _, _ = select.select([fd], [], [], 0.25)
if not readable:
continue
try:
chunk = os.read(fd, 65536)
except BlockingIOError:
continue
except OSError:
raise
if not chunk:
continue
self._buffer.extend(chunk)
self._drain_buffer()
def _drain_buffer(self) -> None:
while True:
if self._extract_frame():
continue
if self._extract_text_line():
continue
break
def _extract_frame(self) -> bool:
frame_start = self._find_frame_header()
if frame_start < 0:
return False
if frame_start > 0:
del self._buffer[:frame_start]
if len(self._buffer) < FRAME_SIZE:
return False
frame = bytes(self._buffer[:FRAME_SIZE])
if not self._valid_frame(frame):
del self._buffer[0]
return True
del self._buffer[:FRAME_SIZE]
self._handle_frame(frame)
return True
def _extract_text_line(self) -> bool:
if not self._buffer:
return False
try:
newline_idx = self._buffer.index(10)
except ValueError:
return False
raw = bytes(self._buffer[: newline_idx + 1])
del self._buffer[: newline_idx + 1]
line = raw.replace(b"\r", b"").replace(b"\n", b"").decode("ascii", "ignore").strip()
if line:
self._handle_text(line)
return True
def _handle_text(self, line: str) -> None:
now = time.time()
with self._lock:
self._state["updated_at"] = now
self._state["last_text"] = line
self._state["ok"] = True
self._state["connected"] = True
if line == "+CFG":
with self._lock:
self._state["current_mode"] = "CFG"
self._state["last_mode_ack"] = line
self._state["last_error"] = None
return
if line in {"+WTB2", "+OUTPUT=WTB2"}:
with self._lock:
self._state["current_mode"] = "WTB2"
self._state["last_mode_ack"] = line
self._state["last_error"] = None
return
if line.startswith("+ERR"):
with self._lock:
self._state["last_error"] = line
def _handle_frame(self, frame: bytes) -> None:
frame_type = frame[1]
now = time.time()
payload = frame[2:10]
with self._lock:
self._state["ok"] = True
self._state["connected"] = True
self._state["updated_at"] = now
if self._state["configured_mode"] in {"WTB2", "COMBINED"}:
self._state["current_mode"] = "WTB2"
key = f"0x{frame_type:02X}"
if key in self._state["frame_counters"]:
self._state["frame_counters"][key] += 1
if frame_type == 0x51:
self._parse_accel(payload, now)
elif frame_type == 0x52:
self._parse_gyro(payload, now)
elif frame_type == 0x53:
self._parse_attitude(payload, now)
elif frame_type == 0x55:
self._parse_battery(payload, now)
elif frame_type == 0x56:
self._parse_baro(payload, now)
elif frame_type == 0x57:
self._parse_gps_coords(payload, now)
elif frame_type == 0x58:
self._parse_gps_status(payload, now)
elif frame_type == 0x59:
self._parse_waypoint_target(payload, now)
elif frame_type == 0x5A:
self._parse_waypoint_progress(payload, now)
def _parse_accel(self, payload: bytes, now: float) -> None:
ax_raw, ay_raw, az_raw, temp_raw = struct.unpack("<hhhh", payload)
scale = 2.0 * 9.81 / 32768.0
with self._lock:
self._state["accel"] = {
"updated_at": now,
"x_mps2": ax_raw * scale,
"y_mps2": ay_raw * scale,
"z_mps2": az_raw * scale,
"temperature_c": temp_raw / 100.0,
}
def _parse_gyro(self, payload: bytes, now: float) -> None:
gx_raw, gy_raw, gz_raw, _unused = struct.unpack("<hhhh", payload)
scale = 250.0 * math.pi / 180.0 / 32768.0
with self._lock:
self._state["gyro"] = {
"updated_at": now,
"x_rads": gx_raw * scale,
"y_rads": gy_raw * scale,
"z_rads": gz_raw * scale,
}
def _parse_attitude(self, payload: bytes, now: float) -> None:
roll_raw, pitch_raw, yaw_raw, _unused = struct.unpack("<hhhh", payload)
scale = 180.0 / 32768.0
with self._lock:
self._state["attitude"] = {
"updated_at": now,
"roll_deg": roll_raw * scale,
"pitch_deg": pitch_raw * scale,
"yaw_deg": yaw_raw * scale,
}
def _parse_battery(self, payload: bytes, now: float) -> None:
battery_raw = struct.unpack("<h", payload[2:4])[0]
in_voltage_v = struct.unpack("<f", payload[4:8])[0]
with self._lock:
self._state["battery"] = {
"updated_at": now,
"battery_voltage_v": battery_raw / 100.0,
"in_voltage_v": in_voltage_v,
}
def _parse_baro(self, payload: bytes, now: float) -> None:
pressure_raw = struct.unpack("<i", payload[:4])[0]
altitude_raw = struct.unpack("<i", payload[4:8])[0]
with self._lock:
self._state["baro"] = {
"updated_at": now,
"pressure_raw": pressure_raw,
"altitude_raw": altitude_raw,
"pressure_pa": pressure_raw * BARO_PRESSURE_SCALE,
"altitude_m": altitude_raw * BARO_ALTITUDE_SCALE,
}
def _parse_gps_coords(self, payload: bytes, now: float) -> None:
longitude_raw = struct.unpack("<i", payload[:4])[0]
latitude_raw = struct.unpack("<i", payload[4:8])[0]
with self._lock:
gps = dict(self._state["gps"])
gps["updated_at"] = now
gps["longitude"] = longitude_raw / 10000000.0
gps["latitude"] = latitude_raw / 10000000.0
self._state["gps"] = gps
def _parse_gps_status(self, payload: bytes, now: float) -> None:
altitude_raw = struct.unpack("<i", payload[:4])[0]
satellites = payload[4]
fix_quality = payload[5]
speed_knots_raw = struct.unpack("<h", payload[6:8])[0]
speed_knots = speed_knots_raw / 100.0
with self._lock:
gps = dict(self._state["gps"])
gps["updated_at"] = now
gps["altitude_m"] = altitude_raw / 1000.0
gps["satellites"] = satellites
gps["fix_quality"] = fix_quality
gps["speed_knots"] = speed_knots
gps["speed_mps"] = speed_knots * 0.514444
self._state["gps"] = gps
def _parse_waypoint_target(self, payload: bytes, now: float) -> None:
longitude_raw = struct.unpack("<i", payload[:4])[0]
latitude_raw = struct.unpack("<i", payload[4:8])[0]
with self._lock:
waypoint = dict(self._state["waypoint"])
waypoint["updated_at"] = now
waypoint["target_longitude"] = longitude_raw / 10000000.0
waypoint["target_latitude"] = latitude_raw / 10000000.0
self._state["waypoint"] = waypoint
def _parse_waypoint_progress(self, payload: bytes, now: float) -> None:
target_altitude_raw = struct.unpack("<i", payload[:4])[0]
distance_m = struct.unpack("<h", payload[4:6])[0]
total_distance_m = struct.unpack("<h", payload[6:8])[0]
with self._lock:
waypoint = dict(self._state["waypoint"])
waypoint["updated_at"] = now
waypoint["target_altitude_m"] = target_altitude_raw / 1000.0
waypoint["distance_m"] = distance_m
waypoint["total_distance_m"] = total_distance_m
self._state["waypoint"] = waypoint
def _find_frame_header(self) -> int:
limit = len(self._buffer) - 1
for idx in range(max(0, limit)):
if self._buffer[idx] == 0x55:
frame_type = self._buffer[idx + 1]
if frame_type in {0x51, 0x52, 0x53, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A}:
return idx
return -1
@staticmethod
def _valid_frame(frame: bytes) -> bool:
if len(frame) != FRAME_SIZE:
return False
if frame[0] != 0x55:
return False
return (sum(frame[:10]) & 0xFF) == frame[10]
@classmethod
def _sanitize(cls, value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {key: cls._sanitize(item) for key, item in value.items()}
if isinstance(value, list):
return [cls._sanitize(item) for item in value]
if isinstance(value, tuple):
return [cls._sanitize(item) for item in value]
return value
+269 -99
View File
@@ -1,28 +1,89 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import subprocess
from uuid import uuid4
from aiortc import RTCPeerConnection, RTCSessionDescription
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .controller import UGV01Controller, UGV01MotionService
from .camera_service import UGV01CameraService
from .controller import UGV01Controller, UGV01FeedbackService, UGV01MotionService
from .network_manager import NetworkManagerError, UGV01NetworkManager
from .rtk_service import RTKService
from .wtb_service import WTBService
from .webrtc_service import UGV01CameraVideoTrack, build_answer, prefer_h264
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "web" / "static"
CERT_DIR = BASE_DIR / "certs"
CA_CERT_PATH = CERT_DIR / "ugv01-root-ca.crt"
CAMERA_CONFIG_PATH = BASE_DIR / "camera_config.json"
controller = UGV01Controller()
motion_service = UGV01MotionService(controller)
feedback_service = UGV01FeedbackService(controller, state_supplier=motion_service.raw_state)
network_manager = UGV01NetworkManager()
camera_service = UGV01CameraService(CAMERA_CONFIG_PATH)
rtk_service = RTKService()
wtb_service = WTBService(control_callback=motion_service.apply_wtb, stop_callback=motion_service.stop_if_owner)
peer_connections: set[RTCPeerConnection] = set()
class APConfigPayload(BaseModel):
enabled: bool
ssid: str = ""
password: str = ""
class STAConfigPayload(BaseModel):
enabled: bool
ssid: str = ""
password: str = ""
class WebRTCOfferPayload(BaseModel):
sdp: str
type: str
class RTKModePayload(BaseModel):
mode: str
class WTBControlPayload(BaseModel):
authorized: bool
gear: str = "medium"
class StopPayload(BaseModel):
owner_id: str | None = None
class GearPayload(BaseModel):
gear: str
@asynccontextmanager
async def lifespan(_: FastAPI):
motion_service.start()
feedback_service.start()
camera_service.start()
rtk_service.start()
wtb_service.start()
try:
yield
finally:
for pc in list(peer_connections):
await pc.close()
peer_connections.clear()
wtb_service.shutdown()
rtk_service.shutdown()
camera_service.shutdown()
feedback_service.shutdown()
motion_service.shutdown()
@@ -32,145 +93,254 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")
return FileResponse(
STATIC_DIR / "index.html",
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)
@app.get("/ca.crt")
async def ca_cert():
if not CA_CERT_PATH.exists():
raise HTTPException(status_code=404, detail="CA certificate not found")
return FileResponse(CA_CERT_PATH, media_type="application/x-x509-ca-cert", filename="ugv01-root-ca.crt")
@app.get("/api/status")
async def status():
return motion_service.status()
payload = motion_service.status()
payload["feedback"] = feedback_service.snapshot()["summary"]
payload["wtb_status"] = wtb_service.snapshot()
return payload
@app.post("/api/stop")
async def stop():
motion_service.force_stop()
async def stop(payload: StopPayload | None = None):
if wtb_service.is_authorized():
if payload and payload.owner_id:
motion_service.stop_if_owner(payload.owner_id)
else:
if payload and payload.owner_id:
motion_service.stop_if_owner(payload.owner_id)
else:
motion_service.force_stop()
return {"ok": True, "status": motion_service.status()}
@app.post("/api/gear")
async def set_gear(payload: GearPayload):
return {"ok": True, "status": motion_service.set_preferred_gear(payload.gear)}
@app.get("/api/imu")
async def imu():
try:
items = await asyncio.to_thread(controller.imu)
return {"ok": True, "items": items}
snap = feedback_service.snapshot()
summary = snap["summary"]
return {
"ok": snap["ok"],
"items": [
{
"T": 1002,
"r": summary["roll"],
"p": summary["pitch"],
"y": summary["yaw"],
"v": summary["voltage"],
}
]
if snap["ok"]
else [],
"updated_at": snap["updated_at"],
}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/base-feedback")
async def base_feedback():
try:
items = await asyncio.to_thread(controller.base_feedback)
return {"ok": True, "items": items}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return feedback_service.snapshot()
@app.get("/api/base-feedback-summary")
async def base_feedback_summary():
snap = feedback_service.snapshot()
return {
"ok": snap["ok"],
"summary": snap["summary"],
"updated_at": snap["updated_at"],
}
@app.get("/api/camera/status")
async def camera_status():
return camera_service.snapshot()
@app.post("/api/webrtc/offer")
async def webrtc_offer(payload: WebRTCOfferPayload):
pc = RTCPeerConnection()
peer_connections.add(pc)
@pc.on("connectionstatechange")
async def on_connectionstatechange():
if pc.connectionState in {"failed", "closed", "disconnected"}:
await pc.close()
peer_connections.discard(pc)
track = UGV01CameraVideoTrack(camera_service)
sender = pc.addTrack(track)
transceivers = pc.getTransceivers()
if transceivers:
prefer_h264(transceivers[0])
else:
transceiver = pc.addTransceiver("video")
prefer_h264(transceiver)
pc.removeTrack(sender)
pc.addTrack(track)
answer = await build_answer(pc, RTCSessionDescription(sdp=payload.sdp, type=payload.type))
return {"sdp": answer.sdp, "type": answer.type}
@app.get("/video/fpv.mjpg")
async def fpv_stream():
boundary = "frame"
def stream():
last_timestamp = 0.0
while True:
frame, updated_at = camera_service.wait_for_frame(last_timestamp, timeout=2.0)
if frame is None:
continue
last_timestamp = updated_at
yield (
f"--{boundary}\r\n"
"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(frame)}\r\n\r\n"
).encode("ascii") + frame + b"\r\n"
return StreamingResponse(
stream(),
media_type=f"multipart/x-mixed-replace; boundary={boundary}",
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)
@app.get("/api/network")
async def network_snapshot():
try:
items = await asyncio.to_thread(controller.base_feedback)
latest = _latest_dict(items, 1001)
return {
"ok": True,
"items": items,
"summary": _summarize_base_feedback(latest),
}
except Exception as exc:
return {"ok": True, **network_manager.snapshot()}
except NetworkManagerError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/tailscale")
async def tailscale_status():
try:
result = subprocess.run(
["tailscale", "ip", "-4"],
capture_output=True,
text=True,
timeout=2.0,
check=False,
)
except (FileNotFoundError, subprocess.SubprocessError):
return {"ok": True, "ip": "NA"}
if result.returncode != 0:
return {"ok": True, "ip": "NA"}
for line in result.stdout.splitlines():
line = line.strip()
if line:
return {"ok": True, "ip": line}
return {"ok": True, "ip": "NA"}
@app.get("/api/rtk")
async def rtk_status():
return rtk_service.snapshot()
@app.get("/api/wtb")
async def wtb_status():
return wtb_service.snapshot()
@app.post("/api/wtb/control")
async def wtb_control(payload: WTBControlPayload):
if payload.authorized:
motion_service.force_stop()
return {"ok": True, "wtb": wtb_service.set_control(authorized=payload.authorized, gear=payload.gear)}
@app.post("/api/rtk/mode")
async def rtk_mode(payload: RTKModePayload):
try:
return {"ok": True, "rtk": rtk_service.switch_mode(payload.mode)}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/network/scan")
async def network_scan():
try:
return {"ok": True, "scan": network_manager.scan_sta_networks(rescan="auto")}
except NetworkManagerError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/network/ap")
async def update_ap(payload: APConfigPayload):
try:
config = network_manager.apply_ap(
enabled=payload.enabled,
ssid=payload.ssid,
password=payload.password,
)
return {"ok": True, "ap": config}
except NetworkManagerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/network/sta")
async def update_sta(payload: STAConfigPayload):
try:
config = network_manager.apply_sta(
enabled=payload.enabled,
ssid=payload.ssid,
password=payload.password,
)
return {"ok": True, "sta": config}
except NetworkManagerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.websocket("/ws/control")
async def ws_control(websocket: WebSocket):
owner_id = f"web-{uuid4().hex}"
await websocket.accept()
await websocket.send_json({"type": "hello", "status": motion_service.status()})
await websocket.send_json({"type": "hello", "status": motion_service.status(), "owner_id": owner_id})
try:
while True:
message = await websocket.receive_json()
action = message.get("type")
if action == "drive":
if wtb_service.is_authorized():
await websocket.send_json({"type": "error", "message": "WTB 已授权,网页控制已锁定"})
continue
steering = float(message.get("steering", 0.0))
throttle = float(message.get("throttle", 0.0))
gear = str(message.get("gear", "medium"))
result = await asyncio.to_thread(
motion_service.apply_joystick,
steering,
throttle,
gear,
)
await websocket.send_json({"type": "ack", "state": result, "status": motion_service.status()})
motion_service.apply_joystick(steering, throttle, gear, owner_id)
continue
if action == "stop":
await asyncio.to_thread(motion_service.force_stop)
motion_service.stop_if_owner(owner_id)
await websocket.send_json({"type": "stopped", "status": motion_service.status()})
continue
if action == "status":
await websocket.send_json({"type": "status", "status": motion_service.status()})
continue
await websocket.send_json({"type": "error", "message": f"unknown action: {action}"})
except WebSocketDisconnect:
motion_service.force_stop()
def _latest_dict(items: list[Any], target_t: int) -> dict[str, Any] | None:
for item in reversed(items):
if isinstance(item, dict) and item.get("T") == target_t:
return item
return None
def _summarize_base_feedback(item: dict[str, Any] | None) -> dict[str, Any]:
if not item:
return {
"voltage": None,
"battery_percent": None,
}
voltage = item.get("v")
percent = _estimate_battery_percent(voltage) if isinstance(voltage, (int, float)) else None
return {
"left_speed": item.get("L"),
"right_speed": item.get("R"),
"roll": item.get("r"),
"pitch": item.get("p"),
"yaw": item.get("y"),
"voltage": voltage,
"battery_percent": percent,
}
def _estimate_battery_percent(voltage: float) -> int:
# 3S lithium pack estimate tuned for 12.6V full-charge behavior.
# This is intentionally nonlinear because lithium discharge is not linear.
points = [
(9.30, 0),
(9.60, 2),
(9.90, 6),
(10.20, 12),
(10.50, 20),
(10.80, 32),
(11.10, 45),
(11.25, 54),
(11.40, 62),
(11.55, 71),
(11.70, 79),
(11.85, 86),
(12.00, 91),
(12.15, 95),
(12.30, 97),
(12.45, 99),
(12.6, 100),
]
if voltage <= points[0][0]:
return points[0][1]
if voltage >= points[-1][0]:
return points[-1][1]
for (v1, p1), (v2, p2) in zip(points, points[1:]):
if v1 <= voltage <= v2:
ratio = (voltage - v1) / (v2 - v1)
return round(p1 + ratio * (p2 - p1))
return 0
motion_service.stop_if_owner(owner_id)
+62
View File
@@ -0,0 +1,62 @@
import asyncio
from fractions import Fraction
from aiortc import RTCPeerConnection, RTCRtpSender, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
class UGV01CameraVideoTrack(VideoStreamTrack):
def __init__(self, camera_service):
super().__init__()
self.camera_service = camera_service
self._last_seq = 0
async def recv(self) -> VideoFrame:
pts, time_base = await self.next_timestamp()
frame, seq = await asyncio.to_thread(self.camera_service.wait_for_left_frame, self._last_seq, 1.0)
if frame is None:
await asyncio.sleep(0.02)
frame, seq = await asyncio.to_thread(self.camera_service.wait_for_left_frame, self._last_seq, 0.2)
if frame is None:
raise RuntimeError("camera frame unavailable")
self._last_seq = seq
video_frame = VideoFrame.from_ndarray(frame, format="bgr24")
video_frame.pts = pts
video_frame.time_base = time_base if time_base is not None else Fraction(1, 90000)
return video_frame
async def wait_for_ice_complete(pc: RTCPeerConnection, timeout: float = 3.0) -> None:
if pc.iceGatheringState == "complete":
return
event = asyncio.Event()
@pc.on("icegatheringstatechange")
async def on_ice_gathering_state_change():
if pc.iceGatheringState == "complete":
event.set()
try:
await asyncio.wait_for(event.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass
def prefer_h264(transceiver) -> None:
capabilities = RTCRtpSender.getCapabilities("video")
if not capabilities:
return
preferred = [codec for codec in capabilities.codecs if codec.mimeType.lower() == "video/h264"]
fallback = [codec for codec in capabilities.codecs if codec.mimeType.lower() != "video/rtx"]
codecs = preferred + [codec for codec in fallback if codec not in preferred]
if codecs:
transceiver.setCodecPreferences(codecs)
async def build_answer(pc: RTCPeerConnection, offer: RTCSessionDescription) -> RTCSessionDescription:
await pc.setRemoteDescription(offer)
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await wait_for_ice_complete(pc)
assert pc.localDescription is not None
return pc.localDescription
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import os
import select
import struct
import termios
import threading
import time
from typing import Any, Callable
WTB_DEVICE_PATH = "/dev/serial/by-id/usb-Silicon_Labs_CP2104_USB_to_UART_Bridge_Controller_02HLCY6M-if00-port0"
WTB_FRAME_SIZE = 11
WTB_TO_UGV_SPEED_SCALE = 0.5 / 1000.0
class WTBService:
def __init__(
self,
device_path: str = WTB_DEVICE_PATH,
baudrate: int = 115200,
control_callback: Callable[[float, float, str], dict[str, Any]] | None = None,
stop_callback: Callable[[str], None] | None = None,
control_period: float = 0.05,
):
self.device_path = device_path
self.baudrate = baudrate
self._control_callback = control_callback
self._stop_callback = stop_callback
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._control_thread: threading.Thread | None = None
self._fd: int | None = None
self._buffer = bytearray()
self._control_period = control_period
self._latest_left_norm = 0.0
self._latest_right_norm = 0.0
self._latest_frame_at = 0.0
self._state: dict[str, Any] = {
"ok": False,
"connected": False,
"device_path": device_path,
"resolved_path": None,
"updated_at": 0.0,
"last_error": None,
"frame_count": 0,
"authorized": False,
"gear": "medium",
"applied_output": {
"left": None,
"right": None,
},
"wtb": {
"L": None,
"R": None,
"left_speed_mps": None,
"right_speed_mps": None,
"num": None,
"index": None,
"sw": None,
"route_num": None,
"ready": None,
"pivot": None,
"switch_1": None,
"switch_2": None,
"switch_3": None,
"updated_at": 0.0,
},
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="ugv01-wtb", daemon=True)
self._thread.start()
self._control_thread = threading.Thread(target=self._control_loop, name="ugv01-wtb-control", daemon=True)
self._control_thread.start()
def shutdown(self) -> None:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
if self._control_thread and self._control_thread.is_alive():
self._control_thread.join(timeout=2.0)
self._thread = None
self._control_thread = None
self._close_port()
def snapshot(self) -> dict[str, Any]:
with self._lock:
state = {
**self._state,
"applied_output": dict(self._state["applied_output"]),
"wtb": dict(self._state["wtb"]),
}
state["updated_age_s"] = max(0.0, time.time() - state["updated_at"]) if state["updated_at"] else None
return state
def is_authorized(self) -> bool:
with self._lock:
return bool(self._state["authorized"])
def set_control(self, *, authorized: bool, gear: str) -> dict[str, Any]:
gear_name = gear if gear in {"low", "medium", "high"} else "medium"
should_stop = False
with self._lock:
previous = self._state["authorized"]
self._state["authorized"] = bool(authorized)
self._state["gear"] = gear_name
if not authorized:
self._state["applied_output"] = {"left": 0.0, "right": 0.0}
should_stop = previous
if should_stop and self._stop_callback:
self._stop_callback("wtb")
return self.snapshot()
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._open_port()
self._read_loop()
except Exception as exc:
with self._lock:
self._state["ok"] = False
self._state["connected"] = False
self._state["last_error"] = str(exc)
self._close_port()
time.sleep(1.0)
def _open_port(self) -> None:
self._close_port()
fd = os.open(self.device_path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(fd)
attrs[0] = 0
attrs[1] = 0
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
attrs[3] = 0
attrs[4] = termios.B115200
attrs[5] = termios.B115200
attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 0
termios.tcsetattr(fd, termios.TCSANOW, attrs)
termios.tcflush(fd, termios.TCIOFLUSH)
self._buffer.clear()
self._fd = fd
with self._lock:
self._state["ok"] = True
self._state["connected"] = True
self._state["resolved_path"] = os.path.realpath(self.device_path)
self._state["last_error"] = None
def _close_port(self) -> None:
fd = self._fd
self._fd = None
if fd is not None:
try:
os.close(fd)
except OSError:
pass
def _read_loop(self) -> None:
while not self._stop_event.is_set():
fd = self._fd
if fd is None:
return
readable, _, _ = select.select([fd], [], [], 0.25)
if not readable:
continue
try:
chunk = os.read(fd, 4096)
except BlockingIOError:
continue
except OSError:
raise
if not chunk:
continue
self._buffer.extend(chunk)
self._drain_buffer()
def _drain_buffer(self) -> None:
while True:
frame_start = self._find_frame_header()
if frame_start < 0:
if len(self._buffer) > 64:
del self._buffer[:-10]
return
if frame_start > 0:
del self._buffer[:frame_start]
if len(self._buffer) < WTB_FRAME_SIZE:
return
frame = bytes(self._buffer[:WTB_FRAME_SIZE])
if not self._valid_frame(frame):
del self._buffer[0]
continue
del self._buffer[:WTB_FRAME_SIZE]
self._handle_frame(frame)
def _find_frame_header(self) -> int:
limit = len(self._buffer) - 1
for idx in range(max(0, limit)):
if self._buffer[idx] == 0x55 and self._buffer[idx + 1] == 0x57:
return idx
return -1
@staticmethod
def _valid_frame(frame: bytes) -> bool:
if len(frame) != WTB_FRAME_SIZE:
return False
if frame[0] != 0x55 or frame[1] != 0x57:
return False
return (sum(frame[:10]) & 0xFF) == frame[10]
def _handle_frame(self, frame: bytes) -> None:
left, right = struct.unpack_from("<hh", frame, 2)
num = frame[6]
index = frame[7]
sw = frame[8]
route_num = frame[9]
left_speed_mps = round(left * WTB_TO_UGV_SPEED_SCALE, 3)
right_speed_mps = round(right * WTB_TO_UGV_SPEED_SCALE, 3)
now = time.time()
with self._lock:
self._state["ok"] = True
self._state["connected"] = True
self._state["updated_at"] = now
self._state["frame_count"] += 1
self._latest_left_norm = max(-1.0, min(1.0, left / 1000.0))
self._latest_right_norm = max(-1.0, min(1.0, right / 1000.0))
self._latest_frame_at = now
self._state["wtb"] = {
"L": left,
"R": right,
"left_speed_mps": left_speed_mps,
"right_speed_mps": right_speed_mps,
"num": num,
"index": index,
"sw": sw,
"route_num": route_num,
"ready": bool(sw & 0x08),
"pivot": bool(sw & 0x10),
"switch_1": bool(sw & 0x80),
"switch_2": bool(sw & 0x40),
"switch_3": bool(sw & 0x20),
"updated_at": now,
}
def _control_loop(self) -> None:
while not self._stop_event.wait(self._control_period):
if not self._control_callback:
continue
with self._lock:
authorized = bool(self._state["authorized"])
gear = str(self._state["gear"])
left_norm = self._latest_left_norm
right_norm = self._latest_right_norm
frame_age = time.time() - self._latest_frame_at if self._latest_frame_at else None
if not authorized:
continue
if frame_age is None or frame_age > 0.5:
continue
result = self._control_callback(left_norm, right_norm, gear)
with self._lock:
self._state["applied_output"] = {
"left": result.get("left"),
"right": result.get("right"),
}
+857 -112
View File
File diff suppressed because it is too large. Load diff
+113 -60
View File
@@ -7,83 +7,136 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>UGV01 Mobile Control</title>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="/static/styles.css?v=20260513r" />
</head>
<body>
<main class="app-shell">
<section class="hero">
<div>
<p class="eyebrow">UGV01 Mobile Drive</p>
<h1>手机摇杆控制台</h1>
<p class="subtitle">左侧方向,右侧油门。断开连接、松手或页面切后台后会自动停车。</p>
</div>
<div class="status-card">
<div class="status-row">
<span>连接</span>
<strong id="connection-badge" class="badge offline">离线</strong>
</div>
<div class="status-row">
<span>档位</span>
<strong id="gear-readout">中速</strong>
</div>
<div class="status-row">
<span>轮速</span>
<strong id="wheel-readout">L 0.000 / R 0.000</strong>
</div>
<div class="status-row">
<span>电压</span>
<strong id="battery-voltage">-- V</strong>
</div>
<div class="status-row">
<span>估算电量</span>
<strong id="battery-percent">-- %</strong>
</div>
<div class="status-strip">
<strong id="connection-badge" class="status-pill status-online offline" aria-label="连接状态"></strong>
<strong id="battery-percent" class="status-pill status-battery"><span class="battery-icon" aria-hidden="true"><span class="battery-shell"></span><span class="battery-cap"></span><span class="battery-level"></span></span><span id="battery-text">--%</span></strong>
<button id="fpv-mode-button" class="status-pill status-mode status-pill-button" type="button">WebRTC</button>
<strong id="gamepad-status" class="status-pill status-gamepad disconnected" aria-label="手柄状态"></strong>
<button id="gear-cycle-button" class="status-pill status-pill-button toolbar-pill gear-medium" type="button">中速</button>
<button id="control-mode-button" class="status-pill status-pill-button toolbar-pill mode-touch" type="button">模拟摇杆</button>
</div>
</section>
<section class="gear-panel">
<button class="gear-button" data-gear="low">低速</button>
<button class="gear-button active" data-gear="medium">中速</button>
<button class="gear-button" data-gear="high">高速</button>
</section>
<section class="control-grid">
<div class="control-panel">
<div class="panel-label">方向</div>
<div class="joystick" id="steering-pad">
<div class="joystick-ring"></div>
<div class="joystick-knob"></div>
<section class="fpv-panel">
<div id="fpv-card" class="fpv-card">
<video id="fpv-rtc" class="fpv-image fpv-video hidden" autoplay playsinline muted></video>
<img id="fpv-view" class="fpv-image hidden" data-src="/video/fpv.mjpg" alt="FPV 左视图" />
<button id="fpv-fullscreen" class="fpv-fullscreen" type="button" aria-label="全屏 FPV"></button>
<div class="fpv-guides" aria-hidden="true">
<span class="fpv-guide fpv-guide-left"></span>
<span class="fpv-guide fpv-guide-right"></span>
<span class="fpv-guide-anchor fpv-guide-anchor-left"></span>
<span class="fpv-guide-anchor fpv-guide-anchor-right"></span>
</div>
<div class="value-readout" id="steering-readout">0%</div>
</div>
<div class="control-panel">
<div class="panel-label">油门</div>
<div class="joystick" id="throttle-pad">
<div class="joystick-ring"></div>
<div class="joystick-knob"></div>
<div class="fpv-controls">
<div class="control-panel-overlay control-panel-left">
<div class="joystick" id="steering-pad">
<div class="joystick-ring"></div>
<div class="joystick-knob"></div>
</div>
</div>
<div class="control-panel-overlay control-panel-right">
<div class="joystick" id="throttle-pad">
<div class="joystick-ring"></div>
<div class="joystick-knob"></div>
</div>
</div>
</div>
<div class="value-readout" id="throttle-readout">0%</div>
</div>
</section>
<section class="actions">
<button id="stop-button" class="stop-button">急停</button>
<button id="refresh-button" class="ghost-button">刷新状态</button>
</section>
<section class="telemetry">
<div class="telemetry-card">
<div class="telemetry-title">底盘状态</div>
<pre id="status-output">加载中...</pre>
<div class="network-card status-card">
<div class="telemetry-title">实时状态</div>
<div id="status-left-cmd" class="network-meta">左指令: NA</div>
<div id="status-right-cmd" class="network-meta">右指令: NA</div>
<div id="status-stopped" class="network-meta">停车状态: NA</div>
<div id="status-age" class="network-meta">指令年龄: NA</div>
<div id="status-left-speed" class="network-meta">左轮反馈: NA</div>
<div id="status-right-speed" class="network-meta">右轮反馈: NA</div>
<div id="status-attitude" class="network-meta">姿态: NA</div>
<div id="status-voltage" class="network-meta">电源: NA</div>
</div>
<div class="telemetry-card">
<div class="telemetry-title">IMU / 底盘反馈</div>
<pre id="feedback-output">尚未读取</pre>
<div class="network-card rtk-card">
<div class="network-card-head">
<div class="telemetry-title">RTK</div>
<button id="rtk-mode-button" class="network-button network-button-inline" type="button">重进 WTB2</button>
</div>
<div id="rtk-device" class="network-meta">设备: /dev/serial/by-id/...if06</div>
<div id="rtk-gps" class="network-meta">GPS: NA</div>
<div id="rtk-gps-detail" class="network-meta">GPS 详情: NA</div>
<div id="rtk-waypoint" class="network-meta">航点: NA</div>
<div id="rtk-waypoint-detail" class="network-meta">航点进度: NA</div>
<div id="rtk-attitude" class="network-meta">姿态: NA</div>
<div id="rtk-gyro" class="network-meta">角速度: NA</div>
<div id="rtk-accel" class="network-meta">加速度: NA</div>
</div>
<div class="network-card wtb-card">
<div class="network-card-head">
<div class="telemetry-title">WTB</div>
</div>
<div id="wtb-device" class="network-meta">设备: /dev/serial/by-id/...ttyUSB0</div>
<div id="wtb-control" class="network-meta">控制: 模拟摇杆 / 中速</div>
<div id="wtb-speed" class="network-meta">电机速度: NA</div>
<div id="wtb-mapped" class="network-meta">底盘映射: NA</div>
<div id="wtb-route" class="network-meta">路线: NA</div>
<div id="wtb-state" class="network-meta">状态位: NA</div>
<div id="wtb-flags" class="network-meta">状态: NA</div>
</div>
</section>
<section class="network-panel">
<div class="network-card">
<div class="telemetry-title">Tailscale</div>
<div id="tailscale-ip" class="network-ip-card">NA</div>
</div>
<div class="network-card">
<div class="telemetry-title">AP 设置</div>
<div id="ap-ip" class="network-meta">IP: --</div>
<label class="field-label">
<span>启用 AP</span>
<input id="ap-enabled" type="checkbox" />
</label>
<label class="field-label">
<span>SSID</span>
<input id="ap-ssid" class="field-input" type="text" autocomplete="off" />
</label>
<label class="field-label">
<span>密码</span>
<input id="ap-password" class="field-input" type="password" autocomplete="new-password" placeholder="留空则保持原密码" />
</label>
<button id="ap-save" class="network-button" type="button">保存 AP</button>
</div>
<div class="network-card">
<div class="telemetry-title">STA 设置</div>
<div id="sta-ip" class="network-meta">IP: --</div>
<label class="field-label">
<span>启用 STA</span>
<input id="sta-enabled" type="checkbox" />
</label>
<label class="field-label">
<span>SSID</span>
<input id="sta-ssid" class="field-input" type="text" autocomplete="off" list="sta-scan-list" />
</label>
<label class="field-label">
<span>密码</span>
<input id="sta-password" class="field-input" type="password" autocomplete="new-password" placeholder="留空则保持当前密码" />
</label>
<button id="sta-save" class="network-button" type="button">保存 STA</button>
<datalist id="sta-scan-list"></datalist>
</div>
</section>
</main>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=20260513r"></script>
</body>
</html>
+671 -143
View File
@@ -1,14 +1,17 @@
:root {
--bg-top: #04131f;
--bg-bottom: #0d2f40;
--panel: rgba(11, 24, 33, 0.82);
--panel-border: rgba(165, 214, 255, 0.16);
--text-main: #eef8ff;
--text-soft: #9fb7c8;
--accent: #5ec8ff;
--accent-strong: #0ea5e9;
--bg-top: #07111a;
--bg-bottom: #123246;
--panel: rgba(7, 22, 32, 0.8);
--panel-strong: rgba(10, 28, 40, 0.92);
--panel-border: rgba(123, 200, 255, 0.14);
--text-main: #eef7fb;
--text-soft: #89a8bb;
--accent: #61d0ff;
--accent-strong: #0fb8e7;
--accent-faint: rgba(97, 208, 255, 0.14);
--danger: #ff6b5e;
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
--shadow: 0 22px 48px rgba(0, 0, 0, 0.24);
--battery-level-width: 0px;
}
* {
@@ -30,161 +33,563 @@ body {
body {
padding: env(safe-area-inset-top, 0) 0 env(safe-area-inset-bottom, 0);
background:
radial-gradient(circle at 20% 0%, rgba(97, 208, 255, 0.12), transparent 24%),
radial-gradient(circle at 100% 100%, rgba(10, 184, 231, 0.1), transparent 28%),
linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
}
.app-shell {
max-width: 920px;
max-width: 1120px;
margin: 0 auto;
padding: 18px 16px 32px;
padding: 14px 14px 20px;
}
.hero,
.gear-panel,
.fpv-card,
.toolbar,
.control-panel,
.telemetry-card,
.actions {
.gear-panel,
.status-strip,
.network-card,
.gamepad-bar {
backdrop-filter: blur(18px);
}
.hero {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 14px;
margin-bottom: 16px;
margin-bottom: 10px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
.fpv-panel {
margin-bottom: 12px;
}
h1 {
margin: 0;
font-size: clamp(28px, 6vw, 42px);
line-height: 1.05;
}
.subtitle {
margin: 12px 0 0;
max-width: 34rem;
color: var(--text-soft);
line-height: 1.5;
}
.status-card,
.status-strip,
.fpv-card,
.telemetry-card,
.control-panel,
.toolbar,
.gear-panel,
.actions {
.network-card,
.gamepad-bar {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 24px;
box-shadow: var(--shadow);
}
.status-card {
padding: 18px;
}
.status-row {
.status-strip {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
padding: 10px;
}
.status-row:last-child {
.fpv-card {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
background: #02070b;
}
.fpv-card::after {
content: "FPV";
position: absolute;
top: 12px;
left: 12px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(2, 10, 16, 0.58);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-main);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
}
.fpv-fullscreen {
position: absolute;
top: 12px;
right: 12px;
width: 38px;
height: 38px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(2, 10, 16, 0.58);
z-index: 5;
}
.fpv-fullscreen::before,
.fpv-fullscreen::after {
content: "";
position: absolute;
inset: 11px;
border: 2px solid rgba(255, 255, 255, 0.92);
border-left: 0;
border-bottom: 0;
}
.badge {
padding: 6px 12px;
.fpv-fullscreen::after {
inset: 11px;
transform: rotate(180deg);
}
.fpv-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #02070b;
}
.fpv-video {
position: absolute;
inset: 0;
z-index: 1;
}
.hidden {
display: none !important;
}
.fpv-guides {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.fpv-controls {
position: absolute;
inset: auto 0 0;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 18px 18px;
z-index: 4;
pointer-events: none;
}
.control-panel-overlay {
position: relative;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none !important;
pointer-events: auto;
border-radius: 0;
}
.control-panel-left,
.control-panel-right {
width: clamp(110px, 18vw, 170px);
}
.fpv-guide {
position: absolute;
bottom: 9%;
width: 30%;
height: 3px;
border-radius: 999px;
font-size: 13px;
background: linear-gradient(90deg, rgba(255, 88, 88, 0.18), rgba(255, 88, 88, 0.96));
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05),
0 0 18px rgba(255, 88, 88, 0.28);
transform-origin: center;
}
.badge.online {
background: rgba(34, 197, 94, 0.18);
color: #8cf2a6;
.fpv-guide-left {
left: 18%;
transform: rotate(-42deg);
}
.badge.offline {
background: rgba(255, 107, 94, 0.18);
color: #ffb3aa;
.fpv-guide-right {
right: 18%;
transform: rotate(42deg);
background: linear-gradient(90deg, rgba(255, 88, 88, 0.96), rgba(255, 88, 88, 0.18));
}
.fpv-guide-anchor {
position: absolute;
bottom: 29%;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 50%;
background: rgba(255, 88, 88, 0.98);
box-shadow:
0 0 0 3px rgba(255, 88, 88, 0.18),
0 0 16px rgba(255, 88, 88, 0.38);
}
.fpv-guide-anchor-left {
left: 43%;
}
.fpv-guide-anchor-right {
left: 57%;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 46px;
padding: 10px 14px;
border-radius: 999px;
font-size: 15px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01)),
var(--panel-strong);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.status-pill-button {
appearance: none;
font: inherit;
color: inherit;
cursor: pointer;
}
.toolbar-pill {
min-width: 96px;
justify-content: center;
}
.status-online {
min-width: 48px;
justify-content: center;
padding: 10px 12px;
}
.status-online::before,
.status-mode::before,
.status-gamepad::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-online.online {
background:
linear-gradient(180deg, rgba(34, 197, 94, 0.16), rgba(34, 197, 94, 0.1)),
var(--panel-strong);
}
.status-online.online::before {
background: #45e27d;
box-shadow: 0 0 0 0 rgba(69, 226, 125, 0.6);
animation: pulse-online 1.8s infinite;
}
.status-online.offline {
background:
linear-gradient(180deg, rgba(255, 107, 94, 0.16), rgba(255, 107, 94, 0.1)),
var(--panel-strong);
}
.status-online.offline::before {
background: #ff7d70;
}
.status-battery {
color: var(--text-main);
gap: 9px;
}
.battery-icon {
position: relative;
width: 22px;
height: 12px;
flex: 0 0 auto;
}
.battery-shell {
position: absolute;
inset: 0 3px 0 0;
border-radius: 3px;
border: 1.5px solid rgba(97, 208, 255, 0.95);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.battery-cap {
position: absolute;
right: 0;
top: 3px;
width: 2px;
height: 6px;
border-radius: 0 2px 2px 0;
background: rgba(97, 208, 255, 0.95);
}
.battery-level {
position: absolute;
left: 2px;
top: 2px;
bottom: 2px;
width: var(--battery-level-width);
border-radius: 2px;
background: rgba(97, 208, 255, 0.88);
}
#battery-text {
min-width: 54px;
}
.status-mode {
color: #dff7ff;
}
.status-mode::before {
width: 14px;
height: 14px;
border-radius: 4px;
background: linear-gradient(180deg, rgba(97, 208, 255, 0.95), rgba(15, 184, 231, 0.8));
box-shadow: 0 0 10px rgba(97, 208, 255, 0.2);
}
.status-gamepad {
min-width: 52px;
justify-content: center;
padding: 10px 12px;
}
.status-gamepad::before {
width: 20px;
height: 12px;
border-radius: 8px 8px 10px 10px;
background:
radial-gradient(circle at 27% 56%, rgba(255, 255, 255, 0.68) 0 1px, transparent 2px),
radial-gradient(circle at 73% 42%, rgba(255, 255, 255, 0.68) 0 1px, transparent 2px),
radial-gradient(circle at 66% 58%, rgba(255, 255, 255, 0.68) 0 1px, transparent 2px),
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.82) 0 2px, transparent 3px),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: inset 0 0 0 1px rgba(97, 208, 255, 0.05);
}
.status-gamepad::after {
content: "";
display: none;
}
.status-gamepad.connected::before {
border-color: rgba(69, 226, 125, 0.45);
box-shadow:
0 0 10px rgba(69, 226, 125, 0.2),
inset 0 0 0 1px rgba(69, 226, 125, 0.14);
opacity: 1;
}
.status-gamepad.disconnected::before {
opacity: 0.38;
}
#fpv-card:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
aspect-ratio: auto;
border-radius: 0;
border: 0;
}
#fpv-card:fullscreen::after {
top: 18px;
left: 18px;
}
#fpv-card:fullscreen .fpv-fullscreen {
top: 18px;
right: 18px;
}
@keyframes pulse-online {
0% {
box-shadow: 0 0 0 0 rgba(69, 226, 125, 0.5);
}
70% {
box-shadow: 0 0 0 10px rgba(69, 226, 125, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(69, 226, 125, 0);
}
}
.toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.gear-panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
}
.gamepad-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
margin-bottom: 16px;
padding: 10px 12px;
}
.gamepad-status {
font-size: 14px;
font-weight: 700;
color: var(--text-soft);
}
.gamepad-status.connected {
color: #9cf2ba;
}
.gamepad-status.disconnected {
color: #ffb3aa;
}
.toolbar-label {
color: var(--text-soft);
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.gear-button,
.stop-button,
.ghost-button {
border: 0;
border-radius: 18px;
border-radius: 16px;
font: inherit;
color: var(--text-main);
}
.gear-button {
padding: 15px 12px;
background: rgba(255, 255, 255, 0.05);
min-width: 104px;
padding: 12px 18px;
background: rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(15, 184, 231, 0.18);
font-weight: 700;
transition:
background 160ms ease,
box-shadow 160ms ease,
color 160ms ease;
}
.gear-button.active {
background: linear-gradient(135deg, rgba(94, 200, 255, 0.32), rgba(14, 165, 233, 0.62));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
.gear-button.gear-low,
.toolbar-pill.gear-low {
color: #b7ffd4;
background:
linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.5)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(34, 197, 94, 0.16);
}
.control-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
.gear-button.gear-medium,
.toolbar-pill.gear-medium {
color: #dff7ff;
background:
linear-gradient(135deg, rgba(97, 208, 255, 0.22), rgba(15, 184, 231, 0.52)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(15, 184, 231, 0.18);
}
.gear-button.gear-high,
.toolbar-pill.gear-high {
color: #ffe5cc;
background:
linear-gradient(135deg, rgba(249, 115, 22, 0.22), rgba(239, 68, 68, 0.5)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(249, 115, 22, 0.18);
}
.gear-button.mode-touch,
.toolbar-pill.mode-touch {
color: #dff7ff;
background:
linear-gradient(135deg, rgba(97, 208, 255, 0.22), rgba(15, 184, 231, 0.52)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(15, 184, 231, 0.18);
}
.gear-button.mode-gamepad,
.toolbar-pill.mode-gamepad {
color: #ece8ff;
background:
linear-gradient(135deg, rgba(129, 140, 248, 0.24), rgba(79, 70, 229, 0.52)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(99, 102, 241, 0.2);
}
.gear-button.mode-wtb,
.toolbar-pill.mode-wtb {
color: #ffe9c7;
background:
linear-gradient(135deg, rgba(251, 191, 36, 0.24), rgba(249, 115, 22, 0.52)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(245, 158, 11, 0.2);
}
.control-panel {
padding: 18px 12px 20px;
padding: 16px 12px 16px;
text-align: center;
background:
radial-gradient(circle at 50% 20%, rgba(97, 208, 255, 0.09), transparent 42%),
var(--panel);
}
.panel-label {
margin-bottom: 14px;
margin-bottom: 12px;
color: var(--text-soft);
font-size: 14px;
letter-spacing: 0.08em;
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.joystick {
position: relative;
width: min(40vw, 260px);
height: min(40vw, 260px);
max-width: 100%;
width: 100%;
aspect-ratio: 1 / 1;
margin: 0 auto;
border-radius: 50%;
touch-action: none;
background:
radial-gradient(circle at 50% 32%, rgba(255, 255, 255, 0.09), transparent 42%),
radial-gradient(circle at center, rgba(94, 200, 255, 0.14), rgba(255, 255, 255, 0.02));
background: transparent;
}
.joystick-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 0 0 14px rgba(255, 255, 255, 0.025),
inset 0 0 40px rgba(94, 200, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 0 1px rgba(97, 208, 255, 0.08);
}
.joystick::before,
@@ -212,55 +617,136 @@ h1 {
position: absolute;
left: 50%;
top: 50%;
width: 34%;
height: 34%;
width: 36%;
aspect-ratio: 1 / 1;
transform: translate(-50%, -50%);
border-radius: 50%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.85), rgba(94, 200, 255, 0.75) 34%, rgba(13, 47, 64, 0.95));
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.86), rgba(97, 208, 255, 0.78) 34%, rgba(10, 38, 54, 0.98));
box-shadow:
0 10px 20px rgba(0, 0, 0, 0.35),
inset 0 0 18px rgba(255, 255, 255, 0.2);
transition: transform 0.08s linear;
}
.value-readout {
margin-top: 14px;
color: var(--accent);
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.actions {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
padding: 12px;
margin: 16px 0;
}
.stop-button {
padding: 16px;
background: linear-gradient(135deg, #ff7a59, #c43d2f);
font-size: 18px;
font-weight: 700;
}
.ghost-button {
padding: 16px;
background: rgba(255, 255, 255, 0.06);
0 8px 14px rgba(0, 0, 0, 0.24),
inset 0 0 14px rgba(255, 255, 255, 0.18);
transition: none;
}
.telemetry {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.telemetry-card {
.network-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.network-card {
padding: 16px;
}
.network-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.network-meta {
margin-top: 8px;
color: var(--accent);
font-size: 13px;
font-variant-numeric: tabular-nums;
line-height: 1.45;
overflow-wrap: anywhere;
word-break: break-word;
}
.network-ip-card {
margin-top: 6px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(2, 10, 16, 0.38);
border: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-main);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.field-label {
display: grid;
grid-template-columns: 110px 1fr;
align-items: center;
gap: 12px;
margin-top: 12px;
color: var(--text-soft);
font-size: 14px;
}
.field-input {
width: 100%;
min-height: 40px;
padding: 10px 12px;
color: var(--text-main);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
outline: none;
}
.field-input:focus {
border-color: rgba(97, 208, 255, 0.5);
box-shadow: 0 0 0 3px rgba(97, 208, 255, 0.12);
}
.network-button {
width: 100%;
margin-top: 14px;
min-height: 44px;
border: 0;
border-radius: 16px;
color: var(--text-main);
font: inherit;
font-weight: 700;
background:
linear-gradient(135deg, rgba(97, 208, 255, 0.22), rgba(15, 184, 231, 0.52)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(15, 184, 231, 0.18);
}
.network-button-inline {
width: auto;
min-height: 36px;
margin-top: 0;
padding: 0 14px;
border-radius: 999px;
font-size: 13px;
}
.network-button-muted {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.12);
}
.network-button-active {
background:
linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.5)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 8px 18px rgba(22, 163, 74, 0.18);
}
.telemetry-title {
margin-bottom: 10px;
color: var(--text-soft);
@@ -269,43 +755,85 @@ h1 {
letter-spacing: 0.12em;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #d6f1ff;
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 820px) {
.hero,
.control-grid,
.toolbar,
.telemetry,
.actions {
.network-panel {
grid-template-columns: 1fr;
}
.joystick {
width: min(42vw, 280px);
height: min(42vw, 280px);
.status-strip {
justify-content: space-between;
}
.control-panel-left,
.control-panel-right {
width: clamp(96px, 24vw, 150px);
}
}
@media (orientation: landscape) and (max-width: 980px) {
.app-shell {
max-width: 1180px;
padding: 10px 12px 16px;
}
.hero {
margin-bottom: 8px;
}
.toolbar {
margin-bottom: 10px;
}
.fpv-panel {
margin-bottom: 10px;
}
.control-panel-left,
.control-panel-right {
width: clamp(92px, 16vw, 145px);
}
}
@media (max-width: 560px) {
.app-shell {
padding: 14px 12px 24px;
padding: 12px 10px 18px;
}
.status-card,
.status-strip,
.fpv-card,
.control-panel,
.telemetry-card {
.toolbar,
.network-card,
.gamepad-bar {
border-radius: 20px;
}
.joystick {
width: min(44vw, 220px);
height: min(44vw, 220px);
.gear-panel {
padding: 10px 12px;
}
.gear-button {
min-width: 88px;
padding: 11px 14px;
}
.control-panel-left,
.control-panel-right {
width: clamp(88px, 26vw, 132px);
}
.status-pill {
min-height: 42px;
padding: 9px 12px;
font-size: 14px;
}
.field-label {
grid-template-columns: 1fr;
gap: 8px;
}
}