diff --git a/config/settings.py b/config/settings.py index 6ecbed8..33de64f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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, diff --git a/server/api.py b/server/api.py index 19a291f..56c6173 100644 --- a/server/api.py +++ b/server/api.py @@ -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 diff --git a/server/telemetry/listener.py b/server/telemetry/listener.py index 4ae52b7..31536b7 100644 --- a/server/telemetry/listener.py +++ b/server/telemetry/listener.py @@ -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) diff --git a/static/js/components/sidebar.js b/static/js/components/sidebar.js index f008f39..8f719db 100644 --- a/static/js/components/sidebar.js +++ b/static/js/components/sidebar.js @@ -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(); diff --git a/static/js/pages/settings.js b/static/js/pages/settings.js index 7cd3aa8..8e15488 100644 --- a/static/js/pages/settings.js +++ b/static/js/pages/settings.js @@ -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 => `
将收到的游戏原始 UDP 数据包完整转发到下游物理外设