refactor: TUI multi-feed monitor with binary parsers
- Add ratatui/crossterm TUI replacing stdout data dump - Add binary parsers for KSUDP RTCarInfo (port 9996) and Telemetry Tool (port 10101) - Add shared binary_utils module for safe packet reading - Add ring-buffer frequency calculation and feed health state machine - Add parse_first_driver() to avoid unnecessary multi-driver allocation - Add 27 unit tests (14 feed_state + 6 ksudp + 7 telemetry_tool) - Move parsing outside mutex lock to minimize TUI contention - Replace expect() on env var with graceful error handling - Remove unused serde dependency - Delete obsolete stats.rs and telemetry.rs
This commit is contained in:
parent
1731495197
commit
7a1c37fc86
11 changed files with 2308 additions and 307 deletions
222
src/ui.rs
Normal file
222
src/ui.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue