Live can
This commit is contained in:
@@ -21,3 +21,4 @@ egui_plot = "0.35"
|
||||
egui_ltreeview = "0.7"
|
||||
fuzzy-matcher = "0.3"
|
||||
rfd = "0.17"
|
||||
socketcan = "3.6.1"
|
||||
|
||||
+127
-9
@@ -1,3 +1,4 @@
|
||||
mod can_live;
|
||||
mod plot;
|
||||
mod plot_list;
|
||||
mod signal;
|
||||
@@ -13,7 +14,7 @@ use std::{
|
||||
|
||||
use can_dbc::Dbc;
|
||||
use eframe::egui;
|
||||
use libsvld::DecodeResult;
|
||||
use libsvld::{can_frame::CanFrame, DecodeResult};
|
||||
|
||||
use crate::plot::show_plot_panel;
|
||||
use crate::plot_list::{
|
||||
@@ -30,13 +31,36 @@ fn main() {
|
||||
native_options,
|
||||
Box::new(|cc| {
|
||||
let mut app = MyEguiApp::default();
|
||||
// Optional CLI quick-load: `svldplotter [DBC [ASC]]`. Both args
|
||||
// are optional and intended for dev iteration.
|
||||
// Optional CLI quick-load. Positional args are an optional DBC and
|
||||
// ASC, intended for dev iteration:
|
||||
// svldplotter [DBC [ASC]]
|
||||
// Passing `--can <iface>` (e.g. `svldplotter car.dbc --can can0`)
|
||||
// switches to live mode: instead of reading an ASC file the
|
||||
// plotter streams frames straight off the CAN bus and plots them
|
||||
// in real time, using the DBC for signal metadata.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(dbc) = args.get(1) {
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
let mut can_iface: Option<String> = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--can" | "-c" => {
|
||||
can_iface = args.get(i + 1).cloned();
|
||||
i += 2;
|
||||
}
|
||||
other => {
|
||||
positional.push(other.to_string());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(dbc) = positional.first() {
|
||||
app.load_dbc(PathBuf::from(dbc));
|
||||
}
|
||||
if let Some(asc) = args.get(2) {
|
||||
if let Some(iface) = can_iface {
|
||||
// Live CAN takes precedence over any ASC argument.
|
||||
app.start_can_live(&iface, &cc.egui_ctx);
|
||||
} else if let Some(asc) = positional.get(1) {
|
||||
app.asc_paths.push(PathBuf::from(asc));
|
||||
app.set_active_asc(app.asc_paths.len() - 1, Some(&cc.egui_ctx));
|
||||
}
|
||||
@@ -74,6 +98,13 @@ struct MyEguiApp {
|
||||
next_plot_id: u64,
|
||||
cursor_x: Option<f64>,
|
||||
left_tab: LeftTab,
|
||||
/// Live CAN stream receiver, present only in `--can` mode.
|
||||
live_rx: Option<Receiver<CanFrame>>,
|
||||
/// Growing decode result fed by the live CAN stream.
|
||||
live_data: Option<DecodeResult>,
|
||||
/// Latest data x-extent from the previous frame, used to detect whether
|
||||
/// the live view is pinned to the right edge for auto-scroll.
|
||||
live_prev_max: Option<f64>,
|
||||
}
|
||||
|
||||
impl MyEguiApp {
|
||||
@@ -182,15 +213,95 @@ impl MyEguiApp {
|
||||
self.data = Some(data);
|
||||
}
|
||||
|
||||
fn handle_dbc_toggles(&mut self, toggles: Vec<u64>) {
|
||||
let Some(data) = self.data.clone() else {
|
||||
/// Enter live CAN mode: open `iface` and start streaming frames into a
|
||||
/// fresh, growing `DecodeResult`. Requires a DBC to already be loaded so
|
||||
/// incoming frames can be resolved to signals.
|
||||
fn start_can_live(&mut self, iface: &str, ctx: &egui::Context) {
|
||||
if self.dbc.is_none() {
|
||||
eprintln!("Cannot start live CAN: no DBC loaded.");
|
||||
return;
|
||||
}
|
||||
match can_live::spawn_reader(iface) {
|
||||
Ok(rx) => {
|
||||
self.live_rx = Some(rx);
|
||||
self.live_data = Some(DecodeResult::empty());
|
||||
// Clear any file-based state so the two sources don't mix.
|
||||
self.active_asc = None;
|
||||
self.data = None;
|
||||
self.tree.clear();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to open CAN interface '{iface}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any frames received from the live CAN stream, fold them into the
|
||||
/// live `DecodeResult`, and re-resolve the tree and plotted signals so the
|
||||
/// UI reflects the latest data. A repaint is always requested while live
|
||||
/// so polling continues even when no frame arrived this tick.
|
||||
fn poll_live(&mut self, ctx: &egui::Context) {
|
||||
if self.live_rx.is_none() {
|
||||
return;
|
||||
}
|
||||
// Keep the UI ticking so we keep polling the stream.
|
||||
ctx.request_repaint();
|
||||
|
||||
let Some(dbc) = self.dbc.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect first so the receiver borrow is released before we mutate
|
||||
// `self.live_data` below.
|
||||
let frames: Vec<CanFrame> = self
|
||||
.live_rx
|
||||
.as_ref()
|
||||
.map(|rx| rx.try_iter().collect())
|
||||
.unwrap_or_default();
|
||||
if frames.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(live) = self.live_data.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for frame in frames {
|
||||
live.push_live(&dbc, frame);
|
||||
}
|
||||
|
||||
// Re-resolve tree and plots against the updated live data. These touch
|
||||
// disjoint fields of `self`, so the borrows don't conflict.
|
||||
let data = self.live_data.as_ref().unwrap();
|
||||
self.tree = build_tree(&dbc, data);
|
||||
for plot in &mut self.plots {
|
||||
for sig in &mut plot.signals {
|
||||
if let Some((msg_id, sig_idx)) = decode_signal_node_id(sig.id) {
|
||||
if let Some(new_sig) = compute_signal(data, msg_id, sig_idx, sig.id) {
|
||||
*sig = new_sig;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dbc_toggles(&mut self, toggles: Vec<u64>) {
|
||||
for id in toggles {
|
||||
if contains_signal(&self.plots, id) {
|
||||
remove_signal(&mut self.plots, id);
|
||||
} else if let Some((msg_id, sig_idx)) = decode_signal_node_id(id) {
|
||||
if let Some(sig) = compute_signal(&data, msg_id, sig_idx, id) {
|
||||
// Pull from the live stream when in `--can` mode, otherwise the
|
||||
// decoded ASC. Accessed as disjoint fields so the immutable
|
||||
// data borrow doesn't clash with the `self.plots` mutation.
|
||||
let data = if self.live_data.is_some() {
|
||||
self.live_data.as_ref()
|
||||
} else {
|
||||
self.data.as_deref()
|
||||
};
|
||||
let Some(data) = data else {
|
||||
continue;
|
||||
};
|
||||
if let Some(sig) = compute_signal(data, msg_id, sig_idx, id) {
|
||||
add_signal_auto(&mut self.plots, &mut self.next_plot_id, sig);
|
||||
}
|
||||
}
|
||||
@@ -267,6 +378,7 @@ impl eframe::App for MyEguiApp {
|
||||
self.poll_pending();
|
||||
|
||||
let ctx = ui.ctx().clone();
|
||||
self.poll_live(&ctx);
|
||||
egui::Panel::left(egui::Id::new("LeftPanel"))
|
||||
.resizable(true)
|
||||
.show_inside(ui, |ui| {
|
||||
@@ -317,7 +429,13 @@ impl eframe::App for MyEguiApp {
|
||||
}
|
||||
}
|
||||
});
|
||||
show_plot_panel(ui, &self.plots, &mut self.cursor_x);
|
||||
show_plot_panel(
|
||||
ui,
|
||||
&self.plots,
|
||||
&mut self.cursor_x,
|
||||
self.live_rx.is_some(),
|
||||
&mut self.live_prev_max,
|
||||
);
|
||||
|
||||
if let Some(job) = self.pending.as_ref() {
|
||||
let name = job
|
||||
|
||||
+36
-2
@@ -65,7 +65,20 @@ fn line_label(sig: &PlottedSignal, cursor_x: Option<f64>) -> String {
|
||||
/// Clamp the visible x-range to [0, x_max]. Preserves the current zoom
|
||||
/// width when possible; if the zoomed-out width exceeds the data range,
|
||||
/// just snaps to the full range.
|
||||
fn clamp_x_bounds(plot_ui: &mut egui_plot::PlotUi, x_max: f64) {
|
||||
///
|
||||
/// In live mode, if the view's right edge was pinned to the latest sample
|
||||
/// (`prev_max`, the data extent from the previous frame), the window is
|
||||
/// scrolled right to keep the right edge at the new `x_max` so the plot
|
||||
/// auto-follows incoming data. Panning away from the edge disengages this.
|
||||
fn clamp_x_bounds(
|
||||
plot_ui: &mut egui_plot::PlotUi,
|
||||
x_max: f64,
|
||||
live: bool,
|
||||
prev_max: Option<f64>,
|
||||
) {
|
||||
if !x_max.is_finite() {
|
||||
return;
|
||||
}
|
||||
let bounds = plot_ui.plot_bounds();
|
||||
let lo = bounds.min();
|
||||
let hi = bounds.max();
|
||||
@@ -73,6 +86,19 @@ fn clamp_x_bounds(plot_ui: &mut egui_plot::PlotUi, x_max: f64) {
|
||||
let mut new_hi_x = hi[0];
|
||||
let width = new_hi_x - new_lo_x;
|
||||
|
||||
if live && width < x_max {
|
||||
// Pinned when the right edge sits at (or past) the previous extent.
|
||||
// A tiny epsilon absorbs float error from last frame's snap.
|
||||
let pinned = match prev_max {
|
||||
Some(p) => new_hi_x >= p - width * 1e-3,
|
||||
None => true,
|
||||
};
|
||||
if pinned {
|
||||
new_hi_x = x_max;
|
||||
new_lo_x = x_max - width;
|
||||
}
|
||||
}
|
||||
|
||||
if width >= x_max {
|
||||
new_lo_x = 0.0;
|
||||
new_hi_x = x_max;
|
||||
@@ -99,6 +125,8 @@ pub fn show_plot_panel(
|
||||
ui: &mut egui::Ui,
|
||||
plots: &[PlotGroup],
|
||||
cursor_x: &mut Option<f64>,
|
||||
live: bool,
|
||||
prev_max: &mut Option<f64>,
|
||||
) {
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
if plots.is_empty() {
|
||||
@@ -109,6 +137,12 @@ pub fn show_plot_panel(
|
||||
}
|
||||
|
||||
let (full_min, full_max) = full_x_range(plots);
|
||||
// Snapshot the previous frame's extent for the auto-follow decision,
|
||||
// then record this frame's for the next one.
|
||||
let follow_prev_max = *prev_max;
|
||||
if full_max.is_finite() {
|
||||
*prev_max = Some(full_max);
|
||||
}
|
||||
let link_group = ui.id().with("plot-link");
|
||||
let plot_count = plots.len();
|
||||
let plot_height =
|
||||
@@ -145,7 +179,7 @@ pub fn show_plot_panel(
|
||||
]);
|
||||
|
||||
let response = plot.show(ui, |plot_ui| {
|
||||
clamp_x_bounds(plot_ui, full_max);
|
||||
clamp_x_bounds(plot_ui, full_max, live, follow_prev_max);
|
||||
if plot_group.signals.is_empty() {
|
||||
plot_ui.line(
|
||||
Line::new(
|
||||
|
||||
Reference in New Issue
Block a user