feat: TurboSu initial release - racing telemetry dashboard

This commit is contained in:
Yei.J. (AskaEth)
2026-07-25 15:23:40 +08:00
commit d61bb61a83
61 changed files with 5270 additions and 0 deletions
View File
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from fastapi.responses import JSONResponse
from config.settings import get_config, update_config
from models.dashboard import dashboard_manager, DashboardTheme
from models.scene import scene_manager, Scene, SceneCanvas, DashboardPlacement
from server.telemetry.listener import telemetry_listener
from server.websocket import ws_manager
from utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/api")
# ---- Status ----
@router.get("/status")
async def get_status():
latest = telemetry_listener.latest_data
cfg = get_config()
return {
"server_running": True,
"telemetry_running": telemetry_listener.is_running,
"ws_clients": ws_manager.client_count,
"packet_count": telemetry_listener.packet_count,
"last_packet_time": telemetry_listener.last_packet_time,
"selected_game_id": cfg.get("selected_game_id"),
"latest_data": latest.to_dict() if latest else None,
}
# ---- Config ----
@router.get("/config")
async def api_get_config():
return get_config()
@router.put("/config")
async def api_update_config(data: dict[str, Any]):
cfg = get_config()
for k, v in data.items():
cfg[k] = v
from config.settings import save_config
save_config(cfg)
if "selected_game_id" in data:
telemetry_listener.set_parser_for_game(data["selected_game_id"])
if "theme" in data:
cfg["theme"] = data["theme"]
return {"ok": True}
# ---- Telemetry ----
@router.post("/telemetry/start")
async def api_start_telemetry():
ok = await telemetry_listener.start()
return {"ok": ok, "running": telemetry_listener.is_running}
@router.post("/telemetry/stop")
async def api_stop_telemetry():
telemetry_listener.stop()
return {"ok": True, "running": telemetry_listener.is_running}
@router.get("/telemetry/latest")
async def api_latest_telemetry():
latest = telemetry_listener.latest_data
if latest:
return {
"data": latest.to_dict(),
"raw": latest.raw,
}
return {"data": None, "raw": None}
# ---- Dashboards ----
@router.get("/dashboards")
async def api_list_dashboards(category: str = "all"):
return dashboard_manager.list_all(category)
@router.get("/dashboards/categories")
async def api_dashboard_categories():
return dashboard_manager.get_categories()
@router.get("/dashboards/{theme_id}")
async def api_get_dashboard(theme_id: str):
theme = dashboard_manager.get(theme_id)
if not theme:
raise HTTPException(404, "Dashboard not found")
return theme
@router.get("/dashboards/{theme_id}/template")
async def api_get_dashboard_template(theme_id: str):
tmpl = dashboard_manager.get_template(theme_id)
return {"html": tmpl}
@router.post("/dashboards")
async def api_create_dashboard(data: dict[str, Any]):
theme = DashboardTheme(
name=data.get("name", "Untitled"),
category=data.get("category", "basic"),
description=data.get("description", ""),
author=data.get("author", ""),
config=data.get("config", {}),
)
dashboard_manager.save(theme)
if data.get("template_html"):
dashboard_manager._save_template_file(theme.id, data["template_html"])
return theme.to_dict()
@router.put("/dashboards/{theme_id}")
async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
existing = dashboard_manager.get(theme_id)
if not existing:
raise HTTPException(404, "Dashboard not found")
theme = DashboardTheme.from_dict(existing)
for k in ["name", "category", "description", "author", "config"]:
if k in data:
setattr(theme, k, data[k])
dashboard_manager.save(theme)
if "template_html" in data:
dashboard_manager._save_template_file(theme.id, data["template_html"])
return theme.to_dict()
@router.delete("/dashboards/{theme_id}")
async def api_delete_dashboard(theme_id: str):
ok = dashboard_manager.delete(theme_id)
return {"ok": ok}
@router.get("/dashboards/{theme_id}/export")
async def api_export_dashboard(theme_id: str):
result = dashboard_manager.export_theme(theme_id)
if not result:
raise HTTPException(404, "Dashboard not found")
return result
@router.post("/dashboards/import")
async def api_import_dashboard(data: dict[str, Any]):
ok = dashboard_manager.import_theme(data)
return {"ok": ok}
# ---- Scenes ----
@router.get("/scenes")
async def api_list_scenes(game_id: str = ""):
return scene_manager.list_all(game_id or None)
@router.get("/scenes/{scene_id}")
async def api_get_scene(scene_id: str):
scene = scene_manager.get(scene_id)
if not scene:
raise HTTPException(404, "Scene not found")
return scene
@router.post("/scenes")
async def api_create_scene(data: dict[str, Any]):
scene = Scene(
name=data.get("name", "New Scene"),
game_id=data.get("game_id", ""),
description=data.get("description", ""),
canvases=[SceneCanvas(label="16:9", width=1920, height=1080)],
)
if data.get("canvases"):
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", ""),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.put("/scenes/{scene_id}")
async def api_update_scene(scene_id: str, data: dict[str, Any]):
existing = scene_manager.get(scene_id)
if not existing:
raise HTTPException(404, "Scene not found")
scene = Scene.from_dict(existing)
for k in ["name", "game_id", "description"]:
if k in data:
setattr(scene, k, data[k])
if "canvases" in data:
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", "Custom"),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.delete("/scenes/{scene_id}")
async def api_delete_scene(scene_id: str):
ok = scene_manager.delete(scene_id)
return {"ok": ok}
@router.get("/scenes/{scene_id}/export")
async def api_export_scene(scene_id: str):
result = scene_manager.export_scene(scene_id)
if not result:
raise HTTPException(404, "Scene not found")
return result
@router.post("/scenes/import")
async def api_import_scene(data: dict[str, Any]):
ok = scene_manager.import_scene(data)
return {"ok": ok}
# ---- Game Plugins ----
from server.game_manager import game_plugin_manager
@router.get("/games")
async def api_list_games():
return game_plugin_manager.list_all()
@router.get("/games/{plugin_id}")
async def api_get_game(plugin_id: str):
gp = game_plugin_manager.get(plugin_id)
if not gp:
raise HTTPException(404, "Game plugin not found")
return gp.to_dict()
@router.post("/games/install")
async def api_install_game_plugin(data: dict[str, Any]):
ok = game_plugin_manager.install_plugin(data)
return {"ok": ok}
@router.delete("/games/{plugin_id}")
async def api_remove_game_plugin(plugin_id: str):
ok = game_plugin_manager.remove_plugin(plugin_id)
return {"ok": ok}
@router.get("/games/{plugin_id}/export")
async def api_export_game_plugin(plugin_id: str):
result = game_plugin_manager.export_plugin(plugin_id)
if not result:
raise HTTPException(404, "Game plugin not found")
return result
@router.post("/games/reload")
async def api_reload_game_plugins():
game_plugin_manager.reload()
return {"ok": True, "count": len(game_plugin_manager.list_all())}
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import json
import importlib.util
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
GAMES_DIR = Path(__file__).resolve().parent.parent / "games"
BUILTIN_DIR = GAMES_DIR / "builtin"
USER_DIR = GAMES_DIR / "user"
@dataclass
class GamePlugin:
id: str = ""
name: str = ""
parser_type: str = "forza"
telemetry_format: str = ""
description: str = ""
author: str = ""
version: str = "1.0.0"
default_port: int = 20777
icon: str = ""
is_builtin: bool = True
enabled: bool = True
manifest_path: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_manifest(cls, path: Path, is_builtin: bool = True) -> GamePlugin | None:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
gp = cls(
id=data.get("id", path.parent.name),
name=data.get("name", path.parent.name),
parser_type=data.get("parser_type", "forza"),
telemetry_format=data.get("telemetry_format", ""),
description=data.get("description", ""),
author=data.get("author", ""),
version=data.get("version", "1.0.0"),
default_port=data.get("default_port", 20777),
icon=data.get("icon", ""),
is_builtin=is_builtin,
enabled=True,
manifest_path=str(path.parent),
)
return gp
except Exception as e:
logger.error("Failed to load game plugin manifest %s: %s", path, e)
return None
class GamePluginManager:
def __init__(self):
BUILTIN_DIR.mkdir(parents=True, exist_ok=True)
USER_DIR.mkdir(parents=True, exist_ok=True)
self._plugins: dict[str, GamePlugin] = {}
self._parser_cache: dict[str, Any] = {}
self._discover()
def _discover(self):
self._plugins.clear()
for d in [BUILTIN_DIR, USER_DIR]:
if not d.exists():
continue
is_builtin = (d == BUILTIN_DIR)
for item in d.iterdir():
if item.is_dir():
manifest = item / "manifest.json"
if manifest.exists():
gp = GamePlugin.from_manifest(manifest, is_builtin)
if gp:
self._plugins[gp.id] = gp
logger.debug("Discovered game plugin: %s", gp.id)
def list_all(self) -> list[dict[str, Any]]:
self._discover()
return [p.to_dict() for p in self._plugins.values()]
def get(self, plugin_id: str) -> GamePlugin | None:
self._discover()
return self._plugins.get(plugin_id)
def get_parser(self, plugin_id: str) -> Any | None:
self._discover()
gp = self._plugins.get(plugin_id)
if not gp:
return None
cache_key = gp.id
if cache_key in self._parser_cache:
return self._parser_cache[cache_key]
parser_dir = Path(gp.manifest_path)
parser_file = parser_dir / "parser.py"
if not parser_file.exists():
return None
try:
module_name = f"turbosu_game_{gp.id}"
spec = importlib.util.spec_from_file_location(module_name, parser_file)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "get_parser"):
parser = module.get_parser()
self._parser_cache[cache_key] = parser
logger.info("Loaded parser for game: %s", gp.name)
return parser
except Exception as e:
logger.error("Failed to load parser for %s: %s", gp.id, e)
return None
def reload(self):
self._discover()
self._parser_cache.clear()
def install_plugin(self, data: dict[str, Any]) -> bool:
plugin_id = data.get("id", "")
if not plugin_id:
return False
dest_dir = USER_DIR / plugin_id
dest_dir.mkdir(parents=True, exist_ok=True)
manifest = data.get("manifest", {})
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
if data.get("parser_code"):
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
f.write(data["parser_code"])
self._discover()
logger.info("Plugin installed: %s", plugin_id)
return True
def remove_plugin(self, plugin_id: str) -> bool:
gp = self._plugins.get(plugin_id)
if gp and gp.is_builtin:
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
return False
import shutil
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
self._discover()
logger.info("Plugin removed: %s", plugin_id)
return True
return False
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
gp = self._plugins.get(plugin_id)
if not gp:
return None
parser_dir = Path(gp.manifest_path)
manifest_file = parser_dir / "manifest.json"
parser_file = parser_dir / "parser.py"
result: dict[str, Any] = {
"type": "game_plugin",
"version": "1.0",
"manifest": {},
"parser_code": "",
}
if manifest_file.exists():
with open(manifest_file, "r", encoding="utf-8") as f:
result["manifest"] = json.load(f)
if parser_file.exists():
with open(parser_file, "r", encoding="utf-8") as f:
result["parser_code"] = f.read()
return result
game_plugin_manager = GamePluginManager()
View File
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TelemetryData:
game_id: str = ""
timestamp: float = 0.0
speed_kmh: float = 0.0
speed_mph: float = 0.0
rpm: float = 0.0
max_rpm: float = 8000.0
gear: int = 0
throttle: float = 0.0
brake: float = 0.0
clutch: float = 0.0
handbrake: float = 0.0
steering: float = 0.0
lap_time: float = 0.0
best_lap: float = 0.0
last_lap: float = 0.0
lap_number: int = 0
position_x: float = 0.0
position_y: float = 0.0
position_z: float = 0.0
acceleration_x: float = 0.0
acceleration_y: float = 0.0
acceleration_z: float = 0.0
engine_temp: float = 0.0
oil_temp: float = 0.0
fuel: float = 0.0
boost: float = 0.0
horsepower: float = 0.0
torque: float = 0.0
car_name: str = ""
car_class: str = ""
raw: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"game_id": self.game_id,
"timestamp": self.timestamp,
"speed_kmh": self.speed_kmh,
"speed_mph": self.speed_mph,
"rpm": self.rpm,
"max_rpm": self.max_rpm,
"gear": self.gear,
"throttle": self.throttle,
"brake": self.brake,
"clutch": self.clutch,
"handbrake": self.handbrake,
"steering": self.steering,
"lap_time": self.lap_time,
"best_lap": self.best_lap,
"last_lap": self.last_lap,
"lap_number": self.lap_number,
"position_x": self.position_x,
"position_y": self.position_y,
"position_z": self.position_z,
"acceleration_x": self.acceleration_x,
"acceleration_y": self.acceleration_y,
"acceleration_z": self.acceleration_z,
"engine_temp": self.engine_temp,
"oil_temp": self.oil_temp,
"fuel": self.fuel,
"boost": self.boost,
"horsepower": self.horsepower,
"torque": self.torque,
"car_name": self.car_name,
"car_class": self.car_class,
}
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import asyncio
import time
from typing import Any, Callable
from config.settings import get_config
from server.telemetry.parsers import PARSER_MAP, BaseParser
from server.telemetry.data import TelemetryData
from server.game_manager import game_plugin_manager
from utils.logger import get_logger
logger = get_logger(__name__)
class TelemetryListener:
def __init__(self):
self._transport: asyncio.DatagramTransport | None = None
self._running = False
self._callbacks: list[Callable[[TelemetryData], None]] = []
self._parser: BaseParser | None = None
self._parser_cache: dict[str, BaseParser] = {}
self._latest_data: TelemetryData | None = None
self._last_packet_time: float = 0.0
self._packet_count: int = 0
@property
def is_running(self) -> bool:
return self._running
@property
def latest_data(self) -> TelemetryData | None:
return self._latest_data
@property
def packet_count(self) -> int:
return self._packet_count
@property
def last_packet_time(self) -> float:
return self._last_packet_time
def on_data(self, callback: Callable[[TelemetryData], None]):
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[TelemetryData], None]):
if callback in self._callbacks:
self._callbacks.remove(callback)
async def start(self) -> bool:
if self._running:
return True
cfg = get_config()
host = cfg.get("telemetry_host", "0.0.0.0")
port = cfg.get("telemetry_port", 20777)
selected_game = cfg.get("selected_game_id")
parser_key = None
if selected_game:
for game in cfg.get("games", []):
if game["id"] == selected_game:
parser_key = game.get("parser", "forza")
break
loop = asyncio.get_event_loop()
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: _TelemetryProtocol(self),
local_addr=(host, port),
)
self._running = True
if parser_key and parser_key in PARSER_MAP:
self._parser = self._get_parser(parser_key)
logger.info("Telemetry listener started on %s:%d [parser=%s]", host, port, parser_key)
else:
self._parser = None
logger.info("Telemetry listener started on %s:%d [auto-detect]", host, port)
return True
except Exception as e:
logger.error("Failed to start telemetry listener: %s", e)
return False
def stop(self):
self._running = False
if self._transport:
self._transport.close()
self._transport = None
logger.info("Telemetry listener stopped")
def _get_parser(self, key: str) -> BaseParser:
if key not in self._parser_cache:
cls = PARSER_MAP.get(key)
if cls:
self._parser_cache[key] = cls()
return self._parser_cache.get(key, PARSER_MAP["forza"]())
def _handle_packet(self, data: bytes, addr: tuple[str, int]):
self._last_packet_time = time.time()
self._packet_count += 1
td = None
if self._parser:
td = self._parser.parse(data, addr)
else:
for parser_cls in PARSER_MAP.values():
p = parser_cls()
td = p.parse(data, addr)
if td and td.speed_kmh > 0:
break
if td is None:
td = TelemetryData(timestamp=time.time(), raw={"raw_hex": data.hex(), "length": len(data)})
self._latest_data = td
for cb in self._callbacks:
try:
cb(td)
except Exception as e:
logger.error("Callback error: %s", e)
def set_parser_for_game(self, game_id: str):
custom_parser = game_plugin_manager.get_parser(game_id)
if custom_parser:
self._parser = custom_parser
logger.info("Parser loaded from plugin for game: %s", game_id)
return
gp = game_plugin_manager.get(game_id)
if gp:
parser_key = gp.parser_type
if parser_key in PARSER_MAP:
self._parser = self._get_parser(parser_key)
logger.info("Parser set to %s for game %s", parser_key, game_id)
return
self._parser = None
class _TelemetryProtocol(asyncio.DatagramProtocol):
def __init__(self, listener: TelemetryListener):
self._listener = listener
def datagram_received(self, data: bytes, addr: tuple[str, int]):
self._listener._handle_packet(data, addr)
def connection_made(self, transport):
pass
telemetry_listener = TelemetryListener()
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import struct
import time
from server.telemetry.data import TelemetryData
from server.telemetry.parsers.base import BaseParser
from utils.logger import get_logger
logger = get_logger(__name__)
class ForzaParser(BaseParser):
FORZA_FORMATS = {
"fh4": "Forza Horizon 4",
"fh5": "Forza Horizon 5",
"fm8": "Forza Motorsport",
}
def __init__(self, format_id: str = "fh5"):
self._format = format_id
def game_id(self) -> str:
return self._format
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id=self._format, timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 323:
logger.warning("Forza data too short: %d bytes", len(data))
return td
try:
if self._format == "fh4":
offset = 0
else:
offset = 0
td.rpm = struct.unpack_from("<f", data, 8)[0]
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
td.horsepower = struct.unpack_from("<f", data, 12)[0]
td.torque = struct.unpack_from("<f", data, 20)[0]
td.boost = struct.unpack_from("<f", data, 308)[0]
td.fuel = struct.unpack_from("<f", data, 312)[0]
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
td.speed_mph = struct.unpack_from("<f", data, 244)
td.speed_kmh = td.speed_mph * 1.60934
td.gear = struct.unpack_from("<B", data, 264)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.last_lap = struct.unpack_from("<f", data, 276)[0]
td.lap_time = struct.unpack_from("<f", data, 284)[0]
td.lap_number = struct.unpack_from("<H", data, 292)[0]
td.position_x = struct.unpack_from("<f", data, 0)[0]
td.position_y = struct.unpack_from("<f", data, 4)[0]
td.position_z = struct.unpack_from("<f", data, 552)[0]
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
td.throttle = struct.unpack_from("<f", data, 228)[0]
td.brake = struct.unpack_from("<f", data, 232)[0]
td.steering = struct.unpack_from("<f", data, 204)[0]
td.clutch = struct.unpack_from("<f", data, 252)[0]
td.handbrake = struct.unpack_from("<f", data, 256)[0]
td.raw.update({
"speed_mph": td.speed_mph,
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"max_rpm": td.max_rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
"steering": td.steering,
"boost": td.boost,
"horsepower": td.horsepower,
"torque": td.torque,
})
except struct.error as e:
logger.error("Forza parse error: %s", e)
return td
class ACCParser(BaseParser):
def game_id(self) -> str:
return "acc"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="acc", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 200:
logger.warning("ACC data too short: %d bytes", len(data))
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<f", data, 4)[0]
td.max_rpm = struct.unpack_from("<f", data, 8)[0]
td.gear = struct.unpack_from("<B", data, 12)[0]
td.throttle = struct.unpack_from("<f", data, 16)[0]
td.brake = struct.unpack_from("<f", data, 20)[0]
td.steering = struct.unpack_from("<f", data, 24)[0]
td.fuel = struct.unpack_from("<f", data, 28)[0]
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
})
except struct.error as e:
logger.error("ACC parse error: %s", e)
return td
class F1Parser(BaseParser):
def game_id(self) -> str:
return "f1"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="f1", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 1289:
logger.warning("F1 data too short: %d bytes", len(data))
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<H", data, 41)[0]
td.max_rpm = struct.unpack_from("<H", data, 43)[0]
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
td.throttle = struct.unpack_from("<f", data, 47)[0]
td.brake = struct.unpack_from("<f", data, 55)[0]
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
td.lap_number = struct.unpack_from("<B", data, 262)[0]
td.lap_time = struct.unpack_from("<f", data, 63)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.fuel = struct.unpack_from("<f", data, 51)[0]
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
"lap_time": td.lap_time,
})
except struct.error as e:
logger.error("F1 parse error: %s", e)
return td
class IRacingParser(BaseParser):
def game_id(self) -> str:
return "iracing"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="iracing", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 100:
logger.warning("iRacing data too short: %d bytes", len(data))
return td
try:
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.rpm = struct.unpack_from("<f", data, 48)[0]
td.gear = struct.unpack_from("<i", data, 56)[0]
td.throttle = struct.unpack_from("<f", data, 4)[0]
td.brake = struct.unpack_from("<f", data, 8)[0]
td.steering = struct.unpack_from("<f", data, 0)[0]
td.raw.update({
"speed_mph": td.speed_mph,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
})
except struct.error as e:
logger.error("iRacing parse error: %s", e)
return td
class ACParser(BaseParser):
def game_id(self) -> str:
return "ac"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="ac", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
try:
parts = data.decode("utf-8", errors="replace").rstrip("\r\n").split("\t")
if len(parts) < 10:
return td
td.speed_kmh = float(parts[0])
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = float(parts[1])
td.gear = int(float(parts[2]))
td.throttle = float(parts[3])
td.brake = float(parts[4])
td.steering = float(parts[5])
td.fuel = float(parts[6])
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
})
except Exception as e:
logger.error("AC parse error: %s", e)
return td
PARSER_MAP: dict[str, type[BaseParser]] = {
"forza": ForzaParser,
"ac": ACParser,
"acc": ACCParser,
"f1": F1Parser,
"iracing": IRacingParser,
}
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from server.telemetry.data import TelemetryData
class BaseParser(ABC):
@abstractmethod
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
...
@abstractmethod
def game_id(self) -> str:
...
def supports(self, raw_data: bytes) -> bool:
return True
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
import json
import time
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from server.telemetry.listener import telemetry_listener
from server.telemetry.data import TelemetryData
from utils.logger import get_logger
logger = get_logger(__name__)
class ConnectionManager:
def __init__(self):
self._connections: dict[str, WebSocket] = {}
self._counter = 0
self._broadcast_task: asyncio.Task | None = None
self._pending_data: TelemetryData | None = None
async def connect(self, ws: WebSocket) -> str:
await ws.accept()
self._counter += 1
cid = f"client_{self._counter}"
self._connections[cid] = ws
logger.info("WS client connected: %s (total: %d)", cid, len(self._connections))
if not self._broadcast_task or self._broadcast_task.done():
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
return cid
def disconnect(self, cid: str):
self._connections.pop(cid, None)
logger.info("WS client disconnected: %s (total: %d)", cid, len(self._connections))
if not self._connections and self._broadcast_task:
self._broadcast_task.cancel()
self._broadcast_task = None
def push_telemetry(self, data: TelemetryData):
self._pending_data = data
async def broadcast(self, message: dict[str, Any]):
dead = []
for cid, ws in self._connections.items():
try:
await ws.send_json(message)
except Exception:
dead.append(cid)
for cid in dead:
self.disconnect(cid)
async def _broadcast_loop(self):
last_sent = 0.0
throttle_interval = 1.0 / 30.0
try:
while self._connections:
now = time.time()
if now - last_sent >= throttle_interval and self._pending_data:
data = self._pending_data
self._pending_data = None
last_sent = now
await self.broadcast({
"type": "telemetry",
"data": data.to_dict(),
})
await asyncio.sleep(0.01)
except asyncio.CancelledError:
pass
@property
def client_count(self) -> int:
return len(self._connections)
ws_manager = ConnectionManager()
def on_telemetry(data: TelemetryData):
ws_manager.push_telemetry(data)
telemetry_listener.on_data(on_telemetry)