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