use crate::config::MergedConfig;
use crate::parser::{SensorData, get_color_for_index, parse_sensor_data};
use crate::serial::{
get_timestamp, parse_data_bits, parse_flow_control, parse_parity, parse_stop_bits,
};
use crossterm::{
event::{self, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use inline_colorization::*;
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
prelude::*,
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::*,
};
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{self, BufWriter, Read, Write};
use std::time::{Duration, Instant};
struct PlotterState {
sensors: HashMap<String, SensorData>,
sensor_order: Vec<String>,
x: f64,
global_y_min: f64,
global_y_max: f64,
paused: bool,
total_samples: u64,
samples_this_second: u32,
sample_rate: u32,
last_rate_update: Instant,
start_time: Instant,
lines_discarded: usize,
receive_buf: String,
last_error: Option<String>,
export_data: HashMap<String, Vec<(f64, f64)>>,
pub export_limit: usize,
pub csv_streamer: Option<crate::export::CsvStreamer>,
}
const DISCARD_FIRST_LINES: usize = 3;
impl PlotterState {
fn new(export_limit: usize, csv_streamer: Option<crate::export::CsvStreamer>) -> Self {
let now = Instant::now();
PlotterState {
sensors: HashMap::new(),
sensor_order: Vec::new(),
x: 0.0,
global_y_min: f64::INFINITY,
global_y_max: f64::NEG_INFINITY,
paused: false,
total_samples: 0,
samples_this_second: 0,
sample_rate: 0,
last_rate_update: now,
start_time: now,
lines_discarded: 0,
receive_buf: String::new(),
last_error: None,
export_data: HashMap::new(),
export_limit,
csv_streamer,
}
}
fn get_or_create_sensor(&mut self, name: &str) -> &mut SensorData {
if !self.sensors.contains_key(name) {
let color = get_color_for_index(self.sensor_order.len());
self.sensors
.insert(name.to_string(), SensorData::new(name.to_string(), color));
self.sensor_order.push(name.to_string());
}
self.sensors.get_mut(name).unwrap()
}
fn ingest_line(&mut self, line: &str, max_points: usize) {
let clean = line.trim();
if self.lines_discarded < DISCARD_FIRST_LINES {
self.lines_discarded += 1;
return;
}
if self.paused {
return;
}
let readings = parse_sensor_data(clean);
if readings.is_empty() {
return;
}
if let Some(streamer) = &mut self.csv_streamer {
let _ = streamer.write_row(&readings);
}
for (name, value) in readings {
let x = self.x;
let sensor = self.get_or_create_sensor(name.as_ref());
sensor.add_point(x, value, max_points);
let series = self.export_data.entry(name.to_string()).or_default();
series.push((x, value));
if series.len() > self.export_limit {
let drop_count = self.export_limit / 10; series.drain(0..drop_count);
}
if value < self.global_y_min {
self.global_y_min = value;
}
if value > self.global_y_max {
self.global_y_max = value;
}
}
self.x += 1.0;
self.total_samples += 1;
self.samples_this_second += 1;
let elapsed = self.last_rate_update.elapsed();
if elapsed >= Duration::from_secs(1) {
self.sample_rate = self.samples_this_second;
self.samples_this_second = 0;
self.last_rate_update = Instant::now();
}
}
fn x_bounds(&self) -> [f64; 2] {
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
for sensor in self.sensors.values() {
if let (Some(first), Some(last)) = (sensor.data.first(), sensor.data.last()) {
if first.0 < min_x {
min_x = first.0;
}
if last.0 > max_x {
max_x = last.0;
}
}
}
if min_x.is_finite() && max_x.is_finite() {
[min_x, max_x]
} else {
[0.0, 10.0]
}
}
fn y_bounds(&self) -> [f64; 2] {
if self.global_y_min.is_finite() && self.global_y_max.is_finite() {
if self.global_y_min == self.global_y_max {
[self.global_y_min - 1.0, self.global_y_max + 1.0]
} else {
let padding = (self.global_y_max - self.global_y_min) * 0.1;
[self.global_y_min - padding, self.global_y_max + padding]
}
} else {
[-1.0, 1.0]
}
}
fn uptime_str(&self) -> String {
let secs = self.start_time.elapsed().as_secs();
format!(
"{:02}:{:02}:{:02}",
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
}
}
pub fn run_plotter_mode(
config: MergedConfig,
port_name: String,
) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data_bits = parse_data_bits(config.data_bits)?;
let stop_bits = parse_stop_bits(config.stop_bits)?;
let parity = parse_parity(&config.parity)?;
let flow_control = parse_flow_control(&config.flow_control)?;
let mut port = if config.simulate || config.replay_file.is_some() {
None
} else {
Some(
serialport::new(&port_name, config.baud)
.timeout(Duration::from_millis(config.timeout_ms))
.data_bits(data_bits)
.stop_bits(stop_bits)
.parity(parity)
.flow_control(flow_control)
.open()?,
)
};
let mut log_writer: Option<BufWriter<std::fs::File>> =
if let Some(ref log_path) = config.log_file {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
Some(BufWriter::new(file))
} else {
None
};
std::thread::sleep(Duration::from_millis(config.reset_delay_ms));
let csv_streamer = config
.csv_file
.as_ref()
.and_then(|path| crate::export::CsvStreamer::new(path).ok());
let mut session_replayer = if let Some(ref path) = config.replay_file {
Some(
crate::replay::SessionReplayer::new(path)
.map_err(|e| format!("Failed to open replay file '{}' : {}", path, e))?,
)
} else {
None
};
let mut state = PlotterState::new(config.export_limit, csv_streamer);
let mut serial_buf = [0u8; 1024];
loop {
if event::poll(Duration::from_millis(5))?
&& let event::Event::Key(key) = event::read()?
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Char(' ') => {
state.paused = !state.paused;
}
KeyCode::Char('c') => {
state.sensors.clear();
state.sensor_order.clear();
state.export_data.clear();
state.x = 0.0;
state.global_y_min = f64::INFINITY;
state.global_y_max = f64::NEG_INFINITY;
state.total_samples = 0;
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let timestamp = get_timestamp().replace(":", "-");
let filename = format!("comchan_plot_{}.svg", timestamp);
match crate::export::export_to_svg(
&state.export_data,
&filename,
&state.sensor_order,
&config.plot_title,
) {
Ok(_) => {
state.last_error = Some(format!("✅ Exported to {}", filename));
}
Err(e) => {
state.last_error = Some(format!("❌ Export failed: {}", e));
}
}
}
_ => {}
}
}
if config.simulate {
let t = state.x * 0.1;
let temp = (t * 1.0).sin() * 50.0;
let hum = (t * 0.8).cos() * 50.0;
let pres = (t * 0.5).sin() * 50.0;
state.ingest_line(&format!("Temperature: {:.2}", temp), config.plot_points);
state.ingest_line(&format!("Humidity: {:.2}", hum), config.plot_points);
state.ingest_line(&format!("Pressure: {:.2}", pres), config.plot_points);
std::thread::sleep(Duration::from_millis(50));
} else if let Some(ref mut replayer) = session_replayer {
match replayer.next_payload() {
crate::replay::ReplayEvent::Payload(payload) => {
state.ingest_line(&payload, config.plot_points);
}
crate::replay::ReplayEvent::Waiting => {}
crate::replay::ReplayEvent::Eof => {
std::thread::sleep(Duration::from_millis(100));
}
}
} else if let Some(p) = port.as_mut() {
match p.read(&mut serial_buf) {
Ok(n) if n > 0 => {
let chunk = String::from_utf8_lossy(&serial_buf[..n]);
state.receive_buf.push_str(&chunk);
while let Some(pos) = state.receive_buf.find('\n') {
let line = state.receive_buf.drain(..=pos).collect::<String>();
if let Some(ref mut writer) = log_writer {
let _ =
writeln!(writer, "RX [{}]: {}", get_timestamp(), line.trim_end());
let _ = writer.flush();
}
state.ingest_line(&line, config.plot_points);
}
}
Ok(_) => {}
Err(ref e) if e.kind() == io::ErrorKind::TimedOut => {}
Err(e) => {
state.last_error = Some(format!("Read error: {}", e));
}
}
}
let x_bounds = state.x_bounds();
let y_bounds = state.y_bounds();
let x_labels = [
format!("{:.0}", x_bounds[0]),
format!("{:.0}", (x_bounds[0] + x_bounds[1]) / 2.0),
format!("{:.0}", x_bounds[1]),
];
let y_labels = [
format!("{:.2}", y_bounds[0]),
format!("{:.2}", (y_bounds[0] + y_bounds[1]) / 2.0),
format!("{:.2}", y_bounds[1]),
];
let pause_indicator = if state.paused { " ⏸ PAUSED" } else { "" };
let uptime = state.uptime_str();
let sample_rate = state.sample_rate;
let total_samples = state.total_samples;
let sensor_count = state.sensors.len();
let last_error = state.last_error.clone();
let baud = config.baud;
let port_name_disp = port_name.clone();
let sidebar_rows: Vec<(String, Color, f64, f64, f64)> = state
.sensor_order
.iter()
.filter_map(|name| {
state.sensors.get(name).map(|s| {
(
s.name.clone(),
s.color,
s.current_value,
s.min_value,
s.max_value,
)
})
})
.collect();
let datasets: Vec<Dataset> = state
.sensor_order
.iter()
.filter_map(|name| state.sensors.get(name))
.filter(|s| s.has_data())
.map(|sensor| {
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(sensor.color))
.data(&sensor.data)
})
.collect();
terminal.draw(|f| {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(f.area());
let main_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(28)])
.split(outer[0]);
let chart_title = format!(
" ComChan Plotter {} {} {} sensors{}",
port_name_disp, baud, sensor_count, pause_indicator
);
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
chart_title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.x_axis(
Axis::default()
.title(Span::styled("Sample", Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds(x_bounds)
.labels(vec![
x_labels[0].as_str(),
x_labels[1].as_str(),
x_labels[2].as_str(),
]),
)
.y_axis(
Axis::default()
.title(Span::styled("Value", Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds(y_bounds)
.labels(vec![
y_labels[0].as_str(),
y_labels[1].as_str(),
y_labels[2].as_str(),
]),
);
f.render_widget(chart, main_row[0]);
let sidebar_block = Block::default()
.title(Span::styled(
" Sensors ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = sidebar_block.inner(main_row[1]);
f.render_widget(sidebar_block, main_row[1]);
if sidebar_rows.is_empty() {
let waiting = Paragraph::new("Waiting for\ndata…")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
f.render_widget(waiting, inner);
} else {
let mut lines: Vec<Line> = Vec::new();
for (name, color, cur, min, max) in &sidebar_rows {
lines.push(Line::from(vec![Span::styled(
format!("● {}", name),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" now: {:.3}", cur),
Style::default().fg(Color::White),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" min: {:.3}", min),
Style::default().fg(Color::Blue),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" max: {:.3}", max),
Style::default().fg(Color::Red),
)]));
lines.push(Line::from(vec![Span::styled(
" ─────────────",
Style::default().fg(Color::DarkGray),
)]));
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
let status_bg = if state.paused {
Color::DarkGray
} else {
Color::Reset
};
let error_span = if let Some(ref err) = last_error {
Span::styled(format!(" ⚠ {} ", err), Style::default().fg(Color::Red))
} else {
Span::raw("")
};
let status_line = Line::from(vec![
Span::styled(format!(" ⏱ {}", uptime), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(
format!(" {} sps", sample_rate),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(
format!(" {} total", total_samples),
Style::default().fg(Color::Yellow),
),
Span::raw(" "),
error_span,
Span::styled(
" [Space] pause [c] clear [q/Esc] quit [Ctrl + s] Export",
Style::default().fg(Color::DarkGray),
),
]);
let status_bar = Paragraph::new(status_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.style(Style::default().bg(status_bg));
f.render_widget(status_bar, outer[1]);
})?;
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
if !state.sensors.is_empty() {
println!("\n{color_green} Plotting Summary:{color_reset}");
for name in &state.sensor_order {
if let Some(s) = state.sensors.get(name) {
println!(
" {}: {} pts min={:.3} max={:.3} last={:.3}",
s.name,
s.data.len(),
s.min_value,
s.max_value,
s.current_value,
);
}
}
println!(
" Total samples: {} Uptime: {}",
state.total_samples,
state.uptime_str()
);
}
Ok(())
}