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,
|
"sidebar_collapsed": False,
|
||||||
"telemetry_port": 20777,
|
"telemetry_port": 20777,
|
||||||
"use_unified_port": True,
|
"use_unified_port": True,
|
||||||
|
"game_ports": {},
|
||||||
|
"ui_test_mode": False,
|
||||||
"telemetry_host": "0.0.0.0",
|
"telemetry_host": "0.0.0.0",
|
||||||
"server_host": "0.0.0.0",
|
"server_host": "0.0.0.0",
|
||||||
"server_port": 9527,
|
"server_port": 9527,
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ async def api_stop_telemetry():
|
|||||||
return {"ok": True, "running": telemetry_listener.is_running}
|
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")
|
@router.get("/telemetry/latest")
|
||||||
async def api_latest_telemetry():
|
async def api_latest_telemetry():
|
||||||
latest = telemetry_listener.latest_data
|
latest = telemetry_listener.latest_data
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ class TelemetryListener:
|
|||||||
self._last_packet_time: float = 0.0
|
self._last_packet_time: float = 0.0
|
||||||
self._packet_count: int = 0
|
self._packet_count: int = 0
|
||||||
self.forwarder = DataForwarder()
|
self.forwarder = DataForwarder()
|
||||||
|
self._test_task: asyncio.Task | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -112,6 +115,7 @@ class TelemetryListener:
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self.stop_test_mode()
|
||||||
if self._transport:
|
if self._transport:
|
||||||
self._transport.close()
|
self._transport.close()
|
||||||
self._transport = None
|
self._transport = None
|
||||||
@@ -120,6 +124,76 @@ class TelemetryListener:
|
|||||||
self.forwarder._sock = None
|
self.forwarder._sock = None
|
||||||
logger.info("Telemetry listener stopped")
|
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:
|
def _get_parser(self, key: str) -> BaseParser:
|
||||||
if key not in self._parser_cache:
|
if key not in self._parser_cache:
|
||||||
cls = PARSER_MAP.get(key)
|
cls = PARSER_MAP.get(key)
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ const Sidebar = {
|
|||||||
async _selectGame(gameId) {
|
async _selectGame(gameId) {
|
||||||
await API.updateConfig({ selected_game_id: gameId });
|
await API.updateConfig({ selected_game_id: gameId });
|
||||||
const cfg = await API.getConfig();
|
const cfg = await API.getConfig();
|
||||||
const game = this._gameData.find(g => g.id === gameId);
|
if (!cfg.use_unified_port) {
|
||||||
if (game && !cfg.use_unified_port && game.default_port) {
|
const gamePort = (cfg.game_ports || {})[gameId] || this._gameData.find(g => g.id === gameId)?.default_port || 20777;
|
||||||
await API.updateConfig({ telemetry_port: game.default_port });
|
await API.updateConfig({ telemetry_port: gamePort });
|
||||||
}
|
}
|
||||||
await API.stopTelemetry();
|
await API.stopTelemetry();
|
||||||
await API.startTelemetry();
|
await API.startTelemetry();
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const PageSettings = {
|
|||||||
const forwards = await this._getForwards();
|
const forwards = await this._getForwards();
|
||||||
this._el.innerHTML = this._template(cfg, games, forwards);
|
this._el.innerHTML = this._template(cfg, games, forwards);
|
||||||
this._bindEvents();
|
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() {
|
async _getForwards() {
|
||||||
@@ -30,6 +36,7 @@ const PageSettings = {
|
|||||||
|
|
||||||
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
|
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
|
||||||
await API.updateConfig({ use_unified_port: e.target.checked });
|
await API.updateConfig({ use_unified_port: e.target.checked });
|
||||||
|
this._toggleGamePorts(e.target.checked);
|
||||||
Toast.show('已更新', 'info');
|
Toast.show('已更新', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,8 +99,37 @@ const PageSettings = {
|
|||||||
a.download = 'turbosu_backup.tsb';
|
a.download = 'turbosu_backup.tsb';
|
||||||
a.click();
|
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() {
|
_addForward() {
|
||||||
const list = document.getElementById('forward-list');
|
const list = document.getElementById('forward-list');
|
||||||
const row = document.createElement('div');
|
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><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 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">导出全量备份</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 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>' +
|
'<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