use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; use pipeview_core::protocol::Endian; use pipeview_core::protocol::plot::{PlotFormat, SampleType}; use pipeview_core::protocol::text::TextEncoding; use pipeview_core::transport::TransportConfig; use pipeview_core::transport::serial::{ SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport, }; #[derive(Clone)] pub struct ConfigForm { pub transport: TransportChoice, pub port: String, pub baud: u32, pub serial_data_bits: SerialDataBits, pub serial_parity: SerialParity, pub serial_stop_bits: SerialStopBits, pub serial_flow_control: SerialFlowControl, pub serial_dtr: bool, pub serial_rts: bool, pub addr: String, pub udp_bind: String, pub udp_remote: String, pub pipelines: Vec, pub history: usize, pub auto_reconnect: bool, } #[derive(Clone)] pub struct PipelineForm { pub name: String, pub framer: FramerChoice, pub decoder: DecoderChoice, pub text_encoding: TextEncoding, pub line_strip_cr: bool, pub line_max_line_len: usize, pub fixed_frame_len: usize, pub length_len_bytes: usize, pub length_endian: Endian, pub length_includes_self: bool, pub length_max_payload: usize, pub cobs_max_frame: usize, pub mixed_strip_cr: bool, pub mixed_max_line_len: usize, pub mixed_max_plot_frame: usize, pub lua_framer_script_path: String, pub hex_uppercase: bool, pub hex_separator: String, pub hex_bytes_per_group: usize, pub hex_endian: Endian, pub plot_sample_type: SampleType, pub plot_channels: usize, pub plot_endian: Endian, pub plot_format: PlotFormat, pub lua_decoder_script_path: String, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum TransportChoice { Serial, Tcp, Udp, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum FramerChoice { Line, Fixed, Length, Cobs, MixedTextPlot, Lua, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum DecoderChoice { Text, Hex, Plot, MixedTextPlot, Lua, } impl PipelineForm { fn new(name: impl Into) -> Self { Self { name: name.into(), framer: FramerChoice::Line, decoder: DecoderChoice::Text, text_encoding: TextEncoding::Utf8, line_strip_cr: true, line_max_line_len: 1_048_576, fixed_frame_len: 256, length_len_bytes: 2, length_endian: Endian::Big, length_includes_self: false, length_max_payload: 1_048_576, cobs_max_frame: 1_048_576, mixed_strip_cr: true, mixed_max_line_len: 1_048_576, mixed_max_plot_frame: 1_048_576, lua_framer_script_path: String::new(), hex_uppercase: false, hex_separator: String::from(" "), hex_bytes_per_group: 1, hex_endian: Endian::Big, plot_sample_type: SampleType::F32, plot_channels: 1, plot_endian: Endian::Little, plot_format: PlotFormat::Interleaved, lua_decoder_script_path: String::new(), } } } impl Default for ConfigForm { fn default() -> Self { Self { transport: TransportChoice::Tcp, port: String::from("/dev/ttyUSB0"), baud: 115200, serial_data_bits: SerialDataBits::Eight, serial_parity: SerialParity::None, serial_stop_bits: SerialStopBits::One, serial_flow_control: SerialFlowControl::None, serial_dtr: false, serial_rts: false, addr: String::from("127.0.0.1:8080"), udp_bind: String::from("0.0.0.0:9000"), udp_remote: String::new(), pipelines: vec![PipelineForm::new("text")], history: 10_000, auto_reconnect: false, } } } impl ConfigForm { pub fn from_session_config(config: &SessionConfig) -> Self { Self { transport: match &config.transport { TransportConfig::Serial { .. } => TransportChoice::Serial, TransportConfig::Tcp { .. } => TransportChoice::Tcp, TransportConfig::Udp { .. } => TransportChoice::Udp, }, port: match &config.transport { TransportConfig::Serial { port, .. } => port.clone(), _ => String::from("/dev/ttyUSB0"), }, baud: match &config.transport { TransportConfig::Serial { baud_rate, .. } => *baud_rate, _ => 115200, }, serial_data_bits: match &config.transport { TransportConfig::Serial { data_bits, .. } => *data_bits, _ => SerialDataBits::Eight, }, serial_parity: match &config.transport { TransportConfig::Serial { parity, .. } => *parity, _ => SerialParity::None, }, serial_stop_bits: match &config.transport { TransportConfig::Serial { stop_bits, .. } => *stop_bits, _ => SerialStopBits::One, }, serial_flow_control: match &config.transport { TransportConfig::Serial { flow_control, .. } => *flow_control, _ => SerialFlowControl::None, }, serial_dtr: match &config.transport { TransportConfig::Serial { dtr, .. } => *dtr, _ => false, }, serial_rts: match &config.transport { TransportConfig::Serial { rts, .. } => *rts, _ => false, }, addr: match &config.transport { TransportConfig::Tcp { addr } => addr.clone(), _ => String::from("127.0.0.1:8080"), }, udp_bind: match &config.transport { TransportConfig::Udp { bind_addr, .. } => bind_addr.clone(), _ => String::from("0.0.0.0:9000"), }, udp_remote: match &config.transport { TransportConfig::Udp { remote_addr, .. } => remote_addr.clone().unwrap_or_default(), _ => String::new(), }, pipelines: config .pipelines .iter() .map(PipelineForm::from_pipeline_config) .collect(), history: config.history_limit, auto_reconnect: config.auto_reconnect, } } pub fn to_session_config(&self) -> SessionConfig { SessionConfig { transport: match self.transport { TransportChoice::Serial => TransportConfig::Serial { port: self.port.clone(), baud_rate: self.baud, data_bits: self.serial_data_bits, parity: self.serial_parity, stop_bits: self.serial_stop_bits, flow_control: self.serial_flow_control, dtr: self.serial_dtr, rts: self.serial_rts, }, TransportChoice::Tcp => TransportConfig::Tcp { addr: self.addr.clone(), }, TransportChoice::Udp => TransportConfig::Udp { bind_addr: self.udp_bind.clone(), remote_addr: if self.udp_remote.trim().is_empty() { None } else { Some(self.udp_remote.clone()) }, }, }, pipelines: self .pipelines .iter() .map(PipelineForm::to_pipeline_config) .collect(), history_limit: self.history, auto_reconnect: self.auto_reconnect, } } } impl PipelineForm { fn from_pipeline_config(config: &PipelineConfig) -> Self { let mut form = Self::new(config.name.clone()); match &config.framer { FramerConfig::Line { strip_cr, max_line_len, } => { form.framer = FramerChoice::Line; form.line_strip_cr = *strip_cr; form.line_max_line_len = *max_line_len; } FramerConfig::Fixed { frame_len } => { form.framer = FramerChoice::Fixed; form.fixed_frame_len = *frame_len; } FramerConfig::Length { len_bytes, endian, length_includes_self, max_payload, } => { form.framer = FramerChoice::Length; form.length_len_bytes = *len_bytes; form.length_endian = *endian; form.length_includes_self = *length_includes_self; form.length_max_payload = *max_payload; } FramerConfig::Cobs { max_frame } => { form.framer = FramerChoice::Cobs; form.cobs_max_frame = *max_frame; } FramerConfig::MixedTextPlot { strip_cr, max_line_len, max_plot_frame, } => { form.framer = FramerChoice::MixedTextPlot; form.mixed_strip_cr = *strip_cr; form.mixed_max_line_len = *max_line_len; form.mixed_max_plot_frame = *max_plot_frame; } FramerConfig::Lua { script_path } => { form.framer = FramerChoice::Lua; form.lua_framer_script_path = script_path.clone(); } } match &config.decoder { DecoderConfig::Text { encoding } => { form.decoder = DecoderChoice::Text; form.text_encoding = *encoding; } DecoderConfig::Hex { uppercase, separator, bytes_per_group, endian, } => { form.decoder = DecoderChoice::Hex; form.hex_uppercase = *uppercase; form.hex_separator = separator.clone(); form.hex_bytes_per_group = *bytes_per_group; form.hex_endian = *endian; } DecoderConfig::Plot { sample_type, endian, channels, format, } => { form.decoder = DecoderChoice::Plot; form.plot_sample_type = *sample_type; form.plot_endian = *endian; form.plot_channels = *channels; form.plot_format = *format; } DecoderConfig::MixedTextPlot { encoding } => { form.decoder = DecoderChoice::MixedTextPlot; form.text_encoding = *encoding; } DecoderConfig::Lua { script_path } => { form.decoder = DecoderChoice::Lua; form.lua_decoder_script_path = script_path.clone(); } } form } fn to_pipeline_config(&self) -> PipelineConfig { PipelineConfig { name: self.name.clone(), framer: match self.framer { FramerChoice::Line => FramerConfig::Line { strip_cr: self.line_strip_cr, max_line_len: self.line_max_line_len, }, FramerChoice::Fixed => FramerConfig::Fixed { frame_len: self.fixed_frame_len, }, FramerChoice::Length => FramerConfig::Length { len_bytes: self.length_len_bytes, endian: self.length_endian, length_includes_self: self.length_includes_self, max_payload: self.length_max_payload, }, FramerChoice::Cobs => FramerConfig::Cobs { max_frame: self.cobs_max_frame, }, FramerChoice::MixedTextPlot => FramerConfig::MixedTextPlot { strip_cr: self.mixed_strip_cr, max_line_len: self.mixed_max_line_len, max_plot_frame: self.mixed_max_plot_frame, }, FramerChoice::Lua => FramerConfig::Lua { script_path: self.lua_framer_script_path.clone(), }, }, decoder: match self.decoder { DecoderChoice::Text => DecoderConfig::Text { encoding: self.text_encoding, }, DecoderChoice::Hex => DecoderConfig::Hex { uppercase: self.hex_uppercase, separator: self.hex_separator.clone(), bytes_per_group: self.hex_bytes_per_group.max(1), endian: self.hex_endian, }, DecoderChoice::Plot => DecoderConfig::Plot { sample_type: self.plot_sample_type, endian: self.plot_endian, channels: self.plot_channels.max(1), format: self.plot_format, }, DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot { encoding: self.text_encoding, }, DecoderChoice::Lua => DecoderConfig::Lua { script_path: self.lua_decoder_script_path.clone(), }, }, } } } pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option { let mut result = None; let mut validation_errors = Vec::new(); ScrollArea::vertical().show(ui, |ui| { ui.heading("Transport"); render_transport_section(ui, form); ui.separator(); ui.heading("Pipelines"); let mut remove = None; for (index, pipeline) in form.pipelines.iter_mut().enumerate() { ui.group(|ui| { render_pipeline_header(ui, pipeline, index, &mut remove); render_framer_settings(ui, pipeline, index); render_decoder_settings(ui, pipeline, index); validate_pipeline(ui, pipeline, &mut validation_errors); }); } if let Some(index) = remove { form.pipelines.remove(index); } if ui.button("+ Add Pipeline").clicked() { form.pipelines .push(PipelineForm::new(format!("p{}", form.pipelines.len()))); } ui.separator(); ui.horizontal(|ui| { ui.label("History:"); ui.add(DragValue::new(&mut form.history).range(100..=1_000_000)); }); ui.checkbox(&mut form.auto_reconnect, "Auto reconnect"); ui.separator(); for error in &validation_errors { ui.colored_label(egui::Color32::RED, error); } if ui.button(submit_label).clicked() && validation_errors.is_empty() { result = Some(form.to_session_config()); } }); result } fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) { ComboBox::from_label("Type") .selected_text(match form.transport { TransportChoice::Serial => "Serial", TransportChoice::Tcp => "TCP", TransportChoice::Udp => "UDP", }) .show_ui(ui, |ui| { ui.selectable_value(&mut form.transport, TransportChoice::Serial, "Serial"); ui.selectable_value(&mut form.transport, TransportChoice::Tcp, "TCP"); ui.selectable_value(&mut form.transport, TransportChoice::Udp, "UDP"); }); match form.transport { TransportChoice::Serial => { render_serial_port_combo(ui, form); ui.horizontal(|ui| { ui.label("Baud:"); ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000)); }); ui.horizontal(|ui| { ui.label("Data bits:"); ComboBox::from_id_salt("serial_data_bits") .selected_text(match form.serial_data_bits { SerialDataBits::Five => "5", SerialDataBits::Six => "6", SerialDataBits::Seven => "7", SerialDataBits::Eight => "8", }) .show_ui(ui, |ui| { ui.selectable_value(&mut form.serial_data_bits, SerialDataBits::Five, "5"); ui.selectable_value(&mut form.serial_data_bits, SerialDataBits::Six, "6"); ui.selectable_value(&mut form.serial_data_bits, SerialDataBits::Seven, "7"); ui.selectable_value(&mut form.serial_data_bits, SerialDataBits::Eight, "8"); }); }); ui.horizontal(|ui| { ui.label("Parity:"); ComboBox::from_id_salt("serial_parity") .selected_text(match form.serial_parity { SerialParity::None => "None", SerialParity::Odd => "Odd", SerialParity::Even => "Even", }) .show_ui(ui, |ui| { ui.selectable_value(&mut form.serial_parity, SerialParity::None, "None"); ui.selectable_value(&mut form.serial_parity, SerialParity::Odd, "Odd"); ui.selectable_value(&mut form.serial_parity, SerialParity::Even, "Even"); }); }); ui.horizontal(|ui| { ui.label("Stop bits:"); ComboBox::from_id_salt("serial_stop_bits") .selected_text(match form.serial_stop_bits { SerialStopBits::One => "1", SerialStopBits::Two => "2", }) .show_ui(ui, |ui| { ui.selectable_value(&mut form.serial_stop_bits, SerialStopBits::One, "1"); ui.selectable_value(&mut form.serial_stop_bits, SerialStopBits::Two, "2"); }); }); ui.horizontal(|ui| { ui.label("Flow:"); ComboBox::from_id_salt("serial_flow_control") .selected_text(match form.serial_flow_control { SerialFlowControl::None => "None", SerialFlowControl::Software => "Software", SerialFlowControl::Hardware => "Hardware", }) .show_ui(ui, |ui| { ui.selectable_value( &mut form.serial_flow_control, SerialFlowControl::None, "None", ); ui.selectable_value( &mut form.serial_flow_control, SerialFlowControl::Software, "Software", ); ui.selectable_value( &mut form.serial_flow_control, SerialFlowControl::Hardware, "Hardware", ); }); }); ui.checkbox(&mut form.serial_dtr, "DTR"); ui.checkbox(&mut form.serial_rts, "RTS"); } TransportChoice::Tcp => { ui.horizontal(|ui| { ui.label("Addr:"); ui.text_edit_singleline(&mut form.addr); }); } TransportChoice::Udp => { ui.horizontal(|ui| { ui.label("Bind:"); ui.text_edit_singleline(&mut form.udp_bind); }); ui.horizontal(|ui| { ui.label("Remote:"); ui.text_edit_singleline(&mut form.udp_remote); }); } } } fn render_serial_port_combo(ui: &mut Ui, form: &mut ConfigForm) { let available_ports = available_serial_ports(); let selected_text = selected_serial_port_text(&form.port, &available_ports); ui.horizontal(|ui| { ui.label("Port:"); ComboBox::from_id_salt("serial_port") .width(180.0) .selected_text(selected_text) .show_ui(ui, |ui| { if available_ports.is_empty() { ui.add_enabled(false, egui::Label::new("No serial ports found")); } else { for port in &available_ports { ui.selectable_value(&mut form.port, port.clone(), port); } } let current_port = form.port.clone(); if !current_port.trim().is_empty() && !available_ports.iter().any(|port| port == ¤t_port) { ui.separator(); ui.selectable_value( &mut form.port, current_port.clone(), format!("{current_port} (current)"), ); } }); ui.add(TextEdit::singleline(&mut form.port).desired_width(260.0)); }); } fn available_serial_ports() -> Vec { let mut ports: Vec<_> = SerialTransport::list_ports() .into_iter() .map(|port| port.port_name) .collect(); ports.sort(); ports.dedup(); ports } fn selected_serial_port_text(current_port: &str, available_ports: &[String]) -> String { if current_port.trim().is_empty() { if available_ports.is_empty() { String::from("No serial ports found") } else { String::from("Select port") } } else { current_port.to_owned() } } fn render_pipeline_header( ui: &mut Ui, pipeline: &mut PipelineForm, index: usize, remove: &mut Option, ) { ui.horizontal(|ui| { ui.label("Name:"); ui.add(TextEdit::singleline(&mut pipeline.name).desired_width(90.0)); ComboBox::from_id_salt(format!("framer_{index}")) .selected_text(match pipeline.framer { FramerChoice::Line => "Line", FramerChoice::Fixed => "Fixed", FramerChoice::Length => "Length", FramerChoice::Cobs => "Cobs", FramerChoice::MixedTextPlot => "MixedTextPlot", FramerChoice::Lua => "Lua", }) .show_ui(ui, |ui| { ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Lua, "Lua"); if ui .selectable_label( matches!(pipeline.framer, FramerChoice::MixedTextPlot), "MixedTextPlot", ) .clicked() { pipeline.framer = FramerChoice::MixedTextPlot; pipeline.decoder = DecoderChoice::MixedTextPlot; } }); ComboBox::from_id_salt(format!("decoder_{index}")) .selected_text(match pipeline.decoder { DecoderChoice::Text => "Text", DecoderChoice::Hex => "Hex", DecoderChoice::Plot => "Plot", DecoderChoice::MixedTextPlot => "MixedTextPlot", DecoderChoice::Lua => "Lua", }) .show_ui(ui, |ui| { ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text"); ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex"); ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Lua, "Lua"); if ui .selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot") .clicked() { if matches!(pipeline.framer, FramerChoice::Line) { pipeline.framer = FramerChoice::Fixed; } pipeline.decoder = DecoderChoice::Plot; } if ui .selectable_label( matches!(pipeline.decoder, DecoderChoice::MixedTextPlot), "MixedTextPlot", ) .clicked() { pipeline.framer = FramerChoice::MixedTextPlot; pipeline.decoder = DecoderChoice::MixedTextPlot; } }); if ui.button("Delete").clicked() { *remove = Some(index); } }); } fn render_framer_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usize) { ui.separator(); ui.label("Framer settings"); match pipeline.framer { FramerChoice::Line => { ui.checkbox(&mut pipeline.line_strip_cr, "Strip CR"); ui.horizontal(|ui| { ui.label("Max line length:"); ui.add(DragValue::new(&mut pipeline.line_max_line_len).range(1..=16_777_216)); }); } FramerChoice::Fixed => { ui.horizontal(|ui| { ui.label("Frame length:"); ui.add(DragValue::new(&mut pipeline.fixed_frame_len).range(1..=16_777_216)); }); } FramerChoice::Length => { ui.horizontal(|ui| { ui.label("Length bytes:"); ComboBox::from_id_salt(format!("length_len_bytes_{index}")) .selected_text(pipeline.length_len_bytes.to_string()) .show_ui(ui, |ui| { ui.selectable_value(&mut pipeline.length_len_bytes, 1, "1"); ui.selectable_value(&mut pipeline.length_len_bytes, 2, "2"); ui.selectable_value(&mut pipeline.length_len_bytes, 4, "4"); }); }); ui.horizontal(|ui| { ui.label("Length endian:"); render_endian_combo( ui, &mut pipeline.length_endian, format!("length_endian_{index}"), ); }); ui.checkbox( &mut pipeline.length_includes_self, "Length field includes header bytes", ); ui.horizontal(|ui| { ui.label("Max payload:"); ui.add(DragValue::new(&mut pipeline.length_max_payload).range(0..=16_777_216)); }); } FramerChoice::Cobs => { ui.horizontal(|ui| { ui.label("Max frame:"); ui.add(DragValue::new(&mut pipeline.cobs_max_frame).range(1..=16_777_216)); }); } FramerChoice::MixedTextPlot => { ui.checkbox(&mut pipeline.mixed_strip_cr, "Strip CR"); ui.horizontal(|ui| { ui.label("Max text line:"); ui.add(DragValue::new(&mut pipeline.mixed_max_line_len).range(1..=16_777_216)); }); ui.horizontal(|ui| { ui.label("Max plot frame:"); ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216)); }); } FramerChoice::Lua => { ui.horizontal(|ui| { ui.label("Script:"); ui.add( TextEdit::singleline(&mut pipeline.lua_framer_script_path).desired_width(260.0), ); }); } } } fn render_decoder_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usize) { ui.separator(); ui.label("Decoder settings"); match pipeline.decoder { DecoderChoice::Text => { ui.horizontal(|ui| { ui.label("Text encoding:"); render_text_encoding_combo( ui, &mut pipeline.text_encoding, format!("text_encoding_{index}"), ); }); } DecoderChoice::Hex => { ui.checkbox(&mut pipeline.hex_uppercase, "Uppercase output"); ui.horizontal(|ui| { ui.label("Separator:"); ui.text_edit_singleline(&mut pipeline.hex_separator); }); ui.horizontal(|ui| { ui.label("Bytes per group:"); ui.add(DragValue::new(&mut pipeline.hex_bytes_per_group).range(1..=32)); }); ui.horizontal(|ui| { ui.label("Hex endian:"); render_endian_combo(ui, &mut pipeline.hex_endian, format!("hex_endian_{index}")); }); } DecoderChoice::Plot => { ui.horizontal(|ui| { ui.label("Sample type:"); render_sample_type_combo( ui, &mut pipeline.plot_sample_type, format!("plot_sample_type_{index}"), ); }); ui.horizontal(|ui| { ui.label("Plot endian:"); render_endian_combo( ui, &mut pipeline.plot_endian, format!("plot_endian_{index}"), ); }); ui.horizontal(|ui| { ui.label("Channels:"); ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=64)); }); ui.horizontal(|ui| { ui.label("Plot format:"); render_plot_format_combo( ui, &mut pipeline.plot_format, format!("plot_format_{index}"), ); }); if matches!(pipeline.plot_format, PlotFormat::XY) { pipeline.plot_channels = 2; } } DecoderChoice::MixedTextPlot => { ui.horizontal(|ui| { ui.label("Mixed text encoding:"); render_text_encoding_combo( ui, &mut pipeline.text_encoding, format!("mixed_text_encoding_{index}"), ); }); ui.small( "Plot sample type, endian, format, and channels come from the mixed packet header.", ); } DecoderChoice::Lua => { ui.horizontal(|ui| { ui.label("Script:"); ui.add( TextEdit::singleline(&mut pipeline.lua_decoder_script_path) .desired_width(260.0), ); }); } } } fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec) { let mut push_error = |message: String| { ui.colored_label(egui::Color32::RED, &message); errors.push(format!("pipeline '{}': {}", pipeline.name, message)); }; if matches!(pipeline.framer, FramerChoice::MixedTextPlot) && !matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) { push_error(String::from( "MixedTextPlot framer requires MixedTextPlot decoder", )); } if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) && !matches!(pipeline.framer, FramerChoice::MixedTextPlot) { push_error(String::from( "MixedTextPlot decoder requires MixedTextPlot framer", )); } if matches!(pipeline.decoder, DecoderChoice::Plot) { if matches!( pipeline.framer, FramerChoice::Line | FramerChoice::MixedTextPlot ) { push_error(String::from( "Raw Plot decoder requires Fixed, Length, or Cobs framing", )); } if matches!(pipeline.plot_format, PlotFormat::XY) && pipeline.plot_channels != 2 { push_error(String::from("XY plot requires exactly 2 channels")); } if matches!(pipeline.framer, FramerChoice::Fixed) { let channel_count = if matches!(pipeline.plot_format, PlotFormat::XY) { 2 } else { pipeline.plot_channels.max(1) }; let sample_bytes = pipeline.plot_sample_type.byte_size(); let frame_unit = channel_count.saturating_mul(sample_bytes); if frame_unit == 0 || !pipeline.fixed_frame_len.is_multiple_of(frame_unit) { push_error(format!( "Fixed frame length must be a multiple of {} bytes for the current plot layout", frame_unit )); } } } if matches!(pipeline.framer, FramerChoice::Length) && !matches!(pipeline.length_len_bytes, 1 | 2 | 4) { push_error(String::from("Length framer len_bytes must be 1, 2, or 4")); } if matches!(pipeline.framer, FramerChoice::Lua) && pipeline.lua_framer_script_path.trim().is_empty() { push_error(String::from("Lua framer script path is required")); } if matches!(pipeline.decoder, DecoderChoice::Lua) && pipeline.lua_decoder_script_path.trim().is_empty() { push_error(String::from("Lua decoder script path is required")); } } fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) { ComboBox::from_id_salt(id) .selected_text(match encoding { TextEncoding::Utf8 => "UTF-8", TextEncoding::Latin1 => "Latin1", TextEncoding::Ascii => "ASCII", }) .show_ui(ui, |ui| { ui.selectable_value(encoding, TextEncoding::Utf8, "UTF-8"); ui.selectable_value(encoding, TextEncoding::Latin1, "Latin1"); ui.selectable_value(encoding, TextEncoding::Ascii, "ASCII"); }); } fn render_endian_combo(ui: &mut Ui, endian: &mut Endian, id: impl std::hash::Hash) { ComboBox::from_id_salt(id) .selected_text(match endian { Endian::Little => "Little", Endian::Big => "Big", }) .show_ui(ui, |ui| { ui.selectable_value(endian, Endian::Little, "Little"); ui.selectable_value(endian, Endian::Big, "Big"); }); } fn render_sample_type_combo(ui: &mut Ui, sample_type: &mut SampleType, id: impl std::hash::Hash) { ComboBox::from_id_salt(id) .selected_text(sample_type.name()) .show_ui(ui, |ui| { for option in [ SampleType::I8, SampleType::U8, SampleType::I16, SampleType::U16, SampleType::I32, SampleType::U32, SampleType::I64, SampleType::U64, SampleType::F32, SampleType::F64, ] { ui.selectable_value(sample_type, option, option.name()); } }); } fn render_plot_format_combo(ui: &mut Ui, plot_format: &mut PlotFormat, id: impl std::hash::Hash) { ComboBox::from_id_salt(id) .selected_text(match plot_format { PlotFormat::Interleaved => "Interleaved", PlotFormat::Block => "Block", PlotFormat::XY => "XY", }) .show_ui(ui, |ui| { ui.selectable_value(plot_format, PlotFormat::Interleaved, "Interleaved"); ui.selectable_value(plot_format, PlotFormat::Block, "Block"); ui.selectable_value(plot_format, PlotFormat::XY, "XY"); }); } #[cfg(test)] mod tests { use super::*; #[test] fn config_form_roundtrip_preserves_expanded_pipeline_settings() { let session = SessionConfig { transport: TransportConfig::Tcp { addr: String::from("127.0.0.1:9001"), }, pipelines: vec![ PipelineConfig { name: String::from("hex"), framer: FramerConfig::Length { len_bytes: 4, endian: Endian::Little, length_includes_self: true, max_payload: 8192, }, decoder: DecoderConfig::Hex { uppercase: true, separator: String::from(":"), bytes_per_group: 2, endian: Endian::Little, }, }, PipelineConfig { name: String::from("plot"), framer: FramerConfig::Cobs { max_frame: 4096 }, decoder: DecoderConfig::Plot { sample_type: SampleType::I16, endian: Endian::Big, channels: 3, format: PlotFormat::Block, }, }, PipelineConfig { name: String::from("lua"), framer: FramerConfig::Lua { script_path: String::from("/tmp/framer.lua"), }, decoder: DecoderConfig::Lua { script_path: String::from("/tmp/decoder.lua"), }, }, ], history_limit: 4096, auto_reconnect: true, }; let form = ConfigForm::from_session_config(&session); let rebuilt = form.to_session_config(); assert_eq!( serde_json::to_string(&rebuilt).unwrap(), serde_json::to_string(&session).unwrap() ); } #[test] fn selected_serial_port_text_prefers_current_value() { let ports = vec![String::from("/dev/ttyUSB0")]; assert_eq!( selected_serial_port_text("/dev/ttyUSB1", &ports), "/dev/ttyUSB1" ); } #[test] fn selected_serial_port_text_uses_placeholder_when_empty() { assert_eq!(selected_serial_port_text("", &[]), "No serial ports found"); assert_eq!( selected_serial_port_text("", &[String::from("/dev/ttyUSB0")]), "Select port" ); } }