From a50d3af2902e9258d0cfed5036514c3adb57b3b6 Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Thu, 4 Jun 2026 09:49:00 +0200 Subject: [PATCH] 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 --- src/binary_utils.rs | 16 ++ src/main.rs | 14 ++ src/parser_pcars.rs | 504 +++++++++++++++++++++++++++++++++++++++++ src/telemetry_frame.rs | 36 +++ 4 files changed, 570 insertions(+) create mode 100644 src/parser_pcars.rs diff --git a/src/binary_utils.rs b/src/binary_utils.rs index 970fb1d..9afa718 100644 --- a/src/binary_utils.rs +++ b/src/binary_utils.rs @@ -41,6 +41,22 @@ pub fn read_i16(data: &[u8], offset: usize) -> i16 { 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. /// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets). pub fn read_f32(data: &[u8], offset: usize) -> f32 { diff --git a/src/main.rs b/src/main.rs index 0f07f69..a551c8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod ui; mod telemetry_frame; mod parser_ksudp; mod parser_telemetry_tool; +mod parser_pcars; use feed_state::*; use ui::*; @@ -69,6 +70,12 @@ static FEEDS: &[FeedConfig] = &[ label: "Telemetry Tool", needs_handshake: false, }, + FeedConfig { + port: 5606, + name: "pcars", + label: "PCARS", + needs_handshake: false, + }, ]; type Term = Terminal>; @@ -265,6 +272,13 @@ async fn run_feed( } else { 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 { // Telemetry Tool: parse only the first driver parser_telemetry_tool::parse_first_driver(&buf[..len]).ok() diff --git a/src/parser_pcars.rs b/src/parser_pcars.rs new file mode 100644 index 0000000..200f5d7 --- /dev/null +++ b/src/parser_pcars.rs @@ -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 { + 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 { + 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 { + 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))); + } +} diff --git a/src/telemetry_frame.rs b/src/telemetry_frame.rs index 1fab9e2..5fcd063 100644 --- a/src/telemetry_frame.rs +++ b/src/telemetry_frame.rs @@ -10,6 +10,7 @@ use std::fmt; pub enum FeedSource { Ksudp, TelemetryTool, + Pcars, } /// 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 driver_id: Option, + // ── PCARS-specific fields ────────────────────────────────────── + pub oil_temp_celsius: Option, + pub water_temp_celsius: Option, + pub fuel_capacity: Option, + pub boost_amount: Option, + pub crash_state: Option, + pub odometer_km: Option, + pub aero_damage: Option, + pub engine_damage: Option, + pub ambient_temperature: Option, + pub track_temperature: Option, + pub rain_density: Option, + pub track_length: Option, + 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 ─────────────────────────────────────────────────────── pub source: FeedSource, pub raw_size: usize, @@ -179,6 +198,22 @@ impl Default for TelemetryFrame { sector_times: None, last_sector_times: 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, raw_size: 0, } @@ -213,6 +248,7 @@ impl fmt::Display for FeedSource { match self { FeedSource::Ksudp => write!(f, "KSUDP"), FeedSource::TelemetryTool => write!(f, "Telemetry Tool"), + FeedSource::Pcars => write!(f, "PCARS"), } } }