Feature: Live Map and Events Tab
This commit is contained in:
+170
-97
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user