Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72551558f | ||
|
|
9fb7908c15 |
No files matched your search
@@ -5,3 +5,4 @@ __pycache__/
|
||||
|
||||
.DS_Store
|
||||
UGV01MCU_V0.9/build/
|
||||
certs/
|
||||
@@ -12,7 +12,7 @@ UGV01 履带差速小车串口控制工程,提供底盘协议封装、手机
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 当前发布版本: `1.0.3`
|
||||
- 当前发布版本: `1.0.4`
|
||||
- 控制方式: 基于底盘协议 `T=1` 的差速控制
|
||||
- Web 界面: 手机横屏优先的双摇杆控制台
|
||||
|
||||
@@ -34,6 +34,8 @@ UGV01 履带差速小车串口控制工程,提供底盘协议封装、手机
|
||||
- MCU 连续反馈流接入
|
||||
- 顶部状态条显示在线状态与 3S 电池估算百分比
|
||||
- 车载 AP 接入: `Clear_UGV01`
|
||||
- 浏览器端 Xbox 手柄支持
|
||||
- HTTPS 访问支持与本地 CA 证书下载
|
||||
|
||||
## Web 控制
|
||||
|
||||
@@ -46,7 +48,7 @@ UGV01 履带差速小车串口控制工程,提供底盘协议封装、手机
|
||||
浏览器访问:
|
||||
|
||||
```text
|
||||
http://树莓派IP:8000
|
||||
https://树莓派IP:8000
|
||||
```
|
||||
|
||||
通过车载 AP 访问:
|
||||
@@ -54,7 +56,19 @@ http://树莓派IP:8000
|
||||
```text
|
||||
SSID: Clear_UGV01
|
||||
Password: Bone1644
|
||||
URL: http://192.168.50.1:8000
|
||||
URL: https://192.168.50.1:8000
|
||||
```
|
||||
|
||||
STA 网络访问示例:
|
||||
|
||||
```text
|
||||
https://192.168.101.177:8000
|
||||
```
|
||||
|
||||
本地 CA 证书下载:
|
||||
|
||||
```text
|
||||
https://192.168.50.1:8000/ca.crt
|
||||
```
|
||||
|
||||
界面特点:
|
||||
@@ -64,6 +78,10 @@ URL: http://192.168.50.1:8000
|
||||
- 单按钮循环切换低速 / 中速 / 高速
|
||||
- 顶部状态条仅显示在线状态和电量
|
||||
- 布局针对手机横屏收紧优化
|
||||
- 底部提供 AP / STA 开启、关闭与参数修改卡片
|
||||
- AP / STA 设置卡片显示当前 IP 地址
|
||||
- 支持检测并接入 Xbox 手柄
|
||||
- 手柄只映射两个摇杆:左摇杆方向,右摇杆油门
|
||||
|
||||
## systemd
|
||||
|
||||
@@ -115,3 +133,5 @@ ROS 风格控制:
|
||||
- 当前电量百分比为基于 `3S / 12.6V 满电` 的估算值,MCU 原始反馈字段是电压 `v`。
|
||||
- Web 主界面显示的是精简后的关键信息;如需完整 IMU 或底盘原始字段,可通过接口继续扩展。
|
||||
- `speed` 和 `ros` 支持 `--duration`,到时后会自动发送停车指令。
|
||||
- AP / STA 设置页默认不回显现有密码;密码框留空表示保持原密码不变。
|
||||
- HTTPS 当前使用本地 CA 签发的证书;若希望浏览器将其视为受信任来源,需要安装并信任 `ca.crt`。
|
||||
+22
-8
@@ -1,15 +1,29 @@
|
||||
#!/opt/miniconda3/envs/py310/bin/python
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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(
|
||||
"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,
|
||||
**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,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()
|
||||
@@ -4,16 +4,33 @@ from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .controller import UGV01Controller, UGV01FeedbackService, UGV01MotionService
|
||||
from .network_manager import NetworkManagerError, UGV01NetworkManager
|
||||
|
||||
|
||||
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"
|
||||
|
||||
controller = UGV01Controller()
|
||||
motion_service = UGV01MotionService(controller)
|
||||
feedback_service = UGV01FeedbackService(controller)
|
||||
network_manager = UGV01NetworkManager()
|
||||
|
||||
|
||||
class APConfigPayload(BaseModel):
|
||||
enabled: bool
|
||||
ssid: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
class STAConfigPayload(BaseModel):
|
||||
enabled: bool
|
||||
ssid: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -36,6 +53,13 @@ async def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@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():
|
||||
payload = motion_service.status()
|
||||
@@ -88,6 +112,48 @@ async def base_feedback_summary():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/network")
|
||||
async def network_snapshot():
|
||||
try:
|
||||
return {"ok": True, **network_manager.snapshot()}
|
||||
except NetworkManagerError as exc:
|
||||
raise HTTPException(status_code=500, 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):
|
||||
await websocket.accept()
|
||||
|
||||
@@ -8,16 +8,33 @@ const state = {
|
||||
sendIntervalMs: 33,
|
||||
driveKeepaliveId: null,
|
||||
driveKeepaliveMs: 40,
|
||||
gamepadAnimationId: null,
|
||||
gamepadIndex: null,
|
||||
gamepadSteeringEl: null,
|
||||
gamepadThrottleEl: null,
|
||||
reconnectId: null,
|
||||
statusRequestInFlight: false,
|
||||
networkRequestInFlight: false,
|
||||
telemetryId: null,
|
||||
};
|
||||
|
||||
const refs = {
|
||||
badge: document.getElementById("connection-badge"),
|
||||
gearCycleButton: document.getElementById("gear-cycle-button"),
|
||||
gamepadStatus: document.getElementById("gamepad-status"),
|
||||
batteryPercent: document.getElementById("battery-percent"),
|
||||
statusOutput: document.getElementById("status-output"),
|
||||
apIp: document.getElementById("ap-ip"),
|
||||
apEnabled: document.getElementById("ap-enabled"),
|
||||
apSsid: document.getElementById("ap-ssid"),
|
||||
apPassword: document.getElementById("ap-password"),
|
||||
apSave: document.getElementById("ap-save"),
|
||||
staIp: document.getElementById("sta-ip"),
|
||||
staEnabled: document.getElementById("sta-enabled"),
|
||||
staSsid: document.getElementById("sta-ssid"),
|
||||
staPassword: document.getElementById("sta-password"),
|
||||
staSave: document.getElementById("sta-save"),
|
||||
staScanList: document.getElementById("sta-scan-list"),
|
||||
};
|
||||
|
||||
const gearLabels = {
|
||||
@@ -27,6 +44,8 @@ const gearLabels = {
|
||||
};
|
||||
|
||||
const gearOrder = ["low", "medium", "high"];
|
||||
const GAMEPAD_STEERING_DEADZONE = 0.14;
|
||||
const GAMEPAD_THROTTLE_DEADZONE = 0.18;
|
||||
|
||||
function setConnection(online) {
|
||||
state.connected = online;
|
||||
@@ -35,12 +54,38 @@ function setConnection(online) {
|
||||
refs.badge.classList.toggle("offline", !online);
|
||||
}
|
||||
|
||||
function setGamepadStatus(text, connected) {
|
||||
refs.gamepadStatus.textContent = text;
|
||||
refs.gamepadStatus.classList.toggle("connected", connected);
|
||||
refs.gamepadStatus.classList.toggle("disconnected", !connected);
|
||||
}
|
||||
|
||||
function updateReadouts() {
|
||||
refs.gearCycleButton.textContent = gearLabels[state.gear];
|
||||
refs.gearCycleButton.classList.remove("gear-low", "gear-medium", "gear-high");
|
||||
refs.gearCycleButton.classList.add(`gear-${state.gear}`);
|
||||
}
|
||||
|
||||
function applyDeadzone(value, deadzone) {
|
||||
const clipped = Math.max(-1, Math.min(1, value));
|
||||
if (Math.abs(clipped) <= deadzone) {
|
||||
return 0;
|
||||
}
|
||||
const sign = clipped >= 0 ? 1 : -1;
|
||||
return sign * ((Math.abs(clipped) - deadzone) / (1 - deadzone));
|
||||
}
|
||||
|
||||
function updateVirtualKnob(element, x, y) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const knob = element.querySelector(".joystick-knob");
|
||||
const rect = element.getBoundingClientRect();
|
||||
const radius = rect.width / 2;
|
||||
const maxOffset = radius * 0.62;
|
||||
knob.style.transform = `translate(calc(-50% + ${x * maxOffset}px), calc(-50% + ${y * maxOffset}px))`;
|
||||
}
|
||||
|
||||
function fmtNum(value, digits = 3) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "--";
|
||||
}
|
||||
@@ -106,6 +151,87 @@ async function refreshStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function populateNetwork(network) {
|
||||
if (!network) {
|
||||
return;
|
||||
}
|
||||
if (network.ap) {
|
||||
refs.apIp.textContent = `IP: ${network.ap.ip_address || "--"}`;
|
||||
refs.apEnabled.checked = Boolean(network.ap.enabled);
|
||||
refs.apSsid.value = network.ap.ssid || "";
|
||||
refs.apPassword.value = "";
|
||||
}
|
||||
if (network.sta) {
|
||||
refs.staIp.textContent = `IP: ${network.sta.ip_address || "--"}`;
|
||||
refs.staEnabled.checked = Boolean(network.sta.enabled);
|
||||
refs.staSsid.value = network.sta.ssid || "";
|
||||
refs.staPassword.value = "";
|
||||
}
|
||||
if (Array.isArray(network.scan)) {
|
||||
refs.staScanList.innerHTML = "";
|
||||
network.scan.forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.ssid;
|
||||
refs.staScanList.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNetwork() {
|
||||
if (state.networkRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
state.networkRequestInFlight = true;
|
||||
try {
|
||||
const payload = await fetchJson("/api/network");
|
||||
populateNetwork(payload);
|
||||
} catch (error) {
|
||||
refs.statusOutput.textContent = `网络设置读取失败: ${error.message}`;
|
||||
} finally {
|
||||
state.networkRequestInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanStaNetworks() {
|
||||
try {
|
||||
const payload = await fetchJson("/api/network/scan");
|
||||
populateNetwork(payload);
|
||||
} catch (error) {
|
||||
refs.statusOutput.textContent = `STA 扫描失败: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNetwork(kind) {
|
||||
const isAP = kind === "ap";
|
||||
const button = isAP ? refs.apSave : refs.staSave;
|
||||
const payload = isAP
|
||||
? {
|
||||
enabled: refs.apEnabled.checked,
|
||||
ssid: refs.apSsid.value.trim(),
|
||||
password: refs.apPassword.value,
|
||||
}
|
||||
: {
|
||||
enabled: refs.staEnabled.checked,
|
||||
ssid: refs.staSsid.value.trim(),
|
||||
password: refs.staPassword.value,
|
||||
};
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = "保存中...";
|
||||
try {
|
||||
await fetchJson(`/api/network/${kind}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
refs.statusOutput.textContent = `网络设置保存失败: ${error.message}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function driveIsActive() {
|
||||
return Math.abs(state.throttle) > 0.01 || Math.abs(state.steering) > 0.01;
|
||||
}
|
||||
@@ -158,6 +284,73 @@ function stopVehicle() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyControlState({ steering, throttle, syncKnobs = false, forceSend = false }) {
|
||||
const wasActive = driveIsActive();
|
||||
state.steering = Number(steering.toFixed(3));
|
||||
state.throttle = Number(throttle.toFixed(3));
|
||||
|
||||
if (syncKnobs) {
|
||||
updateVirtualKnob(state.gamepadSteeringEl, state.steering, 0);
|
||||
updateVirtualKnob(state.gamepadThrottleEl, 0, -state.throttle);
|
||||
}
|
||||
|
||||
if (!driveIsActive()) {
|
||||
if (wasActive) {
|
||||
stopVehicle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendDrive(forceSend);
|
||||
}
|
||||
|
||||
function activeGamepad() {
|
||||
if (state.gamepadIndex == null) {
|
||||
return null;
|
||||
}
|
||||
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
return pads[state.gamepadIndex] || null;
|
||||
}
|
||||
|
||||
function releaseGamepadControl() {
|
||||
state.gamepadIndex = null;
|
||||
setGamepadStatus("未连接", false);
|
||||
if (state.gamepadAnimationId !== null) {
|
||||
cancelAnimationFrame(state.gamepadAnimationId);
|
||||
state.gamepadAnimationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pollGamepad() {
|
||||
const pad = activeGamepad();
|
||||
if (!pad || !pad.connected) {
|
||||
releaseGamepadControl();
|
||||
stopVehicle();
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSteering = pad.axes[0] || 0;
|
||||
const rawThrottle = -(pad.axes[3] ?? pad.axes[1] ?? 0);
|
||||
const steering = applyDeadzone(rawSteering, GAMEPAD_STEERING_DEADZONE);
|
||||
const throttle = applyDeadzone(rawThrottle, GAMEPAD_THROTTLE_DEADZONE);
|
||||
applyControlState({
|
||||
steering,
|
||||
throttle,
|
||||
syncKnobs: true,
|
||||
forceSend: false,
|
||||
});
|
||||
|
||||
state.gamepadAnimationId = requestAnimationFrame(pollGamepad);
|
||||
}
|
||||
|
||||
function connectGamepad(gamepad) {
|
||||
state.gamepadIndex = gamepad.index;
|
||||
setGamepadStatus("已连接", true);
|
||||
if (state.gamepadAnimationId === null) {
|
||||
state.gamepadAnimationId = requestAnimationFrame(pollGamepad);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/control`);
|
||||
@@ -208,6 +401,11 @@ function setupGearPanel() {
|
||||
|
||||
function bindJoystick(element, axis) {
|
||||
const knob = element.querySelector(".joystick-knob");
|
||||
if (axis === "steering") {
|
||||
state.gamepadSteeringEl = element;
|
||||
} else {
|
||||
state.gamepadThrottleEl = element;
|
||||
}
|
||||
let pointerId = null;
|
||||
|
||||
function updateFromEvent(clientX, clientY) {
|
||||
@@ -286,6 +484,25 @@ function setupActions() {
|
||||
}
|
||||
});
|
||||
window.addEventListener("beforeunload", stopVehicle);
|
||||
window.addEventListener("gamepadconnected", (event) => {
|
||||
connectGamepad(event.gamepad);
|
||||
});
|
||||
window.addEventListener("gamepaddisconnected", () => {
|
||||
releaseGamepadControl();
|
||||
stopVehicle();
|
||||
});
|
||||
}
|
||||
|
||||
function setupNetworkPanel() {
|
||||
refs.apSave.addEventListener("click", () => {
|
||||
saveNetwork("ap");
|
||||
});
|
||||
refs.staSave.addEventListener("click", () => {
|
||||
saveNetwork("sta");
|
||||
});
|
||||
refs.staSsid.addEventListener("focus", () => {
|
||||
scanStaNetworks();
|
||||
});
|
||||
}
|
||||
|
||||
function startTelemetryRefresh() {
|
||||
@@ -301,8 +518,17 @@ setupGearPanel();
|
||||
bindJoystick(document.getElementById("steering-pad"), "steering");
|
||||
bindJoystick(document.getElementById("throttle-pad"), "throttle");
|
||||
setupActions();
|
||||
setupNetworkPanel();
|
||||
updateReadouts();
|
||||
setGamepadStatus("未连接", false);
|
||||
connectWebSocket();
|
||||
startDriveKeepalive();
|
||||
startTelemetryRefresh();
|
||||
refreshStatus();
|
||||
refreshNetwork();
|
||||
if (navigator.getGamepads) {
|
||||
const existing = Array.from(navigator.getGamepads()).find((pad) => pad && pad.connected);
|
||||
if (existing) {
|
||||
connectGamepad(existing);
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -7,7 +7,7 @@
|
||||
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?v=20260511p" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260511w" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -23,6 +23,10 @@
|
||||
<span class="toolbar-label">速度挡位</span>
|
||||
<button id="gear-cycle-button" class="gear-button" type="button">中速</button>
|
||||
</div>
|
||||
<div class="gamepad-bar">
|
||||
<span class="toolbar-label">手柄</span>
|
||||
<strong id="gamepad-status" class="gamepad-status disconnected">未连接</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-grid">
|
||||
@@ -49,8 +53,47 @@
|
||||
<pre id="status-output">加载中...</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="network-panel">
|
||||
<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?v=20260511p"></script>
|
||||
<script src="/static/app.js?v=20260511w"></script>
|
||||
</body>
|
||||
</html>
|
||||
+101
-5
@@ -49,7 +49,9 @@ body {
|
||||
.control-panel,
|
||||
.telemetry-card,
|
||||
.gear-panel,
|
||||
.status-strip {
|
||||
.status-strip,
|
||||
.network-card,
|
||||
.gamepad-bar {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
@@ -61,7 +63,9 @@ body {
|
||||
.telemetry-card,
|
||||
.control-panel,
|
||||
.toolbar,
|
||||
.gear-panel {
|
||||
.gear-panel,
|
||||
.network-card,
|
||||
.gamepad-bar {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 24px;
|
||||
@@ -161,7 +165,9 @@ body {
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: block;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -173,6 +179,28 @@ body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.gamepad-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
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;
|
||||
@@ -322,6 +350,66 @@ body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.network-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.network-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.network-meta {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.telemetry-card {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
@@ -360,7 +448,8 @@ pre {
|
||||
.hero,
|
||||
.toolbar,
|
||||
.control-grid,
|
||||
.telemetry {
|
||||
.telemetry,
|
||||
.network-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -419,7 +508,9 @@ pre {
|
||||
.status-strip,
|
||||
.control-panel,
|
||||
.telemetry-card,
|
||||
.toolbar {
|
||||
.toolbar,
|
||||
.network-card,
|
||||
.gamepad-bar {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@@ -442,4 +533,9 @@ pre {
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user