2 Commits
Author SHA1 Message Date
clear c72551558f Release 1.0.4: add HTTPS and gamepad support 2026-05-11 15:11:03 +08:00
clear 9fb7908c15 Add AP/STA settings panel to mobile web UI 2026-05-11 12:50:51 +08:00
9 changed files with 795 additions and 18 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ __pycache__/
.DS_Store
UGV01MCU_V0.9/build/
certs/
+23 -3
View File
@@ -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
View File
@@ -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,
)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/root/clear_tmp/ugv01"
CERT_DIR="${ROOT_DIR}/certs"
CA_KEY="${CERT_DIR}/ugv01-root-ca.key"
CA_CERT="${CERT_DIR}/ugv01-root-ca.crt"
SERVER_KEY="${CERT_DIR}/ugv01-server.key"
SERVER_CSR="${CERT_DIR}/ugv01-server.csr"
SERVER_CERT="${CERT_DIR}/ugv01-server.crt"
SERVER_EXT="${CERT_DIR}/server-ext.cnf"
mkdir -p "${CERT_DIR}"
chmod 700 "${CERT_DIR}"
IP_LIST=()
while read -r _iface cidr; do
ip="${cidr%%/*}"
if [[ -n "${ip}" ]]; then
IP_LIST+=("${ip}")
fi
done < <(ip -4 -o addr show up scope global | awk '{print $2, $4}')
{
echo "authorityKeyIdentifier=keyid,issuer"
echo "basicConstraints=CA:FALSE"
echo "keyUsage = digitalSignature, keyEncipherment"
echo "extendedKeyUsage = serverAuth"
echo "subjectAltName = @alt_names"
echo
echo "[alt_names]"
echo "DNS.1 = localhost"
echo "DNS.2 = orangepi4pro"
echo "IP.1 = 127.0.0.1"
index=2
for ip in "${IP_LIST[@]}"; do
echo "IP.${index} = ${ip}"
index=$((index + 1))
done
} > "${SERVER_EXT}"
if [[ ! -f "${CA_KEY}" || ! -f "${CA_CERT}" ]]; then
openssl genrsa -out "${CA_KEY}" 4096
openssl req -x509 -new -nodes -key "${CA_KEY}" -sha256 -days 3650 \
-subj "/CN=UGV01 Local Root CA" \
-out "${CA_CERT}"
fi
openssl genrsa -out "${SERVER_KEY}" 2048
openssl req -new -key "${SERVER_KEY}" \
-subj "/CN=UGV01 HTTPS" \
-out "${SERVER_CSR}"
openssl x509 -req -in "${SERVER_CSR}" \
-CA "${CA_CERT}" -CAkey "${CA_KEY}" -CAcreateserial \
-out "${SERVER_CERT}" -days 825 -sha256 \
-extfile "${SERVER_EXT}"
chmod 600 "${CA_KEY}" "${SERVER_KEY}"
chmod 644 "${CA_CERT}" "${SERVER_CERT}"
echo "CA cert: ${CA_CERT}"
echo "Server cert: ${SERVER_CERT}"
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
class NetworkManagerError(RuntimeError):
pass
@dataclass
class WifiConnection:
name: str
uuid: str
conn_type: str
device: str
active: bool
class UGV01NetworkManager:
AP_CONNECTION_NAME = "Clear_UGV01"
AP_IFACE = "ap0"
STA_IFACE = "wlan0"
AP_SERVICE_SCRIPT = "/usr/local/sbin/ugv01-ap.sh"
def _run(self, args: list[str], *, check: bool = True) -> str:
completed = subprocess.run(args, capture_output=True, text=True)
if check and completed.returncode != 0:
message = completed.stderr.strip() or completed.stdout.strip() or "command failed"
raise NetworkManagerError(message)
return completed.stdout
def _nmcli_fields(self, fields: list[str], *rest: str) -> list[list[str]]:
output = self._run(["nmcli", "-t", "-f", ",".join(fields), *rest], check=False)
rows: list[list[str]] = []
for line in output.splitlines():
if not line.strip():
continue
rows.append(line.split(":"))
return rows
def list_connections(self) -> list[WifiConnection]:
rows = self._nmcli_fields(["NAME", "UUID", "TYPE", "DEVICE", "ACTIVE"], "connection", "show")
connections: list[WifiConnection] = []
for row in rows:
while len(row) < 5:
row.append("")
connections.append(
WifiConnection(
name=row[0],
uuid=row[1],
conn_type=row[2],
device=row[3],
active=row[4] == "yes",
)
)
return connections
def device_status(self) -> list[dict[str, str]]:
rows = self._nmcli_fields(["DEVICE", "TYPE", "STATE", "CONNECTION"], "device", "status")
devices: list[dict[str, str]] = []
for row in rows:
while len(row) < 4:
row.append("")
devices.append(
{
"device": row[0],
"type": row[1],
"state": row[2],
"connection": row[3],
}
)
return devices
def _connection_value(self, connection_name: str, field: str) -> str:
output = self._run(["nmcli", "-s", "-g", field, "connection", "show", connection_name], check=False)
return output.strip()
def _wifi_connection_names(self) -> set[str]:
return {
connection.name
for connection in self.list_connections()
if connection.conn_type == "802-11-wireless"
}
def _ipv4_address(self, iface: str) -> str:
output = self._run(["ip", "-4", "-o", "addr", "show", "dev", iface], check=False)
for line in output.splitlines():
parts = line.split()
if "inet" in parts:
address = parts[parts.index("inet") + 1]
return address.split("/", 1)[0]
return ""
def get_ap_config(self) -> dict[str, object]:
connection_names = self._wifi_connection_names()
configured = self.AP_CONNECTION_NAME in connection_names
ssid = self._connection_value(self.AP_CONNECTION_NAME, "802-11-wireless.ssid") if configured else ""
device = next((item for item in self.device_status() if item["device"] == self.AP_IFACE), None)
return {
"connection_name": self.AP_CONNECTION_NAME,
"enabled": bool(device and device["state"] == "connected"),
"device": self.AP_IFACE,
"ssid": ssid,
"ip_address": self._ipv4_address(self.AP_IFACE),
}
def get_sta_config(self) -> dict[str, object]:
device = next((item for item in self.device_status() if item["device"] == self.STA_IFACE), None)
current_name = device["connection"] if device and device["connection"] else ""
current_ssid = ""
if current_name and current_name != "--":
current_ssid = self._connection_value(current_name, "802-11-wireless.ssid")
return {
"enabled": bool(device and device["state"] == "connected"),
"device": self.STA_IFACE,
"connection_name": current_name,
"ssid": current_ssid or current_name,
"ip_address": self._ipv4_address(self.STA_IFACE),
}
def scan_sta_networks(self, *, rescan: str = "no") -> list[dict[str, str]]:
output = self._run(
[
"nmcli",
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
self.STA_IFACE,
"--rescan",
rescan,
],
check=False,
)
networks: list[dict[str, str]] = []
seen: set[tuple[str, str, str]] = set()
for line in output.splitlines():
if not line.strip():
continue
row = line.split(":")
while len(row) < 3:
row.append("")
key = (row[0], row[1], row[2])
if not row[0].strip() or key in seen:
continue
seen.add(key)
networks.append({"ssid": row[0], "signal": row[1], "security": row[2]})
return networks
def snapshot(self) -> dict[str, object]:
return {"ap": self.get_ap_config(), "sta": self.get_sta_config()}
def apply_ap(self, *, enabled: bool, ssid: str, password: str) -> dict[str, object]:
ssid = ssid.strip()
if enabled:
if not ssid:
raise NetworkManagerError("AP SSID 不能为空")
if self.AP_CONNECTION_NAME not in self._wifi_connection_names():
self._run(
[
"nmcli",
"connection",
"add",
"type",
"wifi",
"ifname",
self.AP_IFACE,
"con-name",
self.AP_CONNECTION_NAME,
"autoconnect",
"yes",
"ssid",
ssid,
]
)
current_password = self._connection_value(self.AP_CONNECTION_NAME, "802-11-wireless-security.psk")
effective_password = password or current_password
if len(effective_password) < 8:
raise NetworkManagerError("AP 密码至少 8 位")
self._run(
[
"nmcli",
"connection",
"modify",
self.AP_CONNECTION_NAME,
"connection.interface-name",
self.AP_IFACE,
"802-11-wireless.mode",
"ap",
"802-11-wireless.band",
"bg",
"802-11-wireless.ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
effective_password,
"ipv4.method",
"shared",
"ipv4.addresses",
"192.168.50.1/24",
"ipv6.method",
"ignore",
"connection.autoconnect",
"yes",
]
)
self._run([self.AP_SERVICE_SCRIPT, "restart"])
else:
self._run([self.AP_SERVICE_SCRIPT, "stop"], check=False)
return self.get_ap_config()
def apply_sta(self, *, enabled: bool, ssid: str, password: str) -> dict[str, object]:
ssid = ssid.strip()
if not enabled:
self._run(["nmcli", "device", "disconnect", self.STA_IFACE], check=False)
return self.get_sta_config()
if not ssid:
raise NetworkManagerError("STA SSID 不能为空")
connection_names = self._wifi_connection_names()
if ssid in connection_names:
modify_args = ["nmcli", "connection", "modify", ssid, "connection.interface-name", self.STA_IFACE]
if password:
modify_args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password])
self._run(modify_args)
self._run(["nmcli", "connection", "up", ssid, "ifname", self.STA_IFACE])
else:
args = [
"nmcli",
"device",
"wifi",
"connect",
ssid,
"ifname",
self.STA_IFACE,
"name",
ssid,
]
if password:
args.extend(["password", password])
self._run(args)
return self.get_sta_config()
+66
View File
@@ -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()
+226
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}