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

97
src/binary_utils.rs Normal file
View file

@ -0,0 +1,97 @@
//! Shared binary reading helpers for UDP packet parsers.
//!
//! All helpers use `get()` for safe bounds checking — they return a zero/empty
//! fallback instead of panicking when the offset is out of bounds.
use crate::telemetry_frame::NUM_WHEELS;
/// Read a `bool` (1 byte) from the data at the given offset.
/// Returns `false` if the offset is out of bounds.
pub fn read_bool(data: &[u8], offset: usize) -> bool {
data.get(offset).map(|&b| b != 0).unwrap_or(false)
}
/// Read a little-endian `u32` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_u32(data: &[u8], offset: usize) -> u32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
u32::from_le_bytes(buf)
}
/// Read a little-endian `i32` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_i32(data: &[u8], offset: usize) -> i32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
i32::from_le_bytes(buf)
}
/// Read a little-endian `i16` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_i16(data: &[u8], offset: usize) -> i16 {
let buf: [u8; 2] = data
.get(offset..offset + 2)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 2]);
i16::from_le_bytes(buf)
}
/// 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).
pub fn read_f32(data: &[u8], offset: usize) -> f32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
f32::from_le_bytes(buf)
}
/// Read a little-endian `f64` from the data at the given offset.
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_f64(data: &[u8], offset: usize) -> f64 {
let buf: [u8; 8] = data
.get(offset..offset + 8)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 8]);
f64::from_le_bytes(buf)
}
/// Read an array of 4 little-endian `f32` from the data at the given offset.
pub fn read_f32_4(data: &[u8], offset: usize) -> [f32; NUM_WHEELS] {
let mut arr = [0.0f32; NUM_WHEELS];
for i in 0..NUM_WHEELS {
arr[i] = read_f32(data, offset + i * 4);
}
arr
}
/// Read an array of 2 little-endian `f32` from the data at the given offset.
pub fn read_f32_2(data: &[u8], offset: usize) -> [f32; 2] {
[read_f32(data, offset), read_f32(data, offset + 4)]
}
/// Read an array of 3 little-endian `f32` from the data at the given offset.
pub fn read_f32_3(data: &[u8], offset: usize) -> [f32; 3] {
[read_f32(data, offset), read_f32(data, offset + 4), read_f32(data, offset + 8)]
}
/// Read an array of 3 little-endian `i32` from the data at the given offset.
pub fn read_i32_3(data: &[u8], offset: usize) -> [i32; 3] {
[read_i32(data, offset), read_i32(data, offset + 4), read_i32(data, offset + 8)]
}
/// Read a null-terminated/padded ASCII string.
/// Returns an empty string if the offset is out of bounds.
pub fn read_string(data: &[u8], offset: usize, len: usize) -> String {
let slice = match data.get(offset..offset + len) {
Some(s) => s,
None => return String::new(),
};
let end = slice.iter().position(|&b| b == 0).unwrap_or(len);
String::from_utf8_lossy(&slice[..end]).to_string()
}

389
src/feed_state.rs Normal file
View file

@ -0,0 +1,389 @@
use crate::telemetry_frame::TelemetryFrame;
use std::time::Instant;
const RING_CAPACITY: usize = 50;
const STALE_THRESHOLD_SECS: f64 = 5.0;
const MIN_TIME_SPAN_SECS: f64 = 0.001;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FeedHealth {
Waiting,
Alive,
Stale,
Error,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum HandshakePhase {
NotStarted,
WaitingForResponse,
Subscribing,
Subscribed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FeedMode {
BindListen,
Handshake {
target_port: u16,
recv_port: u16,
},
Disabled,
}
#[derive(Clone)]
pub struct FeedState {
pub health: FeedHealth,
pub handshake_phase: HandshakePhase,
pub status_text: String,
pub total_packets: u64,
pub total_bytes: u64,
pub frequency_hz: f64,
pub data_rate_kb_s: f64,
pub last_packet_ago_secs: Option<f64>,
pub start_time: Option<Instant>,
pub last_packet_time: Option<Instant>,
pub packet_timestamps: Vec<Instant>,
pub ring_index: usize,
pub ring_count: usize,
pub last_frame: Option<TelemetryFrame>,
}
impl FeedState {
pub fn new() -> Self {
Self {
health: FeedHealth::Waiting,
handshake_phase: HandshakePhase::NotStarted,
status_text: String::from("Bound, waiting for data"),
total_packets: 0,
total_bytes: 0,
frequency_hz: 0.0,
data_rate_kb_s: 0.0,
last_packet_ago_secs: None,
start_time: None,
last_packet_time: None,
packet_timestamps: vec![Instant::now(); RING_CAPACITY],
ring_index: 0,
ring_count: 0,
last_frame: None,
}
}
pub fn update_frame(&mut self, frame: TelemetryFrame) {
self.last_frame = Some(frame);
}
pub fn record_packet(&mut self, len: usize) {
let now = Instant::now();
if self.start_time.is_none() {
self.start_time = Some(now);
}
self.total_packets += 1;
self.total_bytes += len as u64;
self.last_packet_time = Some(now);
self.last_packet_ago_secs = Some(0.0);
if self.ring_count < RING_CAPACITY {
if self.ring_count < self.packet_timestamps.len() {
self.packet_timestamps[self.ring_count] = now;
}
self.ring_count += 1;
self.ring_index = self.ring_count % RING_CAPACITY;
} else {
self.packet_timestamps[self.ring_index] = now;
self.ring_index = (self.ring_index + 1) % RING_CAPACITY;
}
self.frequency_hz = self.calc_frequency();
self.data_rate_kb_s = self.calc_data_rate();
self.health = FeedHealth::Alive;
if self.status_text != "Receiving" {
self.status_text = String::from("Receiving");
}
}
fn calc_frequency(&self) -> f64 {
if self.ring_count < 2 {
return 0.0;
}
let count = self.ring_count.min(RING_CAPACITY);
let oldest_idx = if self.ring_count >= RING_CAPACITY {
self.ring_index
} else {
0
};
let newest_idx = if self.ring_count >= RING_CAPACITY {
if self.ring_index == 0 {
RING_CAPACITY - 1
} else {
self.ring_index - 1
}
} else {
self.ring_count - 1
};
let oldest = self.packet_timestamps[oldest_idx];
let newest = self.packet_timestamps[newest_idx];
let span = newest.duration_since(oldest).as_secs_f64();
if span < MIN_TIME_SPAN_SECS {
return 0.0;
}
count as f64 / span
}
fn calc_data_rate(&self) -> f64 {
match self.start_time {
Some(start) => {
let elapsed = start.elapsed().as_secs_f64();
if elapsed > 0.0 {
(self.total_bytes as f64 / 1024.0) / elapsed
} else {
0.0
}
}
None => 0.0,
}
}
pub fn update_health(&mut self) {
match self.last_packet_ago_secs {
None => {
if self.health != FeedHealth::Error {
self.health = FeedHealth::Waiting;
self.status_text = String::from("Waiting for data");
}
}
Some(ago) => {
if ago < STALE_THRESHOLD_SECS {
self.health = FeedHealth::Alive;
self.status_text = String::from("Receiving");
} else {
self.health = FeedHealth::Stale;
self.status_text = format!("Stale ({:.1}s since last packet)", ago);
}
}
}
}
pub fn tick(&mut self) {
if let Some(last) = self.last_packet_time {
self.last_packet_ago_secs = Some(last.elapsed().as_secs_f64());
}
self.frequency_hz = self.calc_frequency();
self.data_rate_kb_s = self.calc_data_rate();
self.update_health();
}
pub fn set_handshake_phase(&mut self, phase: HandshakePhase) {
self.handshake_phase = phase;
}
pub fn set_error(&mut self, msg: &str) {
self.health = FeedHealth::Error;
self.status_text = format!("Error: {}", msg);
}
}
impl Default for FeedState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_new_state_is_waiting() {
let state = FeedState::new();
assert_eq!(state.health, FeedHealth::Waiting);
assert_eq!(state.handshake_phase, HandshakePhase::NotStarted);
assert_eq!(state.total_packets, 0);
assert_eq!(state.total_bytes, 0);
assert_eq!(state.frequency_hz, 0.0);
assert!(state.last_packet_ago_secs.is_none());
assert!(state.last_frame.is_none());
}
#[test]
fn test_record_packet_increments_counters() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.total_packets, 1);
assert_eq!(state.total_bytes, 100);
assert_eq!(state.health, FeedHealth::Alive);
assert_eq!(state.status_text, "Receiving");
state.record_packet(200);
assert_eq!(state.total_packets, 2);
assert_eq!(state.total_bytes, 300);
}
#[test]
fn test_frequency_zero_with_fewer_than_two_packets() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.frequency_hz, 0.0);
// With 2 packets at the same instant, span is < MIN_TIME_SPAN_SECS, so still 0
state.record_packet(100);
// frequency could be 0 if the two packets are within MIN_TIME_SPAN_SECS
// This is expected behavior
}
#[test]
fn test_frequency_nonzero_after_spaced_packets() {
let mut state = FeedState::new();
for _ in 0..10 {
state.record_packet(100);
thread::sleep(std::time::Duration::from_millis(10));
}
// 10 packets over ~90ms → frequency should be > 0
assert!(state.frequency_hz > 0.0);
// Rough sanity: 10 packets / 0.09s ≈ 111 Hz, but with overhead, just check > 0
assert!(state.frequency_hz < 10_000.0); // sanity upper bound
}
#[test]
fn test_ring_buffer_fills_without_wraparound() {
let mut state = FeedState::new();
for _ in 0..RING_CAPACITY {
state.record_packet(50);
}
assert_eq!(state.ring_count, RING_CAPACITY);
assert_eq!(state.ring_index, 0); // wraps to 0 after filling
}
#[test]
fn test_ring_buffer_wraps_around() {
let mut state = FeedState::new();
for _ in 0..RING_CAPACITY {
state.record_packet(50);
}
assert_eq!(state.ring_index, 0);
// Add more packets to trigger wraparound
for _ in 0..10 {
state.record_packet(50);
}
assert_eq!(state.ring_count, RING_CAPACITY);
assert_eq!(state.ring_index, 10);
assert_eq!(state.total_packets, (RING_CAPACITY + 10) as u64);
}
#[test]
fn test_health_waiting_to_alive() {
let mut state = FeedState::new();
assert_eq!(state.health, FeedHealth::Waiting);
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
}
#[test]
fn test_health_alive_to_stale() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
// Simulate time passing by manually setting last_packet_ago_secs
state.last_packet_ago_secs = Some(6.0);
state.update_health();
assert_eq!(state.health, FeedHealth::Stale);
assert!(state.status_text.starts_with("Stale"));
}
#[test]
fn test_health_stale_to_alive() {
let mut state = FeedState::new();
state.record_packet(100);
state.last_packet_ago_secs = Some(6.0);
state.update_health();
assert_eq!(state.health, FeedHealth::Stale);
// New packet brings it back to alive
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
}
#[test]
fn test_health_error_is_sticky_via_update() {
let mut state = FeedState::new();
state.set_error("test error");
assert_eq!(state.health, FeedHealth::Error);
// update_health should not override Error when ago is None
state.last_packet_ago_secs = None;
state.update_health();
assert_eq!(state.health, FeedHealth::Error);
}
#[test]
fn test_tick_updates_ago_secs() {
let mut state = FeedState::new();
// Before any packet, tick should not set ago
state.tick();
assert!(state.last_packet_ago_secs.is_none());
state.record_packet(100);
// Immediately after, ago should be ~0
state.tick();
assert!(state.last_packet_ago_secs.is_some());
assert!(state.last_packet_ago_secs.unwrap() < 1.0);
// After sleeping, ago should be > 0
thread::sleep(std::time::Duration::from_millis(100));
state.tick();
let ago = state.last_packet_ago_secs.unwrap();
assert!(ago >= 0.09, "expected ago >= 0.09, got {}", ago);
}
#[test]
fn test_data_rate_calculation() {
let mut state = FeedState::new();
state.record_packet(1024);
thread::sleep(std::time::Duration::from_millis(50));
state.record_packet(1024);
// 2048 bytes over ~50ms → roughly 40 KB/s
state.data_rate_kb_s = state.calc_data_rate();
assert!(state.data_rate_kb_s > 0.0);
}
#[test]
fn test_update_frame() {
let mut state = FeedState::new();
assert!(state.last_frame.is_none());
let frame = TelemetryFrame::default();
state.update_frame(frame);
assert!(state.last_frame.is_some());
}
#[test]
fn test_set_handshake_phase() {
let mut state = FeedState::new();
assert_eq!(state.handshake_phase, HandshakePhase::NotStarted);
state.set_handshake_phase(HandshakePhase::WaitingForResponse);
assert_eq!(state.handshake_phase, HandshakePhase::WaitingForResponse);
state.set_handshake_phase(HandshakePhase::Subscribed);
assert_eq!(state.handshake_phase, HandshakePhase::Subscribed);
}
#[test]
fn test_set_error() {
let mut state = FeedState::new();
state.set_error("bind failed");
assert_eq!(state.health, FeedHealth::Error);
assert_eq!(state.status_text, "Error: bind failed");
}
}

View file

@ -1,32 +1,59 @@
mod stats;
mod telemetry;
mod binary_utils;
mod feed_state;
mod ui;
mod telemetry_frame;
mod parser_ksudp;
mod parser_telemetry_tool;
use stats::ReceiveStats;
use telemetry::TelemetryFrame;
use feed_state::*;
use ui::*;
use std::io::Write;
use std::io::{self, Stdout};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncWriteExt, BufWriter};
use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::io::BufWriter;
use tokio::io::AsyncWriteExt;
use crate::feed_state::HandshakePhase;
use crate::telemetry_frame::TelemetryFrame;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUFFER_SIZE: usize = 65535;
const RENDER_INTERVAL_MS: u64 = 250;
const HANDSHAKE_INTERVAL_SECS: u64 = 10;
const HANDSHAKE_RESPONSE_SIZE: usize = 408;
const HANDSHAKE_REQUEST: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0];
const SUBSCRIBE_UPDATE: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
fn decode_utf16le(bytes: &[u8]) -> String {
let chars: Vec<u16> = bytes
.chunks(2)
.map(|c| u16::from_le_bytes([c[0], c.get(1).copied().unwrap_or(0)]))
.take_while(|&c| c != 0)
.collect();
String::from_utf16_lossy(&chars)
}
fn bind_addr() -> String {
std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| "127.0.0.1".to_string())
}
#[derive(Clone)]
enum FeedFormat {
Binary,
Json,
}
#[derive(Clone)]
struct FeedConfig {
port: u16,
name: &'static str,
label: &'static str,
format: FeedFormat,
needs_handshake: bool,
}
static FEEDS: &[FeedConfig] = &[
@ -34,53 +61,170 @@ static FEEDS: &[FeedConfig] = &[
port: 9996,
name: "ksudp",
label: "KSUDP",
format: FeedFormat::Binary,
needs_handshake: true,
},
FeedConfig {
port: 10101,
name: "telemetry_tool",
label: "Telemetry Tool",
format: FeedFormat::Binary,
},
FeedConfig {
port: 5005,
name: "ac_plugin",
label: "AC Lua Plugin",
format: FeedFormat::Json,
needs_handshake: false,
},
];
fn print_banner(results: &[(&FeedConfig, bool)], session_dir: &str, bind: &str) {
println!("========================================");
println!(" rusty-telemetry v{VERSION} — Multi-feed telemetry listener");
println!("========================================");
for (feed, ok) in results {
let status = if *ok { "✅ Listening" } else { "❌ Failed" };
println!(
" Feed :{} [{}] {}",
feed.port, feed.label, status
);
}
println!("========================================");
println!(" Bind: {bind}");
println!(" Data dir: {session_dir}/");
println!("========================================");
println!();
type Term = Terminal<CrosstermBackend<Stdout>>;
fn setup_terminal() -> io::Result<Term> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut Term) -> io::Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
}
async fn run_feed(
feed: FeedConfig,
socket: tokio::net::UdpSocket,
session_dir: String,
state: Arc<std::sync::Mutex<FeedState>>,
shutdown: Arc<tokio::sync::Notify>,
) {
let ext = match feed.format {
FeedFormat::Binary => "bin",
FeedFormat::Json => "json",
let handshake_target: Option<std::net::SocketAddr> = if feed.needs_handshake {
let target: std::net::SocketAddr = match format!("{}:{}", bind_addr(), feed.port).parse() {
Ok(addr) => addr,
Err(e) => {
eprintln!(
"[ERROR] {} :{} Invalid target address '{}': {}",
feed.label, feed.port, bind_addr(), e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Invalid target address: {}", e));
}
return;
}
};
// Phase 1: Send HANDSHAKE
eprintln!(
"[INFO] {} :{} Phase 1: Sending HANDSHAKE to {}",
feed.label, feed.port, target
);
if let Err(e) = socket.send_to(&HANDSHAKE_REQUEST, target).await {
eprintln!(
"[WARN] HANDSHAKE send failed for :{}: {}",
feed.port, e
);
}
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::WaitingForResponse);
}
// Phase 2: Wait for 408-byte HandshakerResponse with timeout
let mut hs_buf = [0u8; HANDSHAKE_RESPONSE_SIZE];
match tokio::time::timeout(
Duration::from_secs(5),
socket.recv_from(&mut hs_buf),
)
.await
{
Ok(Ok((len, _src))) if len == HANDSHAKE_RESPONSE_SIZE => {
let car_name = decode_utf16le(&hs_buf[0..100]);
let driver_name = decode_utf16le(&hs_buf[100..200]);
let identifier = i32::from_le_bytes([
hs_buf[200], hs_buf[201], hs_buf[202], hs_buf[203],
]);
let version = i32::from_le_bytes([
hs_buf[204], hs_buf[205], hs_buf[206], hs_buf[207],
]);
let track_name = decode_utf16le(&hs_buf[208..308]);
let track_config = decode_utf16le(&hs_buf[308..408]);
eprintln!(
"[INFO] {} :{} Phase 2: HandshakerResponse received",
feed.label, feed.port
);
eprintln!(
"[INFO] car_name=\"{}\" driver_name=\"{}\" id={} ver={}",
car_name, driver_name, identifier, version
);
eprintln!(
"[INFO] track_name=\"{}\" track_config=\"{}\"",
track_name, track_config
);
if identifier != 4242 {
eprintln!(
"[WARN] {} :{} Unexpected handshake identifier: {} (expected 4242)",
feed.label, feed.port, identifier
);
}
}
Ok(Ok((len, _))) => {
eprintln!(
"[WARN] {} :{} Phase 2: Unexpected response size {} (expected {})",
feed.label, feed.port, len, HANDSHAKE_RESPONSE_SIZE
);
}
Ok(Err(e)) => {
eprintln!(
"[WARN] {} :{} Phase 2: Recv error: {}",
feed.label, feed.port, e
);
}
Err(_) => {
eprintln!(
"[WARN] {} :{} Phase 2: Handshake response timed out (5s). AC may not be running.",
feed.label, feed.port
);
}
}
// Phase 3: Send SUBSCRIBE_UPDATE
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::Subscribing);
}
eprintln!(
"[INFO] {} :{} Phase 3: Sending SUBSCRIBE_UPDATE",
feed.label, feed.port
);
if let Err(e) = socket.send_to(&SUBSCRIBE_UPDATE, target).await {
eprintln!(
"[WARN] SUBSCRIBE_UPDATE send failed for :{}: {}",
feed.port, e
);
}
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::Subscribed);
}
eprintln!(
"[INFO] {} :{} Subscribed, entering streaming loop",
feed.label, feed.port
);
Some(target)
} else {
None
};
let file_path = format!(
"{session_dir}/feed_{}_{}.{ext}",
feed.port, feed.name
"{}/feed_{}_{}.bin",
session_dir, feed.port, feed.name
);
let file = match tokio::fs::OpenOptions::new()
.create(true)
@ -94,70 +238,59 @@ async fn run_feed(
"[ERROR] Cannot open dump file for :{} ({}): {}",
feed.port, feed.name, e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Cannot open dump file: {}", e));
}
return;
}
};
let mut file = BufWriter::new(file);
let mut buf = vec![0u8; BUFFER_SIZE];
let mut stats = ReceiveStats::new();
let stdout = std::io::stdout();
let mut hs_interval: Option<tokio::time::Interval> = handshake_target.map(|_| {
tokio::time::interval(Duration::from_secs(HANDSHAKE_INTERVAL_SECS))
});
loop {
tokio::select! {
result = socket.recv_from(&mut buf) => {
match result {
Ok((len, _src)) => {
stats.record(len);
// Parse telemetry data outside the lock to minimize
// contention with the TUI render thread.
let parsed: Option<TelemetryFrame> = if feed.needs_handshake {
// KSUDP: parse RTCarInfo (skip 408-byte handshake responses)
if len != HANDSHAKE_RESPONSE_SIZE {
parser_ksudp::parse_rt_car_info(&buf[..len]).ok()
} else {
None
}
} else {
// Telemetry Tool: parse only the first driver
parser_telemetry_tool::parse_first_driver(&buf[..len]).ok()
};
if let FeedFormat::Binary = feed.format {
let header = (len as u32).to_be_bytes();
if let Err(e) = file.write_all(&header).await {
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
// Update state in a single brief lock acquisition.
if let Ok(mut s) = state.lock() {
s.record_packet(len);
if let Some(frame) = parsed {
s.update_frame(frame);
}
}
let header = (len as u32).to_be_bytes();
if let Err(e) = file.write_all(&header).await {
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
}
if let Err(e) = file.write_all(&buf[..len]).await {
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
}
if let FeedFormat::Json = feed.format {
let _ = file.write_all(b"\n").await;
}
{
let mut handle = stdout.lock();
let fmt_tag = match feed.format {
FeedFormat::Binary => "Binary",
FeedFormat::Json => "JSON",
};
let _ = writeln!(
handle,
"[ :{} | {} ] {fmt_tag} packet: {} bytes (#{})",
feed.port,
feed.label,
len,
stats.total_packets,
);
if let FeedFormat::Json = feed.format {
if let Ok(text) = std::str::from_utf8(&buf[..len]) {
if let Ok(frame) =
serde_json::from_str::<TelemetryFrame>(text)
{
let _ = writeln!(handle, "{frame}");
} else {
let _ = writeln!(handle, " [WARN] JSON parse failed");
}
}
}
let _ = writeln!(handle, " {}", stats);
let _ = writeln!(handle);
}
}
Err(e) => {
eprintln!(
@ -167,6 +300,21 @@ async fn run_feed(
}
}
}
_ = async {
match hs_interval.as_mut() {
Some(interval) => interval.tick().await,
None => std::future::pending().await,
}
} => {
if let Some(target) = handshake_target {
if let Err(e) = socket.send_to(&SUBSCRIBE_UPDATE, target).await {
eprintln!(
"[WARN] Periodic SUBSCRIBE_UPDATE send failed for :{}: {}",
feed.port, e
);
}
}
}
_ = shutdown.notified() => {
break;
}
@ -193,70 +341,155 @@ async fn run_feed(
#[tokio::main]
async fn main() {
let bind = bind_addr();
install_panic_hook();
let bind = bind_addr();
let session_id = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let session_dir = format!("./data/{session_id}");
let session_dir = format!("./data/{}", session_id);
if let Err(e) = tokio::fs::create_dir_all(&session_dir).await {
eprintln!("[ERROR] Cannot create data dir {}: {}", session_dir, e);
std::process::exit(1);
}
let mut banner_data: Vec<(&FeedConfig, bool)> = Vec::new();
let mut bound_feeds: Vec<(FeedConfig, tokio::net::UdpSocket)> = Vec::new();
let mut terminal = match setup_terminal() {
Ok(t) => t,
Err(e) => {
eprintln!("[ERROR] Failed to setup terminal: {}", e);
std::process::exit(1);
}
};
let mut feed_displays: Vec<(FeedDisplayInfo, Arc<std::sync::Mutex<FeedState>>)> = Vec::new();
let mut feed_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
let shutdown = Arc::new(tokio::sync::Notify::new());
for feed in FEEDS {
let addr = format!("{bind}:{}", feed.port);
match tokio::net::UdpSocket::bind(&addr).await {
Ok(socket) => {
banner_data.push((feed, true));
bound_feeds.push((feed.clone(), socket));
let state = Arc::new(std::sync::Mutex::new(FeedState::new()));
let (socket_opt, mode) = if feed.needs_handshake {
let recv_addr = format!("{}:0", bind);
match tokio::net::UdpSocket::bind(&recv_addr).await {
Ok(s) => {
let local_port = s.local_addr().map(|a| a.port()).unwrap_or(0);
(Some(s), FeedMode::Handshake {
target_port: feed.port,
recv_port: local_port,
})
}
Err(e) => {
eprintln!(
"[WARN] Failed to bind handshake recv socket for :{} ({}): {}",
feed.port, feed.label, e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Bind failed: {}", e));
}
(None, FeedMode::Handshake {
target_port: feed.port,
recv_port: 0,
})
}
}
Err(e) => {
eprintln!(
"[WARN] Failed to bind :{} ({}): {} — skipping",
feed.port, feed.label, e
);
banner_data.push((feed, false));
} else {
let addr = format!("{}:{}", bind, feed.port);
match tokio::net::UdpSocket::bind(&addr).await {
Ok(s) => (Some(s), FeedMode::BindListen),
Err(e) => {
eprintln!(
"[WARN] Failed to bind :{} ({}): {} -- feed is optional, continuing without it",
feed.port, feed.label, e
);
(None, FeedMode::Disabled)
}
}
};
feed_displays.push((
FeedDisplayInfo {
label: feed.label,
port: feed.port,
mode,
},
state.clone(),
));
if let Some(socket) = socket_opt {
let feed_clone = feed.clone();
let dir = session_dir.clone();
let sh = shutdown.clone();
feed_tasks.push(tokio::spawn(async move {
run_feed(feed_clone, socket, dir, state, sh).await;
}));
}
}
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<Event>(100);
let event_running = Arc::new(AtomicBool::new(true));
let event_running_clone = event_running.clone();
tokio::spawn(async move {
while event_running_clone.load(Ordering::Relaxed) {
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(ev) = event::read() {
if event_tx.send(ev).await.is_err() {
break;
}
}
}
}
});
let mut render_interval = tokio::time::interval(Duration::from_millis(RENDER_INTERVAL_MS));
let running = Arc::new(AtomicBool::new(true));
while running.load(Ordering::Relaxed) {
tokio::select! {
maybe_event = event_rx.recv() => {
if let Some(ev) = maybe_event {
match ev {
Event::Key(key) => {
match key.code {
KeyCode::Char('q') => {
running.store(false, Ordering::Relaxed);
}
KeyCode::Char('c')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
running.store(false, Ordering::Relaxed);
}
_ => {}
}
}
Event::Resize(_, _) => {
let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION);
});
}
_ => {}
}
}
}
_ = render_interval.tick() => {
let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION);
});
}
}
}
print_banner(&banner_data, &session_dir, &bind);
if bound_feeds.is_empty() {
eprintln!("[ERROR] No feeds could bind. Exiting.");
std::process::exit(1);
}
let shutdown = Arc::new(tokio::sync::Notify::new());
let mut tasks = Vec::new();
for (feed, socket) in bound_feeds {
let dir = session_dir.clone();
let shutdown = shutdown.clone();
tasks.push(tokio::spawn(async move {
run_feed(feed, socket, dir, shutdown).await;
}));
}
tokio::signal::ctrl_c().await.ok();
println!();
println!("[INFO] Ctrl+C received, shutting down...");
event_running.store(false, Ordering::Relaxed);
shutdown.notify_waiters();
match tokio::time::timeout(
std::time::Duration::from_secs(3),
futures::future::join_all(tasks),
Duration::from_secs(3),
futures::future::join_all(feed_tasks),
)
.await
{
Ok(results) => {
for r in results {
if let Err(e) = r {
eprintln!("[WARN] Task join error: {e}");
eprintln!("[WARN] Task join error: {}", e);
}
}
}
@ -264,4 +497,10 @@ async fn main() {
eprintln!("[WARN] Timeout waiting for feeds to flush; forcing exit");
}
}
if let Err(e) = restore_terminal(&mut terminal) {
eprintln!("[WARN] Failed to restore terminal: {}", e);
}
eprintln!("[INFO] rusty-telemetry shut down cleanly.");
}

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);
}
}

View 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"),
}
}
}

View file

@ -1,70 +0,0 @@
use std::fmt;
use std::time::Instant;
pub struct ReceiveStats {
pub total_packets: u64,
pub total_bytes: u64,
pub start_time: Option<Instant>,
pub last_packet_time: Option<Instant>,
}
impl ReceiveStats {
pub fn new() -> Self {
Self {
total_packets: 0,
total_bytes: 0,
start_time: None,
last_packet_time: None,
}
}
pub fn record(&mut self, bytes_received: usize) {
let now = Instant::now();
if self.start_time.is_none() {
self.start_time = Some(now);
}
self.last_packet_time = Some(now);
self.total_packets += 1;
self.total_bytes += bytes_received as u64;
}
pub fn packets_per_second(&self) -> f64 {
match (self.start_time, self.last_packet_time) {
(Some(start), Some(last)) => {
let elapsed = last.duration_since(start).as_secs_f64();
if elapsed > 0.0 {
self.total_packets as f64 / elapsed
} else {
0.0
}
}
_ => 0.0,
}
}
pub fn data_rate_kbps(&self) -> f64 {
match self.start_time {
Some(start) => {
let elapsed = start.elapsed().as_secs_f64();
if elapsed > 0.0 {
(self.total_bytes as f64 / 1024.0) / elapsed
} else {
0.0
}
}
_ => 0.0,
}
}
}
impl fmt::Display for ReceiveStats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Packets: {} | Rate: {:.1} pkt/s | Data: {:.1} KB/s",
self.total_packets,
self.packets_per_second(),
self.data_rate_kbps(),
)
}
}

View file

@ -1,56 +0,0 @@
use serde::Deserialize;
use std::fmt;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TelemetryFrame {
pub timestamp: f64,
pub speed_kmh: f64,
pub rpm: f64,
pub gear: i32,
pub throttle: f64,
pub brake: f64,
pub steering: f64,
pub g_forces: GForces,
pub wheel_slip: [f64; 4],
pub suspension_travel: [f64; 4],
pub lap_time: f64,
pub lap_count: i32,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GForces {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl fmt::Display for TelemetryFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[Frame @ {:.3}]\n Speed: {:.1} km/h | RPM: {:.0} | Gear: {}\n Throttle: {:.1}% | Brake: {:.1}% | Steering: {:.1}%\n G-Forces: X={:.2} Y={:.2} Z={:.2}\n Wheel Slip: [{:.2}, {:.2}, {:.2}, {:.2}]\n Suspension: [{:.2}, {:.2}, {:.2}, {:.2}]\n Lap: #{} | Lap Time: {:.3}s",
self.timestamp,
self.speed_kmh,
self.rpm,
self.gear,
self.throttle * 100.0,
self.brake * 100.0,
self.steering * 100.0,
self.g_forces.x,
self.g_forces.y,
self.g_forces.z,
self.wheel_slip[0],
self.wheel_slip[1],
self.wheel_slip[2],
self.wheel_slip[3],
self.suspension_travel[0],
self.suspension_travel[1],
self.suspension_travel[2],
self.suspension_travel[3],
self.lap_count,
self.lap_time,
)
}
}

218
src/telemetry_frame.rs Normal file
View file

@ -0,0 +1,218 @@
//! Generic telemetry frame model — unified representation of parsed sim racing data.
//!
//! Both the KSUDP (port 9996) and Telemetry Tool (port 10101) parsers produce
//! `TelemetryFrame` instances from their raw binary data.
use std::fmt;
/// Identifies which feed source produced this frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedSource {
Ksudp,
TelemetryTool,
}
/// Per-wheel index convention: FL=0, FR=1, RL=2, RR=3.
pub const NUM_WHEELS: usize = 4;
/// A single parsed telemetry snapshot from any supported feed.
///
/// Fields present in both feeds are stored directly; fields unique to one feed
/// are wrapped in `Option`.
#[derive(Debug, Clone)]
pub struct TelemetryFrame {
// ── Speed ──────────────────────────────────────────────────────
pub speed_kmh: f32,
pub speed_mph: f32,
pub speed_ms: f32,
// ── Engine & Drivetrain ────────────────────────────────────────
pub engine_rpm: f32,
pub gear: i32,
// ── Driver Inputs ──────────────────────────────────────────────
pub throttle: f32,
pub brake: f32,
pub clutch: f32,
pub steer: f32,
// ── Lap Timing ─────────────────────────────────────────────────
pub lap_time_ms: i32,
pub last_lap_ms: i32,
pub best_lap_ms: i32,
pub lap_count: i32,
// ── World Position ─────────────────────────────────────────────
pub world_x: f32,
pub world_y: f32,
pub world_z: f32,
pub spline_position: f32,
// ── Acceleration (G-forces) ────────────────────────────────────
pub acc_g_vertical: f32,
pub acc_g_horizontal: f32,
pub acc_g_frontal: f32,
// ── Boolean Flags ──────────────────────────────────────────────
pub is_abs_enabled: bool,
pub is_abs_in_action: bool,
pub is_tc_enabled: bool,
pub is_tc_in_action: bool,
pub is_in_pit: bool,
pub is_engine_limiter_on: bool,
// ── Per-Wheel Data (FL, FR, RL, RR) ────────────────────────────
pub wheel_angular_speed: [f32; NUM_WHEELS],
pub slip_angle: [f32; NUM_WHEELS],
pub slip_ratio: [f32; NUM_WHEELS],
pub tyre_slip: [f32; NUM_WHEELS],
pub nd_slip: [f32; NUM_WHEELS],
pub suspension_height: [f32; NUM_WHEELS],
pub tyre_dirty_level: [f32; NUM_WHEELS],
// ── KSUDP-specific fields ──────────────────────────────────────
pub slip_angle_contact_patch: [f32; NUM_WHEELS],
pub wheel_load: [f32; NUM_WHEELS],
pub wheel_dy: [f32; NUM_WHEELS],
pub wheel_mz: [f32; NUM_WHEELS],
pub camber_rad: [f32; NUM_WHEELS],
pub tyre_radius: [f32; NUM_WHEELS],
pub tyre_loaded_radius: [f32; NUM_WHEELS],
pub cg_height: f32,
pub car_slope: f32,
pub car_position_normalized: f32,
// ── Telemetry Tool-specific fields ─────────────────────────────
pub driver_name: Option<String>,
pub car_name: Option<String>,
pub nationality: Option<String>,
pub tyre_compound: Option<String>,
pub track_position: Option<i16>,
pub leaderboard_position: Option<i16>,
pub is_connected: Option<bool>,
pub is_car_in_pitlane: Option<bool>,
pub lap_invalidated: Option<bool>,
pub is_drs_available: Option<bool>,
pub is_drs_enabled: Option<bool>,
pub is_race_finished: Option<bool>,
pub turbo_boost: Option<f32>,
pub ers_max_kj: Option<f32>,
pub ers_current_kj: Option<f32>,
pub ride_height_front_rear: Option<[f32; 2]>,
pub tyre_core_temp: Option<[f32; NUM_WHEELS]>,
pub world_velocity: Option<[f32; 3]>,
pub local_velocity: Option<[f32; 3]>,
pub sector_times: Option<[i32; 3]>,
pub last_sector_times: Option<[i32; 3]>,
pub driver_id: Option<u32>,
// ── Meta ───────────────────────────────────────────────────────
pub source: FeedSource,
pub raw_size: usize,
}
impl Default for TelemetryFrame {
fn default() -> Self {
Self {
speed_kmh: 0.0,
speed_mph: 0.0,
speed_ms: 0.0,
engine_rpm: 0.0,
gear: 0,
throttle: 0.0,
brake: 0.0,
clutch: 0.0,
steer: 0.0,
lap_time_ms: 0,
last_lap_ms: 0,
best_lap_ms: 0,
lap_count: 0,
world_x: 0.0,
world_y: 0.0,
world_z: 0.0,
spline_position: 0.0,
acc_g_vertical: 0.0,
acc_g_horizontal: 0.0,
acc_g_frontal: 0.0,
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,
wheel_angular_speed: [0.0; NUM_WHEELS],
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: [0.0; NUM_WHEELS],
tyre_dirty_level: [0.0; NUM_WHEELS],
slip_angle_contact_patch: [0.0; NUM_WHEELS],
wheel_load: [0.0; NUM_WHEELS],
wheel_dy: [0.0; NUM_WHEELS],
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,
driver_name: None,
car_name: None,
nationality: None,
tyre_compound: None,
track_position: None,
leaderboard_position: None,
is_connected: None,
is_car_in_pitlane: None,
lap_invalidated: None,
is_drs_available: None,
is_drs_enabled: None,
is_race_finished: None,
turbo_boost: None,
ers_max_kj: None,
ers_current_kj: None,
ride_height_front_rear: None,
tyre_core_temp: None,
world_velocity: None,
local_velocity: None,
sector_times: None,
last_sector_times: None,
driver_id: None,
source: FeedSource::Ksudp,
raw_size: 0,
}
}
}
impl TelemetryFrame {
/// Format lap time from milliseconds as M:SS.mmm
pub fn format_lap_time(ms: i32) -> String {
if ms <= 0 {
return "-:--.---".to_string();
}
let total_ms = ms as u32;
let minutes = total_ms / 60000;
let seconds = (total_ms % 60000) / 1000;
let millis = total_ms % 1000;
format!("{}:{:02}.{:03}", minutes, seconds, millis)
}
/// Gear display: 0=Reverse, 1=Neutral, 2+=Gear N-1
pub fn gear_display(&self) -> String {
match self.gear {
0 => "R".to_string(),
1 => "N".to_string(),
n => format!("{}", n - 1),
}
}
}
impl fmt::Display for FeedSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FeedSource::Ksudp => write!(f, "KSUDP"),
FeedSource::TelemetryTool => write!(f, "Telemetry Tool"),
}
}
}

222
src/ui.rs Normal file
View file

@ -0,0 +1,222 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use crate::feed_state::{FeedHealth, FeedMode, FeedState, HandshakePhase};
use crate::telemetry_frame::TelemetryFrame;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct FeedDisplayInfo {
pub label: &'static str,
pub port: u16,
pub mode: FeedMode,
}
fn health_indicator(health: FeedHealth) -> Span<'static> {
match health {
FeedHealth::Alive => Span::styled(
"*".to_string(),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
FeedHealth::Stale => Span::styled(
"~".to_string(),
Style::default().fg(Color::Yellow),
),
FeedHealth::Waiting => Span::styled(
".".to_string(),
Style::default().fg(Color::DarkGray),
),
FeedHealth::Error => Span::styled(
"!".to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
}
}
fn freq_style(health: FeedHealth) -> Style {
match health {
FeedHealth::Alive => Style::default().fg(Color::Green),
FeedHealth::Stale => Style::default().fg(Color::Yellow),
_ => Style::default().fg(Color::DarkGray),
}
}
fn format_ago(ago: Option<f64>) -> String {
match ago {
None => "never".to_string(),
Some(t) => format!("{:.2}s", t),
}
}
fn feed_block_title(info: &FeedDisplayInfo, state: &FeedState) -> String {
match info.mode {
FeedMode::Handshake {
target_port,
recv_port,
} => {
let phase_str = match state.handshake_phase {
HandshakePhase::NotStarted => "not started",
HandshakePhase::WaitingForResponse => "waiting",
HandshakePhase::Subscribing => "subscribing",
HandshakePhase::Subscribed => "subscribed",
};
format!(
" {} -> :{} recv :{} [{}] ",
info.label, target_port, recv_port, phase_str
)
}
FeedMode::BindListen => {
format!(" {} Port :{} ", info.label, info.port)
}
FeedMode::Disabled => {
format!(" {} Port :{} [optional, not available] ", info.label, info.port)
}
}
}
fn render_feed_panel(frame: &mut Frame, area: Rect, info: &FeedDisplayInfo, state: &FeedState) {
let title = feed_block_title(info, state);
let border_style = match state.health {
FeedHealth::Alive => Style::default().fg(Color::Green),
FeedHealth::Stale => Style::default().fg(Color::Yellow),
FeedHealth::Error => Style::default().fg(Color::Red),
FeedHealth::Waiting => {
if info.mode == FeedMode::Disabled {
Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
} else {
Style::default().fg(Color::DarkGray)
}
}
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
let indicator = health_indicator(state.health);
let status_text = Span::raw(format!(" {:<12}", state.status_text));
let line1 = Line::from(vec![indicator, status_text]);
let freq_span = Span::styled(
format!("{:>7.1} Hz", state.frequency_hz),
freq_style(state.health),
);
let packets_span = Span::raw(format!(" {:>8} packets", state.total_packets));
let rate_span = Span::raw(format!(" {:>7.1} KB/s", state.data_rate_kb_s));
let ago_span = Span::raw(format!(" last: {:>8}", format_ago(state.last_packet_ago_secs)));
let line2 = Line::from(vec![freq_span, packets_span, rate_span, ago_span]);
let mut lines = vec![line1, line2];
if let Some(ref frame) = state.last_frame {
let speed_span = Span::styled(
format!(" {:>6.0} km/h", frame.speed_kmh),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
);
let gear_span = Span::styled(
format!(" Gear:{}", frame.gear_display()),
Style::default().fg(Color::Yellow),
);
let rpm_span = Span::raw(format!(" {:.0} RPM", frame.engine_rpm));
let lap_span = Span::raw(format!(" Lap:{}", TelemetryFrame::format_lap_time(frame.lap_time_ms)));
let line3 = Line::from(vec![speed_span, gear_span, rpm_span, lap_span]);
lines.push(line3);
let throttle_pct = (frame.throttle.clamp(0.0, 1.0) * 100.0) as u8;
let brake_pct = (frame.brake.clamp(0.0, 1.0) * 100.0) as u8;
let inputs_span = Span::raw(format!(" THR:{:>3}% BRK:{:>3}%", throttle_pct, brake_pct));
let pos_span = Span::raw(format!(" Pos:{:.3}", frame.spline_position));
let line4 = Line::from(vec![inputs_span, pos_span]);
lines.push(line4);
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
pub fn render(
frame: &mut Frame,
feeds: &[(FeedDisplayInfo, Arc<Mutex<FeedState>>)],
session_dir: &str,
version: &str,
) {
let total_feeds = feeds.len() as u16;
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(total_feeds * 4 + 2),
Constraint::Length(3),
])
.split(frame.area());
let title_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let title_inner = title_block.inner(outer[0]);
frame.render_widget(title_block, outer[0]);
let title_text = Line::from(Span::styled(
format!(
" rusty-telemetry v{} -- Telemetry Listener ",
version
),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
let title_para = Paragraph::new(title_text);
frame.render_widget(title_para, title_inner);
let feed_constraints: Vec<Constraint> = feeds
.iter()
.map(|_| Constraint::Min(6))
.collect();
let feed_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(feed_constraints)
.split(outer[1]);
for (i, (info, state)) in feeds.iter().enumerate() {
// Clone inside the lock, then drop the guard before rendering
// to avoid blocking the UDP receive task during the entire draw.
let snapshot = state.lock().ok().map(|mut s| {
s.tick();
s.clone()
});
if let Some(snap) = snapshot {
render_feed_panel(frame, feed_layout[i], info, &snap);
}
}
let footer_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let footer_inner = footer_block.inner(outer[2]);
frame.render_widget(footer_block, outer[2]);
let footer_text = Line::from(vec![
Span::styled(
format!(" Session: {}/ ", session_dir),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled(
"Ctrl+C Quit".to_string(),
Style::default().fg(Color::DarkGray),
),
]);
let footer_para = Paragraph::new(footer_text).wrap(Wrap { trim: true });
frame.render_widget(footer_para, footer_inner);
}