feat: add PCARS 1 & 2 UDP telemetry parser (port 5606)
- Auto-detect PCARS1 (1367-byte V1) vs PCARS2 (559-byte V2) packet format - Parse speed, RPM, gear, inputs, tyres, engine temps, damage, weather - Map PCARS-specific fields to TelemetryFrame (oil/water temp, damage, etc.) - Add FeedSource::Pcars + 16 PCARS-specific Option fields to TelemetryFrame - Port 5606 as optional bind-listen feed (like Telemetry Tool) - 5 new unit tests for PCARS parser (32 total) - Shared binary_utils module for all parsers
This commit is contained in:
parent
7a1c37fc86
commit
a50d3af290
4 changed files with 570 additions and 0 deletions
|
|
@ -41,6 +41,22 @@ pub fn read_i16(data: &[u8], offset: usize) -> i16 {
|
||||||
i16::from_le_bytes(buf)
|
i16::from_le_bytes(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a little-endian `u16` from the data at the given offset.
|
||||||
|
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
|
||||||
|
pub fn read_u16(data: &[u8], offset: usize) -> u16 {
|
||||||
|
let buf: [u8; 2] = data
|
||||||
|
.get(offset..offset + 2)
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.unwrap_or([0u8; 2]);
|
||||||
|
u16::from_le_bytes(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a signed `i8` from the data at the given offset.
|
||||||
|
/// Returns 0 if the offset is out of bounds.
|
||||||
|
pub fn read_i8(data: &[u8], offset: usize) -> i8 {
|
||||||
|
data.get(offset).map(|&b| b as i8).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a little-endian `f32` from the data at the given offset.
|
/// Read a little-endian `f32` from the data at the given offset.
|
||||||
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
|
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
|
||||||
pub fn read_f32(data: &[u8], offset: usize) -> f32 {
|
pub fn read_f32(data: &[u8], offset: usize) -> f32 {
|
||||||
|
|
|
||||||
14
src/main.rs
14
src/main.rs
|
|
@ -4,6 +4,7 @@ mod ui;
|
||||||
mod telemetry_frame;
|
mod telemetry_frame;
|
||||||
mod parser_ksudp;
|
mod parser_ksudp;
|
||||||
mod parser_telemetry_tool;
|
mod parser_telemetry_tool;
|
||||||
|
mod parser_pcars;
|
||||||
|
|
||||||
use feed_state::*;
|
use feed_state::*;
|
||||||
use ui::*;
|
use ui::*;
|
||||||
|
|
@ -69,6 +70,12 @@ static FEEDS: &[FeedConfig] = &[
|
||||||
label: "Telemetry Tool",
|
label: "Telemetry Tool",
|
||||||
needs_handshake: false,
|
needs_handshake: false,
|
||||||
},
|
},
|
||||||
|
FeedConfig {
|
||||||
|
port: 5606,
|
||||||
|
name: "pcars",
|
||||||
|
label: "PCARS",
|
||||||
|
needs_handshake: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Term = Terminal<CrosstermBackend<Stdout>>;
|
type Term = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
@ -265,6 +272,13 @@ async fn run_feed(
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
} else if feed.name == "pcars" {
|
||||||
|
// PCARS: auto-detect V1/V2 format
|
||||||
|
match parser_pcars::parse_pcars_packet(&buf[..len]) {
|
||||||
|
Ok(parser_pcars::PcarsPacket::V1Telemetry(f)) => Some(f),
|
||||||
|
Ok(parser_pcars::PcarsPacket::V2CarPhysics(f)) => Some(f),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Telemetry Tool: parse only the first driver
|
// Telemetry Tool: parse only the first driver
|
||||||
parser_telemetry_tool::parse_first_driver(&buf[..len]).ok()
|
parser_telemetry_tool::parse_first_driver(&buf[..len]).ok()
|
||||||
|
|
|
||||||
504
src/parser_pcars.rs
Normal file
504
src/parser_pcars.rs
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
//! Binary parser for Project CARS 1 & 2 UDP telemetry packets (port 5606).
|
||||||
|
//!
|
||||||
|
//! Both PCARS1 and PCARS2 broadcast on port 5606. The parser auto-detects the
|
||||||
|
//! format based on packet structure:
|
||||||
|
//! - PCARS 1 (Format 1): ~1367 bytes, 3-byte header (u16 build + packed u8)
|
||||||
|
//! - PCARS 2 (Format 2): 12-byte header with packet_type at offset 10
|
||||||
|
|
||||||
|
use crate::binary_utils::*;
|
||||||
|
use crate::telemetry_frame::{FeedSource, TelemetryFrame, NUM_WHEELS};
|
||||||
|
|
||||||
|
// ── PCARS 1 constants ──────────────────────────────────────────────────
|
||||||
|
const V1_TELEMETRY_SIZE: usize = 1367;
|
||||||
|
const V1_PACKET_TYPE_TELEMETRY: u8 = 0;
|
||||||
|
|
||||||
|
// ── PCARS 2 constants ──────────────────────────────────────────────────
|
||||||
|
const V2_HEADER_SIZE: usize = 12;
|
||||||
|
const V2_CAR_PHYSICS_SIZE: usize = 559;
|
||||||
|
const V2_PACKET_TYPE_CAR_PHYSICS: u8 = 0;
|
||||||
|
|
||||||
|
/// Parse error type for PCARS packets.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PcarsParseError {
|
||||||
|
/// Packet too short.
|
||||||
|
TooShort { actual: usize, needed: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PcarsParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PcarsParseError::TooShort { actual, needed } => {
|
||||||
|
write!(f, "packet too short: {} bytes (needed {})", actual, needed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PcarsParseError {}
|
||||||
|
|
||||||
|
/// Parsed result of a PCARS packet.
|
||||||
|
pub enum PcarsPacket {
|
||||||
|
/// PCARS 1 telemetry data (Format 1, type 0).
|
||||||
|
V1Telemetry(TelemetryFrame),
|
||||||
|
/// PCARS 2 car physics data (Format 2, type 0).
|
||||||
|
V2CarPhysics(TelemetryFrame),
|
||||||
|
/// PCARS 2 non-telemetry packet (timings, game state, etc.) — ignored.
|
||||||
|
V2Other,
|
||||||
|
/// Unrecognised packet.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a single byte as u8 at the given offset.
|
||||||
|
fn read_u8_at(data: &[u8], offset: usize) -> u8 {
|
||||||
|
data.get(offset).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a single byte as i8 at the given offset.
|
||||||
|
fn read_i8_at(data: &[u8], offset: usize) -> i8 {
|
||||||
|
data.get(offset).copied().unwrap_or(0) as i8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read 4 consecutive u8 values starting at offset.
|
||||||
|
fn read_u8_4(data: &[u8], offset: usize) -> [u8; NUM_WHEELS] {
|
||||||
|
let mut arr = [0u8; NUM_WHEELS];
|
||||||
|
for i in 0..NUM_WHEELS {
|
||||||
|
arr[i] = read_u8_at(data, offset + i);
|
||||||
|
}
|
||||||
|
arr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read 4 consecutive i16 values starting at offset.
|
||||||
|
fn read_i16_4(data: &[u8], offset: usize) -> [i16; NUM_WHEELS] {
|
||||||
|
let mut arr = [0i16; NUM_WHEELS];
|
||||||
|
for i in 0..NUM_WHEELS {
|
||||||
|
arr[i] = read_i16(data, offset + i * 2);
|
||||||
|
}
|
||||||
|
arr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize u8 (0–255) to f32 (0.0–1.0).
|
||||||
|
fn normalize_u8(v: u8) -> f32 {
|
||||||
|
v as f32 / 255.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize int8 (-127..127) to f32 (-1.0..1.0).
|
||||||
|
fn normalize_i8(v: i8) -> f32 {
|
||||||
|
v as f32 / 127.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detect and parse a PCARS UDP packet.
|
||||||
|
pub fn parse_pcars_packet(data: &[u8]) -> Result<PcarsPacket, PcarsParseError> {
|
||||||
|
if data.len() < 3 {
|
||||||
|
return Err(PcarsParseError::TooShort { actual: data.len(), needed: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detection strategy:
|
||||||
|
// PCARS 1 telemetry packets are ~1367 bytes — much larger than any V2 packet.
|
||||||
|
// PCARS 2 packets have a 12-byte header with packet_type at offset 10.
|
||||||
|
//
|
||||||
|
// Check V1 first for large packets (V2 never produces packets > ~1100 bytes).
|
||||||
|
|
||||||
|
// Try V1 format: check packet type from packed byte at offset 2
|
||||||
|
let seq_type = read_u8_at(data, 2);
|
||||||
|
let v1_packet_type = seq_type & 0x03;
|
||||||
|
|
||||||
|
if v1_packet_type == V1_PACKET_TYPE_TELEMETRY && data.len() >= V1_TELEMETRY_SIZE {
|
||||||
|
return Ok(PcarsPacket::V1Telemetry(parse_v1_telemetry(data)?));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try V2 format: 12-byte header with packet_type at offset 10
|
||||||
|
if data.len() >= V2_HEADER_SIZE {
|
||||||
|
let packet_type = read_u8_at(data, 10);
|
||||||
|
|
||||||
|
if packet_type <= 8 {
|
||||||
|
match packet_type {
|
||||||
|
V2_PACKET_TYPE_CAR_PHYSICS if data.len() >= V2_CAR_PHYSICS_SIZE => {
|
||||||
|
return Ok(PcarsPacket::V2CarPhysics(parse_v2_car_physics(data)?));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Ok(PcarsPacket::V2Other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PcarsPacket::Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PCARS 1 (Format 1) telemetry packet (1367 bytes).
|
||||||
|
fn parse_v1_telemetry(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
|
||||||
|
if data.len() < V1_TELEMETRY_SIZE {
|
||||||
|
return Err(PcarsParseError::TooShort { actual: data.len(), needed: V1_TELEMETRY_SIZE });
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed_ms = read_f32(data, 120);
|
||||||
|
let speed_kmh = speed_ms * 3.6;
|
||||||
|
|
||||||
|
// Timing (f32 seconds → i32 ms)
|
||||||
|
let best_lap_s = read_f32(data, 12);
|
||||||
|
let last_lap_s = read_f32(data, 16);
|
||||||
|
let current_lap_s = read_f32(data, 20);
|
||||||
|
|
||||||
|
// Gear: packed byte at 128, gear in LSB 4 bits
|
||||||
|
let gear_packed = read_u8_at(data, 128);
|
||||||
|
let gear = (gear_packed & 0x0F) as i32;
|
||||||
|
|
||||||
|
// Inputs (u8 0-255, normalize to 0-1)
|
||||||
|
let throttle = normalize_u8(read_u8_at(data, 113));
|
||||||
|
let brake = normalize_u8(read_u8_at(data, 112));
|
||||||
|
let clutch = normalize_u8(read_u8_at(data, 114));
|
||||||
|
let steer = normalize_i8(read_i8_at(data, 115));
|
||||||
|
|
||||||
|
let rpm = read_u16(data, 124) as f32;
|
||||||
|
|
||||||
|
// Accelerations (local_acceleration at offset 184)
|
||||||
|
let acc_vert = read_f32(data, 184);
|
||||||
|
let acc_horiz = read_f32(data, 188);
|
||||||
|
let acc_front = read_f32(data, 192);
|
||||||
|
|
||||||
|
// Engine & damage
|
||||||
|
let aero_damage = read_u8_at(data, 456);
|
||||||
|
let engine_damage = read_u8_at(data, 457);
|
||||||
|
let ambient_temp = read_i8_at(data, 458);
|
||||||
|
let track_temp = read_i8_at(data, 459);
|
||||||
|
let rain_density = read_u8_at(data, 460);
|
||||||
|
|
||||||
|
// Track length
|
||||||
|
let track_length = read_f32(data, 1360);
|
||||||
|
|
||||||
|
let frame = TelemetryFrame {
|
||||||
|
source: FeedSource::Pcars,
|
||||||
|
raw_size: data.len(),
|
||||||
|
|
||||||
|
speed_kmh,
|
||||||
|
speed_mph: speed_ms * 2.236936,
|
||||||
|
speed_ms,
|
||||||
|
|
||||||
|
engine_rpm: rpm,
|
||||||
|
gear,
|
||||||
|
|
||||||
|
throttle,
|
||||||
|
brake,
|
||||||
|
clutch,
|
||||||
|
steer,
|
||||||
|
|
||||||
|
lap_time_ms: (current_lap_s * 1000.0) as i32,
|
||||||
|
last_lap_ms: (last_lap_s * 1000.0) as i32,
|
||||||
|
best_lap_ms: (best_lap_s * 1000.0) as i32,
|
||||||
|
lap_count: 0, // Not directly in V1 telemetry packet
|
||||||
|
|
||||||
|
world_x: 0.0,
|
||||||
|
world_y: 0.0,
|
||||||
|
world_z: 0.0,
|
||||||
|
spline_position: 0.0,
|
||||||
|
|
||||||
|
acc_g_vertical: acc_vert,
|
||||||
|
acc_g_horizontal: acc_horiz,
|
||||||
|
acc_g_frontal: acc_front,
|
||||||
|
|
||||||
|
is_abs_enabled: false,
|
||||||
|
is_abs_in_action: false,
|
||||||
|
is_tc_enabled: false,
|
||||||
|
is_tc_in_action: false,
|
||||||
|
is_in_pit: false,
|
||||||
|
is_engine_limiter_on: false,
|
||||||
|
|
||||||
|
// Per-wheel
|
||||||
|
wheel_angular_speed: read_f32_4(data, 244), // tyre_rps
|
||||||
|
slip_angle: [0.0; NUM_WHEELS],
|
||||||
|
slip_ratio: [0.0; NUM_WHEELS],
|
||||||
|
tyre_slip: [0.0; NUM_WHEELS],
|
||||||
|
nd_slip: [0.0; NUM_WHEELS],
|
||||||
|
suspension_height: read_f32_4(data, 408), // suspension_travel
|
||||||
|
tyre_dirty_level: [0.0; NUM_WHEELS],
|
||||||
|
|
||||||
|
// KSUDP-specific (defaults)
|
||||||
|
slip_angle_contact_patch: [0.0; NUM_WHEELS],
|
||||||
|
wheel_load: [0.0; NUM_WHEELS],
|
||||||
|
wheel_dy: read_f32_4(data, 228), // tyre_y
|
||||||
|
wheel_mz: [0.0; NUM_WHEELS],
|
||||||
|
camber_rad: [0.0; NUM_WHEELS],
|
||||||
|
tyre_radius: [0.0; NUM_WHEELS],
|
||||||
|
tyre_loaded_radius: [0.0; NUM_WHEELS],
|
||||||
|
cg_height: 0.0,
|
||||||
|
car_slope: 0.0,
|
||||||
|
car_position_normalized: 0.0,
|
||||||
|
|
||||||
|
// PCARS-specific
|
||||||
|
oil_temp_celsius: Some(read_i16(data, 100)),
|
||||||
|
water_temp_celsius: Some(read_i16(data, 104)),
|
||||||
|
fuel_capacity: Some(read_u8_at(data, 111)),
|
||||||
|
boost_amount: Some(read_u8_at(data, 129)),
|
||||||
|
crash_state: Some(read_u8_at(data, 131) & 0x07),
|
||||||
|
odometer_km: Some(read_f32(data, 132)),
|
||||||
|
aero_damage: Some(aero_damage),
|
||||||
|
engine_damage: Some(engine_damage),
|
||||||
|
ambient_temperature: Some(ambient_temp),
|
||||||
|
track_temperature: Some(track_temp),
|
||||||
|
rain_density: Some(rain_density),
|
||||||
|
track_length: Some(track_length),
|
||||||
|
full_position: None, // V1 does not have full 3D position
|
||||||
|
tyre_wear: Some(read_u8_4(data, 316)),
|
||||||
|
brake_temp_celsius: Some(read_i16_4(data, 328)),
|
||||||
|
suspension_damage: Some(read_u8_4(data, 324)),
|
||||||
|
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PCARS 2 (Format 2) car physics packet (559 bytes).
|
||||||
|
fn parse_v2_car_physics(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
|
||||||
|
if data.len() < V2_CAR_PHYSICS_SIZE {
|
||||||
|
return Err(PcarsParseError::TooShort { actual: data.len(), needed: V2_CAR_PHYSICS_SIZE });
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed_ms = read_f32(data, 36);
|
||||||
|
let speed_kmh = speed_ms * 3.6;
|
||||||
|
|
||||||
|
// Gear: packed byte at 45, gear in LSB 4 bits
|
||||||
|
let gear_packed = read_u8_at(data, 45);
|
||||||
|
let gear = (gear_packed & 0x0F) as i32;
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
let throttle = normalize_u8(read_u8_at(data, 30));
|
||||||
|
let brake = normalize_u8(read_u8_at(data, 29));
|
||||||
|
let clutch = normalize_u8(read_u8_at(data, 31));
|
||||||
|
let steer = normalize_i8(read_i8_at(data, 44));
|
||||||
|
|
||||||
|
let rpm = read_u16(data, 40) as f32;
|
||||||
|
|
||||||
|
// Accelerations (local_acceleration at offset 100)
|
||||||
|
let acc_vert = read_f32(data, 100);
|
||||||
|
let acc_horiz = read_f32(data, 104);
|
||||||
|
let acc_front = read_f32(data, 108);
|
||||||
|
|
||||||
|
// Full 3D position (PCARS2 only, offset 542)
|
||||||
|
let full_pos = read_f32_3(data, 542);
|
||||||
|
|
||||||
|
// Tyre compounds (4 × 40 chars at offset 378)
|
||||||
|
let tyre_compound_str = read_string(data, 378, 40);
|
||||||
|
|
||||||
|
let frame = TelemetryFrame {
|
||||||
|
source: FeedSource::Pcars,
|
||||||
|
raw_size: data.len(),
|
||||||
|
|
||||||
|
speed_kmh,
|
||||||
|
speed_mph: speed_ms * 2.236936,
|
||||||
|
speed_ms,
|
||||||
|
|
||||||
|
engine_rpm: rpm,
|
||||||
|
gear,
|
||||||
|
|
||||||
|
throttle,
|
||||||
|
brake,
|
||||||
|
clutch,
|
||||||
|
steer,
|
||||||
|
|
||||||
|
lap_time_ms: 0, // V2 timing is in a separate packet (type 3)
|
||||||
|
last_lap_ms: 0,
|
||||||
|
best_lap_ms: 0,
|
||||||
|
lap_count: 0,
|
||||||
|
|
||||||
|
world_x: full_pos[0],
|
||||||
|
world_y: full_pos[1],
|
||||||
|
world_z: full_pos[2],
|
||||||
|
spline_position: 0.0,
|
||||||
|
|
||||||
|
acc_g_vertical: acc_vert,
|
||||||
|
acc_g_horizontal: acc_horiz,
|
||||||
|
acc_g_frontal: acc_front,
|
||||||
|
|
||||||
|
is_abs_enabled: false,
|
||||||
|
is_abs_in_action: false,
|
||||||
|
is_tc_enabled: false,
|
||||||
|
is_tc_in_action: false,
|
||||||
|
is_in_pit: false,
|
||||||
|
is_engine_limiter_on: false,
|
||||||
|
|
||||||
|
// Per-wheel
|
||||||
|
wheel_angular_speed: read_f32_4(data, 160), // tyre_rps
|
||||||
|
slip_angle: [0.0; NUM_WHEELS],
|
||||||
|
slip_ratio: [0.0; NUM_WHEELS],
|
||||||
|
tyre_slip: [0.0; NUM_WHEELS],
|
||||||
|
nd_slip: [0.0; NUM_WHEELS],
|
||||||
|
suspension_height: read_f32_4(data, 312), // suspension_travel
|
||||||
|
tyre_dirty_level: [0.0; NUM_WHEELS],
|
||||||
|
|
||||||
|
// KSUDP-specific (defaults)
|
||||||
|
slip_angle_contact_patch: [0.0; NUM_WHEELS],
|
||||||
|
wheel_load: [0.0; NUM_WHEELS],
|
||||||
|
wheel_dy: read_f32_4(data, 144), // tyre_y
|
||||||
|
wheel_mz: [0.0; NUM_WHEELS],
|
||||||
|
camber_rad: [0.0; NUM_WHEELS],
|
||||||
|
tyre_radius: [0.0; NUM_WHEELS],
|
||||||
|
tyre_loaded_radius: [0.0; NUM_WHEELS],
|
||||||
|
cg_height: 0.0,
|
||||||
|
car_slope: 0.0,
|
||||||
|
car_position_normalized: 0.0,
|
||||||
|
|
||||||
|
// Telemetry Tool-specific (none applicable)
|
||||||
|
tyre_compound: if tyre_compound_str.is_empty() { None } else { Some(tyre_compound_str) },
|
||||||
|
turbo_boost: Some(read_f32(data, 538)),
|
||||||
|
|
||||||
|
// PCARS-specific
|
||||||
|
oil_temp_celsius: Some(read_i16(data, 18)),
|
||||||
|
water_temp_celsius: Some(read_i16(data, 22)),
|
||||||
|
fuel_capacity: Some(read_u8_at(data, 28)),
|
||||||
|
boost_amount: Some(read_u8_at(data, 46)),
|
||||||
|
crash_state: Some(read_u8_at(data, 47)),
|
||||||
|
odometer_km: Some(read_f32(data, 48)),
|
||||||
|
aero_damage: Some(read_u8_at(data, 371)),
|
||||||
|
engine_damage: Some(read_u8_at(data, 372)),
|
||||||
|
ambient_temperature: None, // In Game State packet (type 4), not here
|
||||||
|
track_temperature: None,
|
||||||
|
rain_density: None,
|
||||||
|
track_length: None,
|
||||||
|
full_position: Some(full_pos),
|
||||||
|
tyre_wear: Some(read_u8_4(data, 196)),
|
||||||
|
brake_temp_celsius: Some(read_i16_4(data, 208)),
|
||||||
|
suspension_damage: Some(read_u8_4(data, 204)),
|
||||||
|
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a u16 at the given offset (little-endian).
|
||||||
|
fn read_u16(data: &[u8], offset: usize) -> u16 {
|
||||||
|
let buf: [u8; 2] = data
|
||||||
|
.get(offset..offset + 2)
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.unwrap_or([0u8; 2]);
|
||||||
|
u16::from_le_bytes(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v1_telemetry_packet() {
|
||||||
|
let mut packet = vec![0u8; V1_TELEMETRY_SIZE];
|
||||||
|
// Header: build_version = 1104, seq_type = 0 (type 0 = telemetry)
|
||||||
|
packet[0..2].copy_from_slice(&1104u16.to_le_bytes());
|
||||||
|
packet[2] = 0x00; // seq=0, type=0
|
||||||
|
|
||||||
|
// Speed = 50.0 m/s at offset 120
|
||||||
|
packet[120..124].copy_from_slice(&50.0f32.to_le_bytes());
|
||||||
|
// RPM = 7200 at offset 124
|
||||||
|
packet[124..126].copy_from_slice(&7200u16.to_le_bytes());
|
||||||
|
// Gear: packed at offset 128, gear=3 (meaning gear 2)
|
||||||
|
packet[128] = 0x30 | 0x03; // 3 gears-high, gear=3
|
||||||
|
// Throttle at 113 = 200/255
|
||||||
|
packet[113] = 200;
|
||||||
|
// Brake at 112 = 0
|
||||||
|
packet[112] = 0;
|
||||||
|
// Best lap at 12 = 78.123 seconds
|
||||||
|
packet[12..16].copy_from_slice(&78.123f32.to_le_bytes());
|
||||||
|
// Last lap at 16 = 80.456 seconds
|
||||||
|
packet[16..20].copy_from_slice(&80.456f32.to_le_bytes());
|
||||||
|
// Current time at 20 = 42.5 seconds
|
||||||
|
packet[20..24].copy_from_slice(&42.5f32.to_le_bytes());
|
||||||
|
|
||||||
|
let result = parse_pcars_packet(&packet).unwrap();
|
||||||
|
match result {
|
||||||
|
PcarsPacket::V1Telemetry(frame) => {
|
||||||
|
assert_eq!(frame.source, FeedSource::Pcars);
|
||||||
|
assert!((frame.speed_kmh - 180.0).abs() < 0.1);
|
||||||
|
assert!((frame.speed_ms - 50.0).abs() < 0.01);
|
||||||
|
assert!((frame.engine_rpm - 7200.0).abs() < 1.0);
|
||||||
|
assert_eq!(frame.gear, 3);
|
||||||
|
assert!((frame.throttle - (200.0 / 255.0)).abs() < 0.01);
|
||||||
|
assert!((frame.brake - 0.0).abs() < 0.01);
|
||||||
|
assert_eq!(frame.best_lap_ms, 78123);
|
||||||
|
assert_eq!(frame.last_lap_ms, 80456);
|
||||||
|
assert_eq!(frame.lap_time_ms, 42500);
|
||||||
|
assert_eq!(frame.oil_temp_celsius, Some(0)); // default
|
||||||
|
assert!(frame.aero_damage.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected V1Telemetry"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_car_physics_packet() {
|
||||||
|
let mut packet = vec![0u8; V2_CAR_PHYSICS_SIZE];
|
||||||
|
// Header
|
||||||
|
packet[0..4].copy_from_slice(&1u32.to_le_bytes()); // packet_number
|
||||||
|
packet[4..8].copy_from_slice(&1u32.to_le_bytes()); // category_packet_number
|
||||||
|
packet[10] = V2_PACKET_TYPE_CAR_PHYSICS; // packet_type = 0
|
||||||
|
packet[11] = 1; // packet_version
|
||||||
|
|
||||||
|
// Speed = 60.0 m/s at offset 36
|
||||||
|
packet[36..40].copy_from_slice(&60.0f32.to_le_bytes());
|
||||||
|
// RPM = 8500 at offset 40
|
||||||
|
packet[40..42].copy_from_slice(&8500u16.to_le_bytes());
|
||||||
|
// Gear at offset 45: gear=4
|
||||||
|
packet[45] = 0x40 | 0x04;
|
||||||
|
// Throttle at offset 30 = 255
|
||||||
|
packet[30] = 255;
|
||||||
|
// Brake at offset 29 = 128
|
||||||
|
packet[29] = 128;
|
||||||
|
// Full position at offset 542
|
||||||
|
packet[542..546].copy_from_slice(&100.0f32.to_le_bytes());
|
||||||
|
packet[546..550].copy_from_slice(&5.0f32.to_le_bytes());
|
||||||
|
packet[550..554].copy_from_slice(&(-200.0f32).to_le_bytes());
|
||||||
|
|
||||||
|
let result = parse_pcars_packet(&packet).unwrap();
|
||||||
|
match result {
|
||||||
|
PcarsPacket::V2CarPhysics(frame) => {
|
||||||
|
assert_eq!(frame.source, FeedSource::Pcars);
|
||||||
|
assert!((frame.speed_kmh - 216.0).abs() < 0.1);
|
||||||
|
assert!((frame.engine_rpm - 8500.0).abs() < 1.0);
|
||||||
|
assert_eq!(frame.gear, 4);
|
||||||
|
assert!((frame.throttle - 1.0).abs() < 0.01);
|
||||||
|
assert!((frame.brake - (128.0 / 255.0)).abs() < 0.01);
|
||||||
|
assert!((frame.world_x - 100.0).abs() < 0.01);
|
||||||
|
assert!((frame.world_y - 5.0).abs() < 0.01);
|
||||||
|
assert!((frame.world_z - (-200.0)).abs() < 0.01);
|
||||||
|
assert!(frame.full_position.is_some());
|
||||||
|
assert!(frame.turbo_boost.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected V2CarPhysics"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_too_short() {
|
||||||
|
let result = parse_pcars_packet(&[0u8; 2]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_format() {
|
||||||
|
// A packet that's too small for V1 (< 1367) and has byte[10] > 8 (not a valid V2 type)
|
||||||
|
let mut packet = vec![0u8; 100];
|
||||||
|
packet[2] = 0xFF; // V1 type bits = 3 (not telemetry)
|
||||||
|
packet[10] = 9; // V2 type = 9 (invalid)
|
||||||
|
let result = parse_pcars_packet(&packet);
|
||||||
|
assert!(matches!(result, Ok(PcarsPacket::Unknown)));
|
||||||
|
|
||||||
|
// Very small packet with no recognizable format
|
||||||
|
let mut p2 = vec![0u8; 50];
|
||||||
|
p2[2] = 0xFF; // V1 type bits = 3 (not telemetry)
|
||||||
|
p2[10] = 9; // V2 type = 9 (invalid)
|
||||||
|
let result2 = parse_pcars_packet(&p2);
|
||||||
|
assert!(matches!(result2, Ok(PcarsPacket::Unknown)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_other_packet_types() {
|
||||||
|
let mut packet = vec![0u8; V2_HEADER_SIZE + 20];
|
||||||
|
packet[0..4].copy_from_slice(&1u32.to_le_bytes());
|
||||||
|
packet[10] = 3; // Timings packet
|
||||||
|
let result = parse_pcars_packet(&packet);
|
||||||
|
assert!(matches!(result, Ok(PcarsPacket::V2Other)));
|
||||||
|
|
||||||
|
packet[10] = 4; // Game State
|
||||||
|
let result = parse_pcars_packet(&packet);
|
||||||
|
assert!(matches!(result, Ok(PcarsPacket::V2Other)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use std::fmt;
|
||||||
pub enum FeedSource {
|
pub enum FeedSource {
|
||||||
Ksudp,
|
Ksudp,
|
||||||
TelemetryTool,
|
TelemetryTool,
|
||||||
|
Pcars,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-wheel index convention: FL=0, FR=1, RL=2, RR=3.
|
/// Per-wheel index convention: FL=0, FR=1, RL=2, RR=3.
|
||||||
|
|
@ -106,6 +107,24 @@ pub struct TelemetryFrame {
|
||||||
pub last_sector_times: Option<[i32; 3]>,
|
pub last_sector_times: Option<[i32; 3]>,
|
||||||
pub driver_id: Option<u32>,
|
pub driver_id: Option<u32>,
|
||||||
|
|
||||||
|
// ── PCARS-specific fields ──────────────────────────────────────
|
||||||
|
pub oil_temp_celsius: Option<i16>,
|
||||||
|
pub water_temp_celsius: Option<i16>,
|
||||||
|
pub fuel_capacity: Option<u8>,
|
||||||
|
pub boost_amount: Option<u8>,
|
||||||
|
pub crash_state: Option<u8>,
|
||||||
|
pub odometer_km: Option<f32>,
|
||||||
|
pub aero_damage: Option<u8>,
|
||||||
|
pub engine_damage: Option<u8>,
|
||||||
|
pub ambient_temperature: Option<i8>,
|
||||||
|
pub track_temperature: Option<i8>,
|
||||||
|
pub rain_density: Option<u8>,
|
||||||
|
pub track_length: Option<f32>,
|
||||||
|
pub full_position: Option<[f32; 3]>,
|
||||||
|
pub tyre_wear: Option<[u8; 4]>,
|
||||||
|
pub brake_temp_celsius: Option<[i16; 4]>,
|
||||||
|
pub suspension_damage: Option<[u8; 4]>,
|
||||||
|
|
||||||
// ── Meta ───────────────────────────────────────────────────────
|
// ── Meta ───────────────────────────────────────────────────────
|
||||||
pub source: FeedSource,
|
pub source: FeedSource,
|
||||||
pub raw_size: usize,
|
pub raw_size: usize,
|
||||||
|
|
@ -179,6 +198,22 @@ impl Default for TelemetryFrame {
|
||||||
sector_times: None,
|
sector_times: None,
|
||||||
last_sector_times: None,
|
last_sector_times: None,
|
||||||
driver_id: None,
|
driver_id: None,
|
||||||
|
oil_temp_celsius: None,
|
||||||
|
water_temp_celsius: None,
|
||||||
|
fuel_capacity: None,
|
||||||
|
boost_amount: None,
|
||||||
|
crash_state: None,
|
||||||
|
odometer_km: None,
|
||||||
|
aero_damage: None,
|
||||||
|
engine_damage: None,
|
||||||
|
ambient_temperature: None,
|
||||||
|
track_temperature: None,
|
||||||
|
rain_density: None,
|
||||||
|
track_length: None,
|
||||||
|
full_position: None,
|
||||||
|
tyre_wear: None,
|
||||||
|
brake_temp_celsius: None,
|
||||||
|
suspension_damage: None,
|
||||||
source: FeedSource::Ksudp,
|
source: FeedSource::Ksudp,
|
||||||
raw_size: 0,
|
raw_size: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +248,7 @@ impl fmt::Display for FeedSource {
|
||||||
match self {
|
match self {
|
||||||
FeedSource::Ksudp => write!(f, "KSUDP"),
|
FeedSource::Ksudp => write!(f, "KSUDP"),
|
||||||
FeedSource::TelemetryTool => write!(f, "Telemetry Tool"),
|
FeedSource::TelemetryTool => write!(f, "Telemetry Tool"),
|
||||||
|
FeedSource::Pcars => write!(f, "PCARS"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue