refactor: TUI multi-feed monitor with binary parsers

- Add ratatui/crossterm TUI replacing stdout data dump
- Add binary parsers for KSUDP RTCarInfo (port 9996) and Telemetry Tool (port 10101)
- Add shared binary_utils module for safe packet reading
- Add ring-buffer frequency calculation and feed health state machine
- Add parse_first_driver() to avoid unnecessary multi-driver allocation
- Add 27 unit tests (14 feed_state + 6 ksudp + 7 telemetry_tool)
- Move parsing outside mutex lock to minimize TUI contention
- Replace expect() on env var with graceful error handling
- Remove unused serde dependency
- Delete obsolete stats.rs and telemetry.rs
This commit is contained in:
Jan-Peter von Hunnius 2026-06-04 08:48:35 +02:00
parent 1731495197
commit 7a1c37fc86
11 changed files with 2308 additions and 307 deletions

241
src/parser_ksudp.rs Normal file
View file

@ -0,0 +1,241 @@
//! Binary parser for KSUDP RTCarInfo packets (Assetto Corsa port 9996).
//!
//! After the 3-phase handshake, AC sends continuous RTCarInfo structs at ~20 Hz.
//! This parser converts the raw binary data into a `TelemetryFrame`.
use crate::binary_utils::{read_bool, read_f32, read_f32_4, read_i32};
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
/// Minimum expected packet size (identifier + size field + all known fields).
/// The `size` field in the packet itself reports the true struct size.
const MIN_PACKET_SIZE: usize = 323;
/// Parse error type for KSUDP packets.
#[derive(Debug)]
pub enum KsudpParseError {
/// Packet too short to contain a valid RTCarInfo.
TooShort { actual: usize, expected: usize },
/// Invalid identifier byte (expected 0x61 = 'a').
InvalidIdentifier { got: u8 },
}
impl std::fmt::Display for KsudpParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KsudpParseError::TooShort { actual, expected } => {
write!(f, "packet too short: {} bytes (expected >= {})", actual, expected)
}
KsudpParseError::InvalidIdentifier { got } => {
write!(f, "invalid identifier: 0x{:02x} (expected 0x61 = 'a')", got)
}
}
}
}
impl std::error::Error for KsudpParseError {}
/// Parse a raw KSUDP RTCarInfo packet into a `TelemetryFrame`.
///
/// # Errors
/// Returns `KsudpParseError` if the packet is too short or has an invalid identifier.
pub fn parse_rt_car_info(data: &[u8]) -> Result<TelemetryFrame, KsudpParseError> {
if data.len() < MIN_PACKET_SIZE {
return Err(KsudpParseError::TooShort {
actual: data.len(),
expected: MIN_PACKET_SIZE,
});
}
// Validate identifier
let identifier = data[0];
if identifier != b'a' {
return Err(KsudpParseError::InvalidIdentifier { got: identifier });
}
let reported_size = read_i32(data, 1) as usize;
if data.len() < reported_size && reported_size > 0 {
// Log warning but don't fail — the struct might have fields we don't read yet
eprintln!(
"[WARN] KSUDP: reported struct size {} but packet is {} bytes",
reported_size,
data.len()
);
}
let frame = TelemetryFrame {
source: FeedSource::Ksudp,
raw_size: data.len(),
// Speed
speed_kmh: read_f32(data, 5),
speed_mph: read_f32(data, 9),
speed_ms: read_f32(data, 13),
// Flags
is_abs_enabled: read_bool(data, 17),
is_abs_in_action: read_bool(data, 18),
is_tc_in_action: read_bool(data, 19),
is_tc_enabled: read_bool(data, 20),
is_in_pit: read_bool(data, 21),
is_engine_limiter_on: read_bool(data, 22),
// G-forces
acc_g_vertical: read_f32(data, 23),
acc_g_horizontal: read_f32(data, 27),
acc_g_frontal: read_f32(data, 31),
// Lap timing
lap_time_ms: read_i32(data, 35),
last_lap_ms: read_i32(data, 39),
best_lap_ms: read_i32(data, 43),
lap_count: read_i32(data, 47),
// Driver inputs
throttle: read_f32(data, 51),
brake: read_f32(data, 55),
clutch: read_f32(data, 59),
engine_rpm: read_f32(data, 63),
steer: read_f32(data, 67),
// Gear & chassis
gear: read_i32(data, 71),
cg_height: read_f32(data, 75),
// Per-wheel arrays
wheel_angular_speed: read_f32_4(data, 79),
slip_angle: read_f32_4(data, 95),
slip_angle_contact_patch: read_f32_4(data, 111),
slip_ratio: read_f32_4(data, 127),
tyre_slip: read_f32_4(data, 143),
nd_slip: read_f32_4(data, 159),
wheel_load: read_f32_4(data, 175),
wheel_dy: read_f32_4(data, 191),
wheel_mz: read_f32_4(data, 207),
tyre_dirty_level: read_f32_4(data, 223),
camber_rad: read_f32_4(data, 239),
tyre_radius: read_f32_4(data, 255),
tyre_loaded_radius: read_f32_4(data, 271),
suspension_height: read_f32_4(data, 287),
// Position
// NOTE: carPosition (offset 303) is the normalized spline position (0.0–1.0).
// Both car_position_normalized and spline_position map to the same AC field.
car_position_normalized: read_f32(data, 303),
car_slope: read_f32(data, 307),
world_x: read_f32(data, 311),
world_y: read_f32(data, 315),
world_z: read_f32(data, 319),
spline_position: read_f32(data, 303), // same as car_position_normalized (AC SDK: carPosition)
..Default::default()
};
// KSUDP-specific: mark Telemetry Tool fields as None (already default)
// Keep KSUDP-specific fields populated above.
Ok(frame)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_valid_packet() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a'; // identifier
// size = MIN_PACKET_SIZE
packet[1..5].copy_from_slice(&(MIN_PACKET_SIZE as i32).to_le_bytes());
// speed_Kmh = 123.4
packet[5..9].copy_from_slice(&123.4f32.to_le_bytes());
// gear = 3 (meaning gear 2)
packet[71..75].copy_from_slice(&3i32.to_le_bytes());
// engineRPM = 7500.0
packet[63..67].copy_from_slice(&7500.0f32.to_le_bytes());
// lapTime = 78123 ms (1:18.123)
packet[35..39].copy_from_slice(&78123i32.to_le_bytes());
let frame = parse_rt_car_info(&packet).unwrap();
assert_eq!(frame.source, FeedSource::Ksudp);
assert!((frame.speed_kmh - 123.4).abs() < 0.01);
assert_eq!(frame.gear, 3);
assert!((frame.engine_rpm - 7500.0).abs() < 0.01);
assert_eq!(frame.lap_time_ms, 78123);
}
#[test]
fn test_too_short() {
let packet = vec![b'a', 0, 0, 0, 0];
let result = parse_rt_car_info(&packet);
assert!(result.is_err());
if let Err(KsudpParseError::TooShort { actual, .. }) = result {
assert_eq!(actual, 5);
} else {
panic!("Expected TooShort error");
}
}
#[test]
fn test_invalid_identifier() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'x';
let result = parse_rt_car_info(&packet);
assert!(result.is_err());
if let Err(KsudpParseError::InvalidIdentifier { got }) = result {
assert_eq!(got, b'x');
} else {
panic!("Expected InvalidIdentifier error");
}
}
#[test]
fn test_boolean_flags() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[17] = 1; // isAbsEnabled = true
packet[18] = 0; // isAbsInAction = false
packet[19] = 1; // isTcInAction = true
packet[20] = 1; // isTcEnabled = true
packet[21] = 0; // isInPit = false
packet[22] = 1; // isEngineLimiterOn = true
let frame = parse_rt_car_info(&packet).unwrap();
assert!(frame.is_abs_enabled);
assert!(!frame.is_abs_in_action);
assert!(frame.is_tc_in_action);
assert!(frame.is_tc_enabled);
assert!(!frame.is_in_pit);
assert!(frame.is_engine_limiter_on);
}
#[test]
fn test_per_wheel_arrays() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
// slipRatio at offset 127: [0.1, 0.2, 0.3, 0.4]
for i in 0..4 {
let val = (i as f32 + 1.0) * 0.1;
packet[127 + i * 4..127 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
}
let frame = parse_rt_car_info(&packet).unwrap();
assert!((frame.slip_ratio[0] - 0.1).abs() < 0.001);
assert!((frame.slip_ratio[1] - 0.2).abs() < 0.001);
assert!((frame.slip_ratio[2] - 0.3).abs() < 0.001);
assert!((frame.slip_ratio[3] - 0.4).abs() < 0.001);
}
#[test]
fn test_world_coordinates() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[311..315].copy_from_slice(&100.5f32.to_le_bytes()); // x
packet[315..319].copy_from_slice(&2.3f32.to_le_bytes()); // y
packet[319..323].copy_from_slice(&(-50.7f32).to_le_bytes()); // z
let frame = parse_rt_car_info(&packet).unwrap();
assert!((frame.world_x - 100.5).abs() < 0.01);
assert!((frame.world_y - 2.3).abs() < 0.01);
assert!((frame.world_z - (-50.7)).abs() < 0.01);
}
}