rusty-telemetry/src/ui.rs
Jan-Peter v. Hunnius 7a1c37fc86 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
2026-06-04 08:48:35 +02:00

222 lines
7.4 KiB
Rust

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