feat: TurboSu initial release - racing telemetry dashboard
This commit is contained in:
+279
@@ -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())}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user