Feature: Live Map and Events Tab

This commit is contained in:
2025-11-02 20:59:28 +00:00
parent 4a8d6bc5d7
commit 8de538cc29
13 changed files with 1391 additions and 388 deletions
+170 -97
View File
@@ -1,15 +1,22 @@
// lib/telemetryBridge.ts
// Bridge between C++ Unix socket and Next.js SSE
// Bridge between C++ Unix socket telemetry server and Next.js SSE clients
import { Socket } from 'net';
import net from 'net';
const TELEMETRY_SOCKET_PATH = '/tmp/ACtelemetry_socket';
interface Position {
x: number;
y: number;
z: number;
}
interface CarTelemetry {
carID: number;
driver_name: string;
driver_guid: string;
car_model: string;
position: Position;
normalizedSplinePos: number;
speed_kmh: number;
gear: number;
@@ -17,7 +24,7 @@ interface CarTelemetry {
last_lap_time: number;
best_lap_time: number;
current_lap: number;
position: number;
position_rank: number;
}
interface TelemetryPacket {
@@ -26,13 +33,13 @@ interface TelemetryPacket {
cars: CarTelemetry[];
}
type TelemetryCallback = (data: TelemetryPacket) => void;
type TelemetryCallback = (packet: TelemetryPacket) => void;
class TelemetryBridge {
private socket: Socket | null = null;
private socket: net.Socket | null = null;
private subscribers: Set<TelemetryCallback> = new Set();
private reconnectTimer: NodeJS.Timeout | null = null;
private connected: boolean = false;
private callbacks: Set<TelemetryCallback> = new Set();
private reconnectTimeout: NodeJS.Timeout | null = null;
private buffer: Buffer = Buffer.alloc(0);
constructor() {
@@ -40,111 +47,146 @@ class TelemetryBridge {
}
private connect() {
console.log('[Telemetry] Connecting to', TELEMETRY_SOCKET_PATH);
this.socket = new Socket();
this.socket.connect(TELEMETRY_SOCKET_PATH, () => {
console.log('[Telemetry] Connected to C++ socket');
if (this.socket) {
this.socket.destroy();
}
console.log('[Bridge] Connecting to telemetry socket...');
this.socket = net.createConnection(TELEMETRY_SOCKET_PATH);
this.socket.on('connect', () => {
console.log('[Bridge] Connected to telemetry server');
this.connected = true;
this.buffer = Buffer.alloc(0); // Reset buffer on new connection
this.buffer = Buffer.alloc(0);
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
});
this.socket.on('data', (data: Buffer) => {
// Append new data to buffer
this.buffer = Buffer.concat([this.buffer, data]);
// Parse complete packets from buffer
this.parsePackets();
// Calculate expected packet size
// server_id (1) + car_count (1) + cars (car_count * car_size)
const HEADER_SIZE = 2; // server_id + car_count
const CAR_SIZE = 1 + 64 + 64 + 64 + 4 + 4 + 1 + 2 + 4 + 4 + 2 + 1; // 211 bytes per car
while (this.buffer.length >= HEADER_SIZE) {
const server_id = this.buffer.readUInt8(0);
const car_count = this.buffer.readUInt8(1);
// Updated CAR_SIZE: 1 + 64 + 64 + 64 + 12 + 4 + 4 + 1 + 2 + 4 + 4 + 2 + 1 = 227 bytes
const CAR_SIZE = 227;
const expected_size = HEADER_SIZE + (car_count * CAR_SIZE);
if (this.buffer.length >= expected_size) {
// We have a complete packet
const packet_data = this.buffer.slice(0, expected_size);
this.buffer = this.buffer.slice(expected_size);
// Parse packet
const packet = this.parsePacket(packet_data);
if (packet) {
// Broadcast to all subscribers
this.subscribers.forEach(callback => {
try {
callback(packet);
} catch (error) {
console.error('[Bridge] Error in subscriber callback:', error);
}
});
}
} else {
// Wait for more data
break;
}
}
});
this.socket.on('error', (err) => {
console.error('[Telemetry] Socket error:', err.message);
console.error('[Bridge] Socket error:', err.message);
this.connected = false;
});
this.socket.on('close', () => {
console.log('[Telemetry] Connection closed, reconnecting in 2s...');
console.log('[Bridge] Connection closed, reconnecting in 5s...');
this.connected = false;
this.socket = null;
// Reconnect after 2 seconds
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = setTimeout(() => this.connect(), 2000);
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
this.connect();
}, 5000);
});
}
private parsePackets() {
// Telemetry packet structure from C++:
// uint8_t server_id (1 byte)
// uint8_t car_count (1 byte)
// car_telemetry cars[64] (each car = 158 bytes)
const HEADER_SIZE = 2;
const CAR_SIZE = 158; // Size of car_telemetry struct
while (this.buffer.length >= HEADER_SIZE) {
const server_id = this.buffer.readUInt8(0);
const car_count = this.buffer.readUInt8(1);
const expected_size = HEADER_SIZE + (car_count * CAR_SIZE);
if (this.buffer.length < expected_size) {
// Not enough data yet, wait for more
break;
}
// Parse the packet
const packet: TelemetryPacket = {
server_id,
car_count,
cars: [],
};
let offset = HEADER_SIZE;
private parsePacket(data: Buffer): TelemetryPacket | null {
try {
let offset = 0;
const server_id = data.readUInt8(offset);
offset += 1;
const car_count = data.readUInt8(offset);
offset += 1;
const cars: CarTelemetry[] = [];
for (let i = 0; i < car_count; i++) {
const carID = this.buffer.readUInt8(offset);
const carID = data.readUInt8(offset);
offset += 1;
const driver_name = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, '');
const driver_name = this.readString(data, offset, 64);
offset += 64;
const driver_guid = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, '');
const driver_guid = this.readString(data, offset, 64);
offset += 64;
const car_model = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, '');
const car_model = this.readString(data, offset, 64);
offset += 64;
const normalizedSplinePos = this.buffer.readFloatLE(offset);
const position: Position = {
x: data.readFloatLE(offset),
y: data.readFloatLE(offset + 4),
z: data.readFloatLE(offset + 8),
};
offset += 12;
const normalizedSplinePos = data.readFloatLE(offset);
offset += 4;
const speed_kmh = this.buffer.readFloatLE(offset);
const speed_kmh = data.readFloatLE(offset);
offset += 4;
const gear = this.buffer.readUInt8(offset);
const gear = data.readUInt8(offset);
offset += 1;
const rpm = this.buffer.readUInt16LE(offset);
const rpm = data.readUInt16LE(offset);
offset += 2;
const last_lap_time = this.buffer.readUInt32LE(offset);
const last_lap_time = data.readUInt32LE(offset);
offset += 4;
const best_lap_time = this.buffer.readUInt32LE(offset);
const best_lap_time = data.readUInt32LE(offset);
offset += 4;
const current_lap = this.buffer.readUInt16LE(offset);
const current_lap = data.readUInt16LE(offset);
offset += 2;
const position = this.buffer.readUInt8(offset);
const position_rank = data.readUInt8(offset);
offset += 1;
packet.cars.push({
cars.push({
carID,
driver_name,
driver_guid,
car_model,
position,
normalizedSplinePos,
speed_kmh,
gear,
@@ -152,51 +194,82 @@ class TelemetryBridge {
last_lap_time,
best_lap_time,
current_lap,
position,
position_rank,
});
}
// Emit packet to all callbacks
this.callbacks.forEach(cb => cb(packet));
// Remove processed packet from buffer
this.buffer = this.buffer.subarray(expected_size);
return {
server_id,
car_count,
cars,
};
} catch (error) {
console.error('[Bridge] Error parsing packet:', error);
return null;
}
}
private readString(buffer: Buffer, offset: number, length: number): string {
const end = buffer.indexOf(0, offset);
const strEnd = end === -1 || end >= offset + length ? offset + length : end;
return buffer.toString('utf8', offset, strEnd).trim();
}
public subscribe(callback: TelemetryCallback): () => void {
this.callbacks.add(callback);
console.log('[Telemetry] Subscriber added, total:', this.callbacks.size);
this.subscribers.add(callback);
console.log('[Bridge] Subscriber added (total:', this.subscribers.size, ')');
// Return unsubscribe function
return () => {
this.callbacks.delete(callback);
console.log('[Telemetry] Subscriber removed, total:', this.callbacks.size);
this.subscribers.delete(callback);
console.log('[Bridge] Subscriber removed (total:', this.subscribers.size, ')');
};
}
public getSubscriberCount(): number {
return this.subscribers.size;
}
public isConnected(): boolean {
return this.connected;
}
public disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
public destroy() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.subscribers.clear();
}
}
// Singleton instance
let bridge: TelemetryBridge | null = null;
let bridgeInstance: TelemetryBridge | null = null;
export function getTelemetryBridge(): TelemetryBridge {
if (!bridge) {
bridge = new TelemetryBridge();
if (!bridgeInstance) {
bridgeInstance = new TelemetryBridge();
}
return bridge;
return bridgeInstance;
}
// Cleanup on process exit
if (typeof process !== 'undefined') {
process.on('SIGTERM', () => {
if (bridgeInstance) {
bridgeInstance.destroy();
}
});
process.on('SIGINT', () => {
if (bridgeInstance) {
bridgeInstance.destroy();
}
});
}
+174
View File
@@ -0,0 +1,174 @@
// lib/trackMapConfig.ts
// Parse and use AC track map.ini configuration files
export interface TrackMapConfig {
width: number;
height: number;
margin: number;
scaleFactor: number;
xOffset: number;
zOffset: number;
drawingSize: number;
}
// Cache for parsed configs
const configCache = new Map<string, TrackMapConfig | null>();
/**
* Clean track name from database format (removes CSP prefix)
* Example: "csp/2100/../spa" -> "spa"
*/
export function cleanTrackPath(track: string): string {
// Remove CSP prefix pattern: csp/XXXX/../
const cleaned = track.replace(/^csp\/\d+\/\.\.\//, '');
return cleaned;
}
/**
* Fetch and parse a track's map.ini file
*/
export async function getTrackMapConfig(
track: string,
trackConfig: string = ''
): Promise<TrackMapConfig | null> {
// Clean track name first
const cleanTrack = cleanTrackPath(track);
const cleanConfig = trackConfig || '';
const cacheKey = `${cleanTrack}|${cleanConfig}`;
// Check cache first
if (configCache.has(cacheKey)) {
return configCache.get(cacheKey)!;
}
try {
// Try to fetch from openwheels.racing first
let configUrl = cleanConfig && cleanConfig !== 'default'
? `https://openwheels.racing/files/img/tracks/${cleanTrack}/${cleanConfig}/map.ini`
: `https://openwheels.racing/files/img/tracks/${cleanTrack}/map.ini`;
let response = await fetch(configUrl);
// Fallback to local public directory
if (!response.ok) {
const localPath = cleanConfig && cleanConfig !== 'default'
? `/tracks/${cleanTrack}/${cleanConfig}/map.ini`
: `/tracks/${cleanTrack}/map.ini`;
response = await fetch(localPath);
}
if (!response.ok) {
console.log(`[TrackMap] No map.ini found for ${cleanTrack}/${cleanConfig}`);
configCache.set(cacheKey, null);
return null;
}
const iniText = await response.text();
const config = parseMapIni(iniText);
console.log(`[TrackMap] Loaded config for ${cleanTrack}/${cleanConfig}:`, config);
configCache.set(cacheKey, config);
return config;
} catch (error) {
console.warn(`[TrackMap] Failed to load map.ini for ${cleanTrack}:`, error);
configCache.set(cacheKey, null);
return null;
}
}
/**
* Parse map.ini text format
*/
function parseMapIni(iniText: string): TrackMapConfig {
const lines = iniText.split('\n');
const config: Partial<TrackMapConfig> = {};
for (const line of lines) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('[')) {
continue;
}
// Parse KEY=VALUE
const [key, value] = trimmed.split('=').map(s => s.trim());
if (!key || !value) continue;
const numValue = parseFloat(value);
switch (key.toUpperCase()) {
case 'WIDTH':
config.width = numValue;
break;
case 'HEIGHT':
config.height = numValue;
break;
case 'MARGIN':
config.margin = numValue;
break;
case 'SCALE_FACTOR':
config.scaleFactor = numValue;
break;
case 'X_OFFSET':
config.xOffset = numValue;
break;
case 'Z_OFFSET':
config.zOffset = numValue;
break;
case 'DRAWING_SIZE':
config.drawingSize = numValue;
break;
}
}
// Return with defaults if any values are missing
return {
width: config.width || 1000,
height: config.height || 1000,
margin: config.margin || 20,
scaleFactor: config.scaleFactor || 1.0,
xOffset: config.xOffset || 0,
zOffset: config.zOffset || 0,
drawingSize: config.drawingSize || 10,
};
}
/**
* Convert world coordinates to map pixel coordinates using AC's formula
* Trying with coordinate rotation
*/
/**
* Convert world coordinates to map pixel coordinates
* Based on working formula with potential rotation fix
*/
export function worldToMapCoords(
worldX: number,
worldY: number,
worldZ: number,
config: TrackMapConfig
): { x: number; y: number } {
var aspectRatio = config.width / config.height;
// Add offsets to player position
if (aspectRatio < 1) {
worldX = worldX * aspectRatio;
} else {
worldZ = worldZ / aspectRatio;
}
var x = (worldX) + config.xOffset;
var y = (worldZ) + config.zOffset;
y /= config.scaleFactor;
x /= config.scaleFactor;
// Percentages
x = (x * 100) / (config.width);
y = (y * 100) / (config.height);
return { x, y };
}