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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
Reference in New Issue
Block a user