feat: initial rusty-telemetry UDP listener

- Async UDP listener on configurable bind address (default 127.0.0.1:5005)
- RUSTY_TELEMETRY_BIND env var for cross-machine deployment
- Deserializes JSON telemetry frames with serde
- deny_unknown_fields for schema drift detection
- ReceiveStats tracker with packet rate and data rate
- Locked stdout writes for efficient output
- .gitignore for /target/ and .idea/
This commit is contained in:
Jan-Peter von Hunnius 2026-06-01 15:13:13 +02:00
commit 30543fd0ba
7 changed files with 797 additions and 0 deletions

78
src/main.rs Normal file
View file

@ -0,0 +1,78 @@
mod stats;
mod telemetry;
use stats::ReceiveStats;
use telemetry::TelemetryFrame;
use std::io::Write;
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:5005";
const BUFFER_SIZE: usize = 65535;
fn print_banner(bind_addr: &str) {
println!("========================================");
println!(" rusty-telemetry listener");
println!(" Phase 1 - UDP JSON receiver");
println!("========================================");
println!(" Bind: {}", bind_addr);
println!("========================================");
println!();
}
#[tokio::main]
async fn main() {
let bind_addr =
std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string());
print_banner(&bind_addr);
let socket = match tokio::net::UdpSocket::bind(&bind_addr).await {
Ok(s) => {
let local_addr = s.local_addr().unwrap();
println!("[INFO] Listening on {}", local_addr);
s
}
Err(e) => {
eprintln!("[ERROR] Failed to bind UDP socket: {}", e);
std::process::exit(1);
}
};
let mut buf = vec![0u8; BUFFER_SIZE];
let mut stats = ReceiveStats::new();
let stdout = std::io::stdout();
loop {
match socket.recv_from(&mut buf).await {
Ok((len, src)) => {
stats.record(len);
let text = match std::str::from_utf8(&buf[..len]) {
Ok(s) => s,
Err(e) => {
eprintln!("[WARN] Invalid UTF-8 from {}: {}", src, e);
continue;
}
};
let frame: TelemetryFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
eprintln!("[WARN] JSON parse error from {}: {}", src, e);
continue;
}
};
{
let mut handle = stdout.lock();
let _ = writeln!(handle, "{}", frame);
let _ = writeln!(handle, " [{}] {}", src, stats);
let _ = writeln!(handle);
}
}
Err(e) => {
eprintln!("[ERROR] Receive error: {}", e);
}
}
}
}

70
src/stats.rs Normal file
View file

@ -0,0 +1,70 @@
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(),
)
}
}

56
src/telemetry.rs Normal file
View file

@ -0,0 +1,56 @@
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,
)
}
}