Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0850f54b4 | ||
|
|
46da5a72e4 | ||
|
|
1eee9394c3 | ||
|
|
c72551558f | ||
|
|
9fb7908c15 | ||
|
|
35e015ed63 |
No files matched your search
@@ -5,3 +5,4 @@ __pycache__/
|
||||
|
||||
.DS_Store
|
||||
UGV01MCU_V0.9/build/
|
||||
certs/
|
||||
@@ -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 已通过命令关闭,并设置为开机关闭。
|
||||
- 已清理调试期加入的黑匣子、异常抓拍与调试接口,保留正式运行逻辑。
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
Executable
+63
@@ -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}"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+113
-60
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user