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
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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("<i", data, 0)[0] == 1
|
||||
except Exception:
|
||||
is_race = True
|
||||
|
||||
if is_race and not self._last_race_on:
|
||||
self.start()
|
||||
elif not is_race and self._last_race_on and self._recording:
|
||||
self.stop()
|
||||
self._last_race_on = is_race
|
||||
|
||||
if not self._recording or not self._file:
|
||||
return
|
||||
elapsed_ms = int((time.time() - self._start_time) * 1000)
|
||||
self._file.write(struct.pack("<II", elapsed_ms, len(data)))
|
||||
self._file.write(data)
|
||||
|
||||
|
||||
class TelemetryPlayer:
|
||||
def __init__(self, listener):
|
||||
self._listener = listener
|
||||
self._task: asyncio.Task | None = None
|
||||
self._playing = False
|
||||
|
||||
@property
|
||||
def is_playing(self) -> 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("<II", data, offset)
|
||||
offset += 8
|
||||
packet = data[offset:offset + length]
|
||||
offset += length
|
||||
|
||||
target_time = play_start + elapsed_ms / 1000.0
|
||||
delay = target_time - time.time()
|
||||
if delay > 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)]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '<p style="color:var(--text-tertiary);font-size:12px">暂无录制文件</p>'; return; }
|
||||
el.innerHTML = list.map(f => {
|
||||
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
||||
return '<div class="glass-card" style="padding:8px 12px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">' + f.name + ' <span style="color:var(--text-tertiary)">' + size + '</span></span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button></div>';
|
||||
}).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 => '<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('');
|
||||
@@ -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 = {
|
||||
|
||||
'<div class="settings-section" id="sec-backup"><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 class="settings-row"><div><div class="settings-label">恢复备份</div><div class="settings-desc">从 .tsb 文件恢复</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">导入 .tsb</button></div></div></div>' +
|
||||
|
||||
'<div class="settings-section" id="sec-dev"><h3>开发工具</h3><div class="settings-row"><div><div class="settings-label">UI 测试模式</div><div class="settings-desc">本地随机模拟遥测数据</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '><span class="switch-track"></span></label></div></div></div>' +
|
||||
'<div class="settings-section" id="sec-record"><h3>录制与回放</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:10px">录制原始 UDP 流到 .tsr 文件,离线回放调试仪表盘</p><div class="settings-row"><div><div class="settings-label">录制状态</div><div class="settings-desc"><span id="rec-status">未启动</span></div></div><div class="settings-control"><button id="btn-rec-start" class="btn btn-secondary btn-sm">开始录制</button><button id="btn-rec-stop" class="btn btn-danger btn-sm" style="display:none">停止</button></div></div><div style="margin-top:10px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:6px">回放文件</h4><div id="recording-list" style="max-height:200px;overflow-y:auto"></div></div></div>' +
|
||||
|
||||
'<div class="settings-section" id="sec-system"><h3>系统</h3><div class="settings-row"><div><div class="settings-label">重启 TurboSu</div><div class="settings-desc">重新加载所有代码和配置</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">重启服务</button></div></div></div>' +
|
||||
|
||||
|
||||
Reference in New Issue
Block a user