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:
parent
1731495197
commit
7a1c37fc86
11 changed files with 2308 additions and 307 deletions
363
src/parser_telemetry_tool.rs
Normal file
363
src/parser_telemetry_tool.rs
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
//! Binary parser for Telemetry Tool plugin packets (port 10101).
|
||||
//!
|
||||
//! The Telemetry Tool is a third-party AC Python plugin by IkoRein v1.3.
|
||||
//! It sends binary telemetry for all cars in the session.
|
||||
//!
|
||||
//! Reference: `<AC>/apps/python/Telemetry_Tool_plugin/Telemetry_Tool_plugin.py`
|
||||
|
||||
use crate::binary_utils::{
|
||||
read_bool, read_f32, read_f32_2, read_f32_3, read_f32_4, read_f64, read_i16, read_i32_3,
|
||||
read_string, read_u32,
|
||||
};
|
||||
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
|
||||
|
||||
/// Packet type byte indicating telemetry data.
|
||||
const PACKET_TYPE_TELEMETRY: u8 = 22;
|
||||
/// Packet type byte indicating version info (sent every ~100 frames).
|
||||
const PACKET_TYPE_VERSION: u8 = 23;
|
||||
|
||||
/// Size of a single per-driver telemetry structure.
|
||||
const DRIVER_STRUCT_SIZE: usize = 357;
|
||||
|
||||
/// Validation constants embedded by the plugin.
|
||||
const TEST_FLOAT: f32 = 666.666;
|
||||
const TEST_INT: u32 = 666;
|
||||
|
||||
/// Parse error type for Telemetry Tool packets.
|
||||
#[derive(Debug)]
|
||||
pub enum TelemetryToolParseError {
|
||||
/// Packet too short for the declared content.
|
||||
TooShort { actual: usize, needed: usize },
|
||||
/// Unknown packet type.
|
||||
UnknownPacketType { got: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TelemetryToolParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TelemetryToolParseError::TooShort { actual, needed } => {
|
||||
write!(f, "packet too short: {} bytes (needed {})", actual, needed)
|
||||
}
|
||||
TelemetryToolParseError::UnknownPacketType { got } => {
|
||||
write!(f, "unknown packet type: {} (expected 22 or 23)", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TelemetryToolParseError {}
|
||||
|
||||
/// Parsed result of a Telemetry Tool packet.
|
||||
///
|
||||
/// Reserved for future multi-driver UI support.
|
||||
#[allow(dead_code)]
|
||||
pub enum TelemetryToolPacket {
|
||||
/// Telemetry data for one or more drivers.
|
||||
Telemetry(Vec<TelemetryFrame>),
|
||||
/// Version info packet (typically ignored).
|
||||
Version,
|
||||
}
|
||||
|
||||
/// Parse a complete Telemetry Tool UDP packet.
|
||||
///
|
||||
/// Returns either a list of parsed driver frames or a version marker.
|
||||
///
|
||||
/// Reserved for future multi-driver UI support.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_packet(data: &[u8]) -> Result<TelemetryToolPacket, TelemetryToolParseError> {
|
||||
if data.is_empty() {
|
||||
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
|
||||
}
|
||||
|
||||
let packet_type = data[0];
|
||||
|
||||
match packet_type {
|
||||
PACKET_TYPE_TELEMETRY => {
|
||||
let payload = &data[1..];
|
||||
if payload.len() < DRIVER_STRUCT_SIZE {
|
||||
return Err(TelemetryToolParseError::TooShort {
|
||||
actual: data.len(),
|
||||
needed: 1 + DRIVER_STRUCT_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let num_drivers = payload.len() / DRIVER_STRUCT_SIZE;
|
||||
let mut drivers = Vec::with_capacity(num_drivers);
|
||||
for i in 0..num_drivers {
|
||||
let start = i * DRIVER_STRUCT_SIZE;
|
||||
let end = start + DRIVER_STRUCT_SIZE;
|
||||
if end > payload.len() {
|
||||
break;
|
||||
}
|
||||
let driver_data = &payload[start..end];
|
||||
drivers.push(parse_driver(driver_data)?);
|
||||
}
|
||||
|
||||
Ok(TelemetryToolPacket::Telemetry(drivers))
|
||||
}
|
||||
PACKET_TYPE_VERSION => Ok(TelemetryToolPacket::Version),
|
||||
_ => Err(TelemetryToolParseError::UnknownPacketType { got: packet_type }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse only the first driver from a Telemetry Tool UDP packet.
|
||||
///
|
||||
/// Avoids allocating a `Vec` for all drivers when only the local player's
|
||||
/// telemetry is needed.
|
||||
pub fn parse_first_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
|
||||
if data.is_empty() {
|
||||
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
|
||||
}
|
||||
|
||||
let packet_type = data[0];
|
||||
if packet_type != PACKET_TYPE_TELEMETRY {
|
||||
return Err(TelemetryToolParseError::UnknownPacketType { got: packet_type });
|
||||
}
|
||||
|
||||
let payload = &data[1..];
|
||||
if payload.len() < DRIVER_STRUCT_SIZE {
|
||||
return Err(TelemetryToolParseError::TooShort {
|
||||
actual: data.len(),
|
||||
needed: 1 + DRIVER_STRUCT_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
parse_driver(&payload[..DRIVER_STRUCT_SIZE])
|
||||
}
|
||||
|
||||
/// Parse a single 357-byte driver structure.
|
||||
fn parse_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
|
||||
// Validation: check test_float at offset 93
|
||||
let test_float_val = read_f32(data, 93);
|
||||
if (test_float_val - TEST_FLOAT).abs() > 1.0 {
|
||||
eprintln!(
|
||||
"[WARN] Telemetry Tool: test_float validation: expected {}, got {}",
|
||||
TEST_FLOAT, test_float_val
|
||||
);
|
||||
}
|
||||
|
||||
// Validation: check test_int at offset 177
|
||||
let test_int_val = read_u32(data, 177);
|
||||
if test_int_val != TEST_INT {
|
||||
eprintln!(
|
||||
"[WARN] Telemetry Tool: test_int validation: expected {}, got {}",
|
||||
TEST_INT, test_int_val
|
||||
);
|
||||
}
|
||||
|
||||
let speed_kmh = read_f32(data, 63);
|
||||
|
||||
let frame = TelemetryFrame {
|
||||
source: FeedSource::TelemetryTool,
|
||||
raw_size: data.len(),
|
||||
|
||||
// Speed (convert km/h back)
|
||||
speed_kmh,
|
||||
speed_mph: speed_kmh * 0.621371,
|
||||
speed_ms: speed_kmh / 3.6,
|
||||
|
||||
// Engine & drivetrain
|
||||
engine_rpm: read_f32(data, 59),
|
||||
gear: read_i16(data, 57) as i32,
|
||||
|
||||
// Driver inputs
|
||||
steer: read_f32(data, 41),
|
||||
throttle: read_f32(data, 45),
|
||||
brake: read_f32(data, 49),
|
||||
clutch: read_f32(data, 53),
|
||||
|
||||
// Lap timing
|
||||
lap_time_ms: read_u32(data, 4) as i32,
|
||||
last_lap_ms: read_u32(data, 8) as i32,
|
||||
best_lap_ms: read_u32(data, 12) as i32,
|
||||
lap_count: read_i16(data, 71) as i32,
|
||||
|
||||
// World position
|
||||
world_x: read_f32(data, 73),
|
||||
world_y: read_f32(data, 77),
|
||||
world_z: read_f32(data, 81),
|
||||
spline_position: read_f64(data, 85) as f32,
|
||||
|
||||
// G-forces
|
||||
acc_g_vertical: read_f32(data, 185),
|
||||
acc_g_horizontal: read_f32(data, 189),
|
||||
acc_g_frontal: read_f32(data, 193),
|
||||
|
||||
// Boolean flags
|
||||
is_in_pit: read_bool(data, 182),
|
||||
is_engine_limiter_on: read_bool(data, 196),
|
||||
|
||||
// Per-wheel data
|
||||
tyre_dirty_level: read_f32_4(data, 213),
|
||||
slip_ratio: read_f32_4(data, 257),
|
||||
slip_angle: read_f32_4(data, 273),
|
||||
nd_slip: read_f32_4(data, 289),
|
||||
tyre_slip: read_f32_4(data, 305),
|
||||
|
||||
// Telemetry Tool-specific fields
|
||||
driver_id: Some(read_u32(data, 0)),
|
||||
driver_name: Some(read_string(data, 97, 33)),
|
||||
nationality: Some(read_string(data, 130, 4)),
|
||||
car_name: Some(read_string(data, 134, 33)),
|
||||
tyre_compound: Some(read_string(data, 167, 10)),
|
||||
track_position: Some(read_i16(data, 67)),
|
||||
leaderboard_position: Some(read_i16(data, 69)),
|
||||
is_connected: Some(read_bool(data, 181)),
|
||||
is_car_in_pitlane: Some(read_bool(data, 183)),
|
||||
lap_invalidated: Some(read_bool(data, 184)),
|
||||
is_drs_available: Some(read_bool(data, 197)),
|
||||
is_drs_enabled: Some(read_bool(data, 198)),
|
||||
is_race_finished: Some(read_bool(data, 199)),
|
||||
turbo_boost: Some(read_f32(data, 229)),
|
||||
ers_max_kj: Some(read_f32(data, 205)),
|
||||
ers_current_kj: Some(read_f32(data, 209)),
|
||||
ride_height_front_rear: Some(read_f32_2(data, 233)),
|
||||
tyre_core_temp: Some(read_f32_4(data, 241)),
|
||||
world_velocity: Some(read_f32_3(data, 321)),
|
||||
local_velocity: Some(read_f32_3(data, 333)),
|
||||
sector_times: Some(read_i32_3(data, 17)),
|
||||
last_sector_times: Some(read_i32_3(data, 29)),
|
||||
|
||||
// Remaining KSUDP-only fields stay at default (0.0 / [0.0; 4])
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_driver_struct() -> Vec<u8> {
|
||||
let mut data = vec![0u8; DRIVER_STRUCT_SIZE];
|
||||
|
||||
// driver_id = 42
|
||||
data[0..4].copy_from_slice(&42u32.to_le_bytes());
|
||||
// lap_time = 65000
|
||||
data[4..8].copy_from_slice(&65000u32.to_le_bytes());
|
||||
// test_float at offset 93
|
||||
data[93..97].copy_from_slice(&TEST_FLOAT.to_le_bytes());
|
||||
// driver_name at offset 97
|
||||
let name = b"TestDriver";
|
||||
data[97..97 + name.len()].copy_from_slice(name);
|
||||
// test_int at offset 177
|
||||
data[177..181].copy_from_slice(&TEST_INT.to_le_bytes());
|
||||
// speed at offset 63 = 180.5
|
||||
data[63..67].copy_from_slice(&180.5f32.to_le_bytes());
|
||||
// engineRPM at offset 59 = 7200.0
|
||||
data[59..63].copy_from_slice(&7200.0f32.to_le_bytes());
|
||||
// gear at offset 57 = 5
|
||||
data[57..59].copy_from_slice(&5i16.to_le_bytes());
|
||||
// throttle at offset 45 = 0.85
|
||||
data[45..49].copy_from_slice(&0.85f32.to_le_bytes());
|
||||
// spline_position at offset 85 = 0.5
|
||||
data[85..93].copy_from_slice(&0.5f64.to_le_bytes());
|
||||
// x at offset 73
|
||||
data[73..77].copy_from_slice(&100.0f32.to_le_bytes());
|
||||
// is_in_pit at offset 182 = false
|
||||
data[182] = 0;
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telemetry_packet_single_driver() {
|
||||
let driver = make_driver_struct();
|
||||
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
||||
packet.extend_from_slice(&driver);
|
||||
|
||||
let result = parse_packet(&packet).unwrap();
|
||||
match result {
|
||||
TelemetryToolPacket::Telemetry(drivers) => {
|
||||
assert_eq!(drivers.len(), 1);
|
||||
let f = &drivers[0];
|
||||
assert_eq!(f.source, FeedSource::TelemetryTool);
|
||||
assert_eq!(f.driver_id, Some(42));
|
||||
assert_eq!(f.lap_time_ms, 65000);
|
||||
assert!((f.speed_kmh - 180.5).abs() < 0.01);
|
||||
assert!((f.engine_rpm - 7200.0).abs() < 0.01);
|
||||
assert_eq!(f.gear, 5);
|
||||
assert!((f.throttle - 0.85).abs() < 0.01);
|
||||
assert!((f.world_x - 100.0).abs() < 0.01);
|
||||
assert_eq!(f.driver_name.as_deref(), Some("TestDriver"));
|
||||
}
|
||||
TelemetryToolPacket::Version => panic!("Expected Telemetry, got Version"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_packet() {
|
||||
let packet = vec![PACKET_TYPE_VERSION, 1, 2, 3];
|
||||
let result = parse_packet(&packet).unwrap();
|
||||
match result {
|
||||
TelemetryToolPacket::Version => {}
|
||||
_ => panic!("Expected Version"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_packet_type() {
|
||||
let packet = vec![99u8];
|
||||
let result = parse_packet(&packet);
|
||||
assert!(result.is_err());
|
||||
if let Err(TelemetryToolParseError::UnknownPacketType { got }) = result {
|
||||
assert_eq!(got, 99);
|
||||
} else {
|
||||
panic!("Expected UnknownPacketType");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_packet() {
|
||||
let result = parse_packet(&[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_drivers() {
|
||||
let driver1 = make_driver_struct();
|
||||
let mut driver2 = make_driver_struct();
|
||||
// Change driver_id for second driver
|
||||
driver2[0..4].copy_from_slice(&99u32.to_le_bytes());
|
||||
|
||||
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
||||
packet.extend_from_slice(&driver1);
|
||||
packet.extend_from_slice(&driver2);
|
||||
|
||||
let result = parse_packet(&packet).unwrap();
|
||||
match result {
|
||||
TelemetryToolPacket::Telemetry(drivers) => {
|
||||
assert_eq!(drivers.len(), 2);
|
||||
assert_eq!(drivers[0].driver_id, Some(42));
|
||||
assert_eq!(drivers[1].driver_id, Some(99));
|
||||
}
|
||||
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_wheel_slip_ratio() {
|
||||
let mut driver = make_driver_struct();
|
||||
// slip_ratio at offset 257: [0.1, 0.2, 0.3, 0.4]
|
||||
for i in 0..4 {
|
||||
let val = (i as f32 + 1.0) * 0.1;
|
||||
driver[257 + i * 4..257 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut packet = vec![PACKET_TYPE_TELEMETRY];
|
||||
packet.extend_from_slice(&driver);
|
||||
|
||||
let result = parse_packet(&packet).unwrap();
|
||||
match result {
|
||||
TelemetryToolPacket::Telemetry(drivers) => {
|
||||
let f = &drivers[0];
|
||||
assert!((f.slip_ratio[0] - 0.1).abs() < 0.001);
|
||||
assert!((f.slip_ratio[1] - 0.2).abs() < 0.001);
|
||||
assert!((f.slip_ratio[2] - 0.3).abs() < 0.001);
|
||||
assert!((f.slip_ratio[3] - 0.4).abs() < 0.001);
|
||||
}
|
||||
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue