feat: UI test mode + per-game port configuration

- Settings: UI test mode toggle generates simulated telemetry data (speed/RPM/gear/laps...)
- Simulated data broadcasts via WebSocket and forwards to downstream devices
- game_ports config: per-game independent port overrides (shown when unified port off)
- Settings UI: game port list with save button, hidden when unified port enabled
- Sidebar: switch game reads game_ports[gameId] fallback to default_port
This commit is contained in:
2026-07-26 17:49:18 +08:00
parent bbaef43cfd
commit b00e59d5bf
5 changed files with 130 additions and 4 deletions
+2
View File
@@ -18,6 +18,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
"sidebar_collapsed": False,
"telemetry_port": 20777,
"use_unified_port": True,
"game_ports": {},
"ui_test_mode": False,
"telemetry_host": "0.0.0.0",
"server_host": "0.0.0.0",
"server_port": 9527,
+12
View File
@@ -72,6 +72,18 @@ async def api_stop_telemetry():
return {"ok": True, "running": telemetry_listener.is_running}
@router.post("/test-mode/start")
async def api_start_test_mode():
telemetry_listener.start_test_mode()
return {"ok": True, "testing": True}
@router.post("/test-mode/stop")
async def api_stop_test_mode():
telemetry_listener.stop_test_mode()
return {"ok": True, "testing": False}
@router.get("/telemetry/latest")
async def api_latest_telemetry():
latest = telemetry_listener.latest_data
+74
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import random
import socket
import struct
import time
from typing import Any, Callable
@@ -58,6 +60,7 @@ class TelemetryListener:
self._last_packet_time: float = 0.0
self._packet_count: int = 0
self.forwarder = DataForwarder()
self._test_task: asyncio.Task | None = None
@property
def is_running(self) -> bool:
@@ -112,6 +115,7 @@ class TelemetryListener:
def stop(self):
self._running = False
self.stop_test_mode()
if self._transport:
self._transport.close()
self._transport = None
@@ -120,6 +124,76 @@ class TelemetryListener:
self.forwarder._sock = None
logger.info("Telemetry listener stopped")
@property
def is_testing(self) -> bool:
return self._test_task is not None and not self._test_task.done()
def start_test_mode(self):
if self._test_task and not self._test_task.done():
return
self._test_task = asyncio.ensure_future(self._test_loop())
logger.info("UI test mode started")
def stop_test_mode(self):
if self._test_task and not self._test_task.done():
self._test_task.cancel()
self._test_task = None
logger.info("UI test mode stopped")
async def _test_loop(self):
import math
phase = 0.0
gear = 1
gear_timer = 0
while True:
await asyncio.sleep(1.0 / 30.0)
phase += 0.06
gear_timer += 1
if gear_timer > 50:
gear_timer = 0
gear = (gear % 6) + 1
max_rpm = 8000.0
rpm = max_rpm * (0.15 + 0.55 * abs(math.sin(phase)))
speed_kmh = rpm / max_rpm * 320.0 + random.uniform(-5, 5)
throttle = max(0.0, min(1.0, 0.15 + 0.65 * abs(math.sin(phase + 0.5))))
brake = max(0.0, min(1.0, random.uniform(0, 0.4) if random.random() > 0.75 else 0))
steering = math.sin(phase * 0.25) * 0.5
fuel = max(0.0, 1.0 - phase * 0.008)
best_lap = 92.0 + random.uniform(-1, 1)
last_lap = best_lap + random.uniform(0.3, 2.5)
lap_time = last_lap * random.uniform(0.2, 0.85)
td = TelemetryData(
game_id=get_config().get("selected_game_id", "test"),
timestamp=time.time(),
speed_kmh=speed_kmh, speed_mph=speed_kmh * 0.621371,
rpm=rpm, max_rpm=max_rpm, gear=gear,
throttle=throttle, brake=brake, clutch=0.0, steering=steering,
lap_time=lap_time, best_lap=best_lap, last_lap=last_lap,
lap_number=int(phase) % 15, fuel=fuel,
boost=random.uniform(0, 1.5),
horsepower=rpm / max_rpm * 400, torque=rpm / max_rpm * 500,
engine_temp=80 + random.uniform(-10, 20),
oil_temp=90 + random.uniform(-5, 15),
raw={"test_mode": True},
)
self._last_packet_time = time.time()
self._packet_count += 1
self._latest_data = td
from server.websocket import ws_manager
ws_manager.push_telemetry(td)
if self.forwarder._sock and self.forwarder._targets:
buf = struct.pack("<32s", b"\x01" + b"\x00" * 323)
self.forwarder.forward(buf)
for cb in self._callbacks:
try: cb(td)
except Exception: pass
def _get_parser(self, key: str) -> BaseParser:
if key not in self._parser_cache:
cls = PARSER_MAP.get(key)
+3 -3
View File
@@ -83,9 +83,9 @@ const Sidebar = {
async _selectGame(gameId) {
await API.updateConfig({ selected_game_id: gameId });
const cfg = await API.getConfig();
const game = this._gameData.find(g => g.id === gameId);
if (game && !cfg.use_unified_port && game.default_port) {
await API.updateConfig({ telemetry_port: game.default_port });
if (!cfg.use_unified_port) {
const gamePort = (cfg.game_ports || {})[gameId] || this._gameData.find(g => g.id === gameId)?.default_port || 20777;
await API.updateConfig({ telemetry_port: gamePort });
}
await API.stopTelemetry();
await API.startTelemetry();
+39 -1
View File
@@ -11,6 +11,12 @@ const PageSettings = {
const forwards = await this._getForwards();
this._el.innerHTML = this._template(cfg, games, forwards);
this._bindEvents();
this._toggleGamePorts(cfg.use_unified_port !== false);
},
_toggleGamePorts(hide) {
const el = document.getElementById('game-ports-section');
if (el) el.style.display = hide ? 'none' : 'block';
},
async _getForwards() {
@@ -30,6 +36,7 @@ const PageSettings = {
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
await API.updateConfig({ use_unified_port: e.target.checked });
this._toggleGamePorts(e.target.checked);
Toast.show('已更新', 'info');
});
@@ -92,8 +99,37 @@ const PageSettings = {
a.download = 'turbosu_backup.tsb';
a.click();
});
document.getElementById('settings-test-mode')?.addEventListener('change', async (e) => {
const on = e.target.checked;
await API.updateConfig({ ui_test_mode: on });
if (on) {
await fetch('/api/test-mode/start', { method: 'POST' });
Toast.show('测试模式已开启', 'success');
} else {
await fetch('/api/test-mode/stop', { method: 'POST' });
Toast.show('测试模式已关闭', 'info');
}
});
document.getElementById('btn-save-game-ports')?.addEventListener('click', async () => {
const rows = document.querySelectorAll('#game-ports-section .game-port-row');
const gamePorts = {};
rows.forEach(r => {
const id = r.querySelector('.gp-id')?.textContent;
const port = parseInt(r.querySelector('.gp-port')?.value) || 0;
if (id && port > 0) gamePorts[id] = port;
});
await API.updateConfig({ game_ports: gamePorts });
Toast.show('端口已保存,切换游戏后生效', 'success');
});
},
_gamePortListHtml(games, cfg) {
const gamePorts = cfg.game_ports || {};
return games.map(g => `<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:140px;font-size:13px;color:var(--text-secondary)">${g.name}</span><input class="gp-port" type="number" value="${gamePorts[g.id] || g.default_port || 20777}" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">默认: ${g.default_port||20777}</span></div>`).join("");
}
_addForward() {
const list = document.getElementById('forward-list');
const row = document.createElement('div');
@@ -131,12 +167,14 @@ const PageSettings = {
'<div class="settings-section"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' +
'<div class="settings-section"><h3>连接设置</h3><div class="settings-row"><div><div class="settings-label">遥测监听端口</div><div class="settings-desc">全局统一端口</div></div><div class="settings-control"><input type="number" id="settings-telemetry-port" value="' + (cfg.telemetry_port||20777) + '" min="1024" max="65535"></div></div><div class="settings-row"><div><div class="settings-label">使用游戏独立端口</div><div class="settings-desc">关闭后切游戏自动切换到各游戏的默认端口</div></div><div class="settings-control"><label class="forward-toggle"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '> 统一端口</label></div></div><div class="settings-row"><div><div class="settings-label">重启遥测监听</div><div class="settings-desc">手动重启</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button></div></div></div>' +
'<div class="settings-section"><h3>连接设置</h3><div class="settings-row"><div><div class="settings-label">遥测监听端口</div><div class="settings-desc">全局统一端口</div></div><div class="settings-control"><input type="number" id="settings-telemetry-port" value="' + (cfg.telemetry_port||20777) + '" min="1024" max="65535"></div></div><div class="settings-row"><div><div class="settings-label">使用游戏独立端口</div><div class="settings-desc">关闭后切游戏自动切换到各游戏的默认端口</div></div><div class="settings-control"><label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '> 统一端口</label></div></div><div id="game-ports-section" style="margin-top:8px;padding:12px;background:var(--bg-tertiary);border-radius:8px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">各游戏独立端口</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">保存端口</button></div><div class="settings-row"><div><div class="settings-label">重启遥测监听</div><div class="settings-desc">手动重启</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button></div></div></div>' +
'<div class="settings-section"><h3>仪表盘管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></div></div>' +
'<div class="settings-section"><h3>数据备份</h3><div class="settings-row"><div><div class="settings-label">导出全量备份</div><div class="settings-desc">打包仪表盘+场景+配置为 .tsb 文件</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">下载 .tsb</button></div></div></div>' +
'<div class="settings-section"><h3>开发工具</h3><div class="settings-row"><div><div class="settings-label">UI 测试模式</div><div class="settings-desc">本地随机模拟遥测数据,用于测试仪表盘 UI</div></div><div class="settings-control"><label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '> 开启模拟</label></div></div></div>' +
'<div class="settings-section"><h3>游戏插件管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">导入 .tsp</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">重新加载</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div><div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6"><strong style="color:var(--text-secondary)">社区插件开发指南:</strong><br>1. 创建包含 manifest.json + parser.py 的文件夹<br>2. manifest.json 定义元信息,parser.py 实现 get_parser() 函数<br>3. 返回对象实现 game_id() 和 parse(data, addr) 方法<br>4. 打包 zip 改后缀 .tsp 即可导入<br>5. TelemetryData 字段参考 server/telemetry/data.py</div></div>' +
'<div class="settings-section"><h3>关于</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)</div></div></div></div>' +