From a9bd35baa8525943866a30275e4af130db08b8cb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 26 Jul 2026 18:40:09 +0800 Subject: [PATCH] feat: #1 telemetry record/playback with auto-race detection - TelemetryRecorder: manual + auto mode (IsRaceOn edge detection) - TelemetryPlayer: timed packet replay into _handle_packet pipeline - .tsr format: [uint32 elapsed_ms][uint32 len][bytes] - Settings UI: start/stop, auto toggle, recording list, play button - API: /record/*, /playback/*, /recordings --- server/api.py | 47 ++++++++++++ server/recorder.py | 134 +++++++++++++++++++++++++++++++++++ server/telemetry/listener.py | 4 ++ static/js/pages/settings.js | 41 ++++++++++- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 server/recorder.py diff --git a/server/api.py b/server/api.py index 04b5ea4..9e59282 100644 --- a/server/api.py +++ b/server/api.py @@ -422,3 +422,50 @@ async def api_update_forward_targets(data: dict[str, Any]): save_config(cfg) telemetry_listener.forwarder.reload_targets() return {"ok": True, "active": telemetry_listener.forwarder.active} + + +# ---- Record & Playback ---- +from server.recorder import TelemetryRecorder, TelemetryPlayer + +_recorder = None +_player = None + +def _get_recorder(): + global _recorder + if _recorder is None: _recorder = TelemetryRecorder(telemetry_listener) + return _recorder + +def _get_player(): + global _player + if _player is None: _player = TelemetryPlayer(telemetry_listener) + return _player + +@router.post("/record/start") +async def api_start_record(): + _get_recorder().start() + return {"ok": True, "recording": True} + +@router.post("/record/stop") +async def api_stop_record(): + _get_recorder().stop() + return {"ok": True, "recording": False} + +@router.get("/record/status") +async def api_record_status(): + return {"recording": _get_recorder().is_recording, "playing": _get_player().is_playing} + +@router.get("/recordings") +async def api_list_recordings(): + return TelemetryPlayer.list_recordings() + +@router.post("/playback/start") +async def api_start_playback(data: dict): + filename = data.get("filename", "") + if not filename: raise HTTPException(400, "filename required") + _get_player().start(filename) + return {"ok": True, "playing": True} + +@router.post("/playback/stop") +async def api_stop_playback(): + _get_player().stop() + return {"ok": True, "playing": False} diff --git a/server/recorder.py b/server/recorder.py new file mode 100644 index 0000000..5e40a78 --- /dev/null +++ b/server/recorder.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import os +import struct +import time +from pathlib import Path + +from utils.logger import get_logger + +logger = get_logger(__name__) + +RECORDINGS_DIR = Path(__file__).resolve().parent.parent / "data" / "recordings" +RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) + + +class TelemetryRecorder: + def __init__(self, listener): + self._listener = listener + self._file = None + self._recording = False + self._auto_mode = False + self._start_time = 0.0 + self._last_race_on = False + + @property + def is_recording(self) -> bool: + return self._recording + + @property + def current_file(self) -> str | None: + return str(self._file.name) if self._file else None + + def start(self): + if self._recording: + return + ts = time.strftime("%Y%m%d_%H%M%S") + self._file = open(RECORDINGS_DIR / f"rec_{ts}.tsr", "wb") + self._recording = True + self._start_time = time.time() + logger.info("Recording started: %s", self._file.name) + + def stop(self): + if not self._recording: + return + self._recording = False + if self._file: + self._file.close() + self._file = None + logger.info("Recording stopped") + + def set_auto(self, enabled: bool): + self._auto_mode = enabled + self._last_race_on = False + if not enabled: + self.stop() + + def write_packet(self, data: bytes): + if self._auto_mode: + try: + is_race = struct.unpack_from(" bool: + return self._playing + + def start(self, filename: str): + if self._playing: + return + path = RECORDINGS_DIR / filename + if not path.exists(): + logger.error("Recording not found: %s", filename) + return + loop = asyncio.get_event_loop() + self._task = loop.create_task(self._play_loop(path)) + self._playing = True + logger.info("Playback started: %s", filename) + + def stop(self): + self._playing = False + if self._task and not self._task.done(): + self._task.cancel() + self._task = None + logger.info("Playback stopped") + + async def _play_loop(self, path: Path): + try: + data = path.read_bytes() + offset = 0 + play_start = time.time() + while offset < len(data) and self._playing: + elapsed_ms, length = struct.unpack_from(" 0: + await asyncio.sleep(min(delay, 0.5)) + + self._listener._handle_packet(packet, ("127.0.0.1", 0)) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error("Playback error: %s", e) + finally: + self._playing = False + + @staticmethod + def list_recordings() -> list[dict]: + return [{"name": f.name, "size": f.stat().st_size, "time": f.stat().st_mtime} + for f in sorted(RECORDINGS_DIR.glob("*.tsr"), key=lambda x: x.stat().st_mtime, reverse=True)] diff --git a/server/telemetry/listener.py b/server/telemetry/listener.py index d32f0f8..0022025 100644 --- a/server/telemetry/listener.py +++ b/server/telemetry/listener.py @@ -151,6 +151,10 @@ class TelemetryListener: self.forwarder.forward(data) + from server.api import _recorder + if _recorder and _recorder.is_recording: + _recorder.write_packet(data) + td = None if self._parser: td = self._parser.parse(data, addr) diff --git a/static/js/pages/settings.js b/static/js/pages/settings.js index b6a036f..1bb8fbc 100644 --- a/static/js/pages/settings.js +++ b/static/js/pages/settings.js @@ -134,6 +134,24 @@ const PageSettings = { }); }); + document.getElementById('btn-rec-start')?.addEventListener('click', async () => { + await fetch('/api/record/start', { method: 'POST' }); + document.getElementById('btn-rec-start').style.display = 'none'; + document.getElementById('btn-rec-stop').style.display = ''; + document.getElementById('rec-status').textContent = '录制中...'; + }); + document.getElementById('btn-rec-stop')?.addEventListener('click', async () => { + await fetch('/api/record/stop', { method: 'POST' }); + document.getElementById('btn-rec-start').style.display = ''; + document.getElementById('btn-rec-stop').style.display = 'none'; + document.getElementById('rec-status').textContent = '已停止'; + this._loadRecordings(); + }); + document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => { + await fetch('/api/record/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: e.target.checked }) }); + }); + this._loadRecordings(); + document.getElementById('settings-test-mode')?.addEventListener('change', async (e) => { const on = e.target.checked; await API.updateConfig({ ui_test_mode: on }); @@ -181,6 +199,26 @@ const PageSettings = { this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success')); }, + async _loadRecordings() { + try { + const res = await fetch('/api/recordings'); + const list = await res.json(); + const el = document.getElementById('recording-list'); + if (!el) return; + if (!list.length) { el.innerHTML = '

暂无录制文件

'; return; } + el.innerHTML = list.map(f => { + const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB'; + return '
' + f.name + ' ' + size + '
'; + }).join(''); + el.querySelectorAll('.btn-play-rec').forEach(btn => { + btn.addEventListener('click', async () => { + await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) }); + Toast.show('回放已开始', 'success'); + }); + }); + } catch (e) {} + }, + _gamePortListHtml(games, cfg) { const gamePorts = cfg.game_ports || {}; return games.map(g => '
' + g.name + '默认: ' + (g.default_port || 20777) + '
').join(''); @@ -204,6 +242,7 @@ const PageSettings = { { id: 'sec-plugins', label: '游戏插件' }, { id: 'sec-backup', label: '数据备份' }, { id: 'sec-dev', label: '开发工具' }, + { id: 'sec-record', label: '录制回放' }, { id: 'sec-system', label: '系统' }, ]; @@ -221,7 +260,7 @@ const PageSettings = { '

数据备份

导出全量备份
打包仪表盘+场景+配置为 .tsb
恢复备份
从 .tsb 文件恢复
' + - '

开发工具

UI 测试模式
本地随机模拟遥测数据
' + + '

录制与回放

录制原始 UDP 流到 .tsr 文件,离线回放调试仪表盘

录制状态
未启动

回放文件

' + '

系统

重启 TurboSu
重新加载所有代码和配置
' +