diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9893870 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +doc-screenshot = "run --bin infuse_decoder -- --docs-screenshot" diff --git a/CHANGELOG.md b/CHANGELOG.md index 018c05d..d27defc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org). +## [1.11.0] - 2026-06-xx + + - Output file list is now scrollable + - Folder search improvements + - In addition to the SD card file naming convention, folders with one file per device are now supported + - Each file must have the 16 character hex ID somewhere in the filename + - Fix decoding crash when no files are found + ## [1.10.0] - 2026-06-25 - Nested TDF definitions inherit the parent field name in the CSV header column diff --git a/Cargo.toml b/Cargo.toml index ba80f87..bce52f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,5 +32,8 @@ egui_extras = "0.34.1" directories = "6.0.0" image = "0.25.9" +[build-dependencies] +chrono = "0.4.42" + [target.'cfg(windows)'.dependencies] winapi = { version = "*", features = ["winbase"] } diff --git a/README.md b/README.md index 8d5c45c..f8dd36f 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,21 @@ The application provides options to control the input selection and output gener ### 1) Output Folder -This field controls where the output data will be placed after decoding. It defaults to $USER_HOME/infuse_iot. The output folder can be updated by clicking `Folder` (1a). The output folder can be opened -in the default system viewer by clicking `Open` (1b). +This field controls where the output data will be placed after decoding. It defaults to `$USER_HOME/infuse_iot`. +The output folder can be updated by clicking the `Folder` button. +The output folder can be opened in the default system viewer by clicking the `Open` button. ### 2) Input folder/file -This field specifies the source of the input data. This can be either a SD card filesystem (2a) or a previously output binary file (2b). +This field specifies the source of the input data. +This can be either a single binary file by clicking the `File` button, or a folder containing many files with the `Folder` button. +If selecting a folder, there are two expected forms: -When an SD card is inserted into the computer, it will appear as a removable disk named INFUSE. This is the folder to select from button 2a. + a) An SD card filesystem, with files of the form `infuse_{device_id}_{subfile_num}.bin` + + b) A folder containing one binary file per device, where each file has the `{device_id}` somewhere in the name. + +When an SD card is inserted into the computer, it will appear as a removable disk named INFUSE. This is the folder to select from the `Folder` button. ### 3) Device ID @@ -38,7 +45,9 @@ This field controls the prefix of the output filenames, created in the output fo Save the output as either Comma Separated Value (CSV) or [Apache Parquet](https://parquet.apache.org/) files. Decoding to Parquet is faster and the resulting files are smaller, but the results are not human readable. -### 6) Linearize Output +### 6) Output Format Options + +#### Linearize Output To optimize processing times, the decoding work is split across multiple CPU cores to intermediate files, with a post-processing step pulling the intermediate results back into a single file. @@ -46,23 +55,23 @@ If a single output file is not required, disabling this skips the merging step, If disabled, the final files have a numeric postfix (e.g. `test_BATTERY_STATE_00000.csv`) which indicates the order from the original binary file. -### 7) Maximum Readings per File +#### Maximum Readings per File If the Linearize Output step is enabled, the output data can be split into multiple files based on the number of rows in each file. This can be useful to limit individual files sizes or optimize data loading. The default value of 0 means no limit. -### 8) Time Output Format +### 7) Time Output Format This option controls the output format of the timestamps written into the output CSV files. The two options are a [RFC3339](https://www.rfc-editor.org/rfc/rfc3339) formatted string (for example 2024-06-27T13:55:12.123456Z), or a Unix timestamp with subseconds (for example 1731457165.123456). The RFC3339 option is recommended if the CSV outputs will be looked at by users, as it is a more human-readable format. By comparison, the Unix timestamps are simpler for data processing scripts to parse, and are faster for the decoder tool to generate. -### 9) Input Block Size +### 8) Input Block Size Specifies the data block size of the input binary data. The default value of `512` should be used unless instructions to the contrary are provided. -### 10) Decode +### 9) Decode Once an input file or folder has been selected, the decode button becomes available to select. Clicking this button begins the decode process with the currently selected options. The button is unavailable to select again until the previous decode has completed. ## Decoding Process diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..58c047e --- /dev/null +++ b/assets/README.md @@ -0,0 +1,3 @@ +# Configuration Options + +The configuration options screenshot is autogenerated with `cargo doc-screenshot`. diff --git a/assets/configuration_options.png b/assets/configuration_options.png index 0ae3789..9aa888b 100644 Binary files a/assets/configuration_options.png and b/assets/configuration_options.png differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..bab8933 --- /dev/null +++ b/build.rs @@ -0,0 +1,8 @@ +use chrono::{Datelike, Utc}; + +fn main() { + println!( + "cargo:rustc-env=INFUSE_DECODER_BUILD_YEAR={}", + Utc::now().year() + ); +} diff --git a/src/fs_util.rs b/src/fs_util.rs index ebc1518..4019b0f 100644 --- a/src/fs_util.rs +++ b/src/fs_util.rs @@ -27,5 +27,105 @@ pub fn find_infuse_iot_files(dir: &PathBuf) -> io::Result PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("infuse_decoder_{name}_{nanos}")); + fs::create_dir(&dir).unwrap(); + dir + } + + fn touch(dir: &PathBuf, file_name: &str) { + File::create(dir.join(file_name)).unwrap(); + } + + #[test] + fn finds_current_infuse_iot_pattern() { + let dir = temp_dir("current_pattern"); + touch(&dir, "infuse_0123456789abcdef_0.bin"); + touch(&dir, "infuse_0123456789abcdef_1.bin"); + touch(&dir, "capture_fedcba9876543210.bin"); + + let files = find_infuse_iot_files(&dir).unwrap(); + + assert_eq!(files.len(), 1); + assert_eq!(files.get(&0x0123_4567_89ab_cdef).unwrap().len(), 2); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn falls_back_to_standalone_hex_id_bin_files() { + let dir = temp_dir("fallback"); + touch(&dir, "capture_0123456789abcdef.bin"); + touch(&dir, "fedcba9876543210.bin"); + touch(&dir, "ignored_00123456789abcdef.bin"); + + let files = find_infuse_iot_files(&dir).unwrap(); + + assert_eq!(files.len(), 2); + assert!(files.contains_key(&0x0123_4567_89ab_cdef)); + assert!(files.contains_key(&0xfedc_ba98_7654_3210)); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn fallback_errors_on_duplicate_device_ids() { + let dir = temp_dir("fallback_duplicate"); + touch(&dir, "capture_0123456789abcdef.bin"); + touch(&dir, "backup_0123456789abcdef.bin"); + + let err = find_infuse_iot_files(&dir).unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("0123456789abcdef")); + + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/main_gui.rs b/src/main_gui.rs index d12a230..9a0aff5 100644 --- a/src/main_gui.rs +++ b/src/main_gui.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::env; +use std::io; use std::sync::{Arc, Mutex}; use std::thread; use std::{collections::HashMap, path::PathBuf}; @@ -55,6 +56,16 @@ impl SliderState { let progress_bar = egui::ProgressBar::new(progress).show_percentage(); ui.add_enabled(s.enabled, progress_bar); } + + pub fn draw_count(self: &mut Self, ui: &mut egui::Ui) { + ui.label(self.label); + + let s = self.state.lock().unwrap(); + let progress = s.current as f32 / s.total as f32; + let progress_bar = + egui::ProgressBar::new(progress).text(format!("{} / {}", s.current, s.total)); + ui.add_enabled(s.enabled, progress_bar); + } } impl infuse_decoder::ProgressReporter for SliderState { @@ -78,9 +89,11 @@ impl infuse_decoder::ProgressReporter for SliderState { } struct MyApp { + doc_capture: Option, time_mode: TimeOutput, output_format: OutputFormat, linearize_output_files: bool, + decode_all_devices: bool, device_id: u64, block_size: BlockSizeOptions, max_readings_per_output_file: usize, @@ -90,6 +103,7 @@ struct MyApp { output_folder: PathBuf, output_prefix: String, progress_copy: SliderState, + progress_devices: SliderState, progress_decode: SliderState, progress_merge: SliderState, block_stats: Option>, @@ -108,8 +122,35 @@ struct MyApp { >, >, } + +struct DocCapture { + markers: Vec, + screenshot_requested: bool, + output_path: PathBuf, +} + +struct DocMarker { + label: &'static str, + rect: egui::Rect, +} + +impl DocCapture { + fn new() -> Self { + Self { + markers: Vec::new(), + screenshot_requested: false, + output_path: PathBuf::from("assets/configuration_options.png"), + } + } + + fn reset_markers(&mut self) { + self.markers.clear(); + } +} use directories::UserDirs; +const DOC_MARKER_GUTTER: f32 = 20.0; + impl Default for MyApp { fn default() -> Self { let mut default_out = if let Some(user_dirs) = UserDirs::new() { @@ -125,20 +166,40 @@ impl Default for MyApp { if default_out.is_none() { default_out = Some(PathBuf::from(".")); } + let doc_capture = doc_capture_enabled().then(DocCapture::new); + + let input_path = doc_capture.as_ref().map(|_| PathBuf::from("E:\\INFUSE")); + let mut input_files = None; + let mut device_id = 0; + let mut output_prefix = String::from(""); + + if doc_capture.is_some() { + device_id = 0x0000_0000_5aa5_f00d; + output_prefix = format!("{device_id:016x}"); + let mut files = HashMap::new(); + files.insert( + device_id, + vec![PathBuf::from("E:\\INFUSE\\infuse_cc0000000000000a.bin")], + ); + input_files = Some(files); + } Self { + doc_capture, time_mode: TimeOutput::UTC, output_format: OutputFormat::CSV, linearize_output_files: true, - device_id: 0, + decode_all_devices: false, + device_id, block_size: BlockSizeOptions::B512, max_readings_per_output_file: infuse_decoder::DEFAULT_MAX_READINGS_PER_OUTPUT_FILE, error_msg: None, - input_path: None, - input_files: None, + input_path, + input_files, output_folder: default_out.unwrap(), - output_prefix: String::from(""), + output_prefix, progress_copy: SliderState::new("Copying files"), + progress_devices: SliderState::new("Devices decoded"), progress_decode: SliderState::new("Decoding files"), progress_merge: SliderState::new("Merging output"), block_stats: None, @@ -149,6 +210,97 @@ impl Default for MyApp { } } +fn doc_capture_enabled() -> bool { + env::var_os("INFUSE_DECODER_DOC_SCREENSHOT").is_some() + || env::args().any(|arg| arg == "--docs-screenshot") +} + +impl MyApp { + fn is_doc_capture(&self) -> bool { + self.doc_capture.is_some() + } + + fn mark_doc(&mut self, label: &'static str, rect: egui::Rect) { + if let Some(doc_capture) = &mut self.doc_capture { + doc_capture.markers.push(DocMarker { label, rect }); + } + } + + fn handle_doc_capture(&mut self, ctx: &egui::Context) { + let Some(doc_capture) = &mut self.doc_capture else { + return; + }; + + let mut screenshot = None; + ctx.input(|input| { + for event in &input.events { + if let egui::Event::Screenshot { image, .. } = event { + screenshot = Some(Arc::clone(image)); + } + } + }); + + if let Some(image) = screenshot { + if let Err(err) = save_color_image(&doc_capture.output_path, &image) { + eprintln!( + "Failed to save documentation screenshot to {}: {err}", + doc_capture.output_path.display() + ); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("doc_markers"), + )); + + for marker in &doc_capture.markers { + draw_doc_marker(&painter, marker); + } + + if !doc_capture.screenshot_requested { + doc_capture.screenshot_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + ctx.request_repaint(); + } + } +} + +fn draw_doc_marker(painter: &egui::Painter, marker: &DocMarker) { + let radius = 12.0; + let position = marker.rect.left_center() + egui::vec2(-14.0, 0.0); + painter.circle_filled(position, radius, egui::Color32::from_rgb(0, 0x89, 0x47)); + painter.circle_stroke( + position, + radius, + egui::Stroke::new(1.5, egui::Color32::WHITE), + ); + painter.text( + position, + egui::Align2::CENTER_CENTER, + marker.label, + egui::FontId::proportional(13.0), + egui::Color32::WHITE, + ); +} + +fn save_color_image(path: &std::path::Path, image: &egui::ColorImage) -> image::ImageResult<()> { + let mut rgba = Vec::with_capacity(image.pixels.len() * 4); + for pixel in &image.pixels { + rgba.extend_from_slice(&[pixel.r(), pixel.g(), pixel.b(), pixel.a()]); + } + + image::save_buffer( + path, + &rgba, + image.size[0] as u32, + image.size[1] as u32, + image::ColorType::Rgba8, + ) +} + fn trimmed_label(label: &String, max_len: usize) -> String { if label.len() > max_len { let idx = label.len() - (max_len - 3); @@ -198,10 +350,12 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { .num_columns(2) .show(ui, |ui| { let folder_str = app.output_folder.display().to_string(); - ui.label("Output folder"); + let output_folder_label = ui.label("Output folder"); + app.mark_doc("1", output_folder_label.rect); ui.label(egui::RichText::new(trimmed_label(&folder_str, 48)).code()); ui.horizontal(|ui| { - if ui.button("Folder").clicked() { + let folder_button = ui.button("Folder"); + if folder_button.clicked() { if let Some(folder) = FileDialog::new() .set_directory(app.output_folder.as_path()) .pick_folder() @@ -209,7 +363,8 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { app.output_folder = folder; } } - if ui.button("Open").clicked() { + let open_button = ui.button("Open"); + if open_button.clicked() { let _ = open_in_native_browser(app.output_folder.as_path()); }; }); @@ -220,11 +375,13 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { None => String::from("N/A"), }; - ui.label("Input folder/file"); + let input_label = ui.label("Input folder/file"); + app.mark_doc("2", input_label.rect); ui.label(egui::RichText::new(trimmed_label(&folder_str, 48)).code()); ui.horizontal(|ui| { - if ui.button("Folder").clicked() { + let folder_button = ui.button("Folder"); + if folder_button.clicked() { if let Some(folder) = FileDialog::new().pick_folder() { match infuse_decoder::fs_util::find_infuse_iot_files(&folder) { Ok(files) => { @@ -232,12 +389,15 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { app.output_prefix = format!("{:016x}", app.device_id); app.input_path = Some(folder); app.input_files = Some(files); + // Reset the 'decode all' option when the folder changes + app.decode_all_devices = false; } _ => {} } } } - if ui.button("File").clicked() { + let file_button = ui.button("File"); + if file_button.clicked() { if let Some(file) = FileDialog::new().pick_file() { let mut h = HashMap::new(); h.insert(0, vec![file.clone()]); @@ -247,6 +407,7 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { }; app.device_id = 0; + app.decode_all_devices = false; app.output_prefix = prefix.to_string(); app.input_path = Some(file); app.input_files = Some(h); @@ -257,39 +418,59 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { // Clear the selected paths if they no longer exist (SD card removed) if let Some(input) = &app.input_path { - if !input.exists() { + if !app.is_doc_capture() && !input.exists() { app.input_path = None; app.input_files = None; } } - ui.label("Device ID"); - if let Some(file_list) = &app.input_files { - ui.add_enabled_ui(file_list.len() > 1, |ui| { - egui::ComboBox::from_label("") - .selected_text(format!("{:016x}", app.device_id)) - .show_ui(ui, |ui| { - for id in file_list.keys() { - ui.selectable_value( - &mut app.device_id, - *id, - format!("{:016x}", id), - ); - } - }); - }); - } + let device_label = ui.label("Device ID"); + app.mark_doc("3", device_label.rect); + ui.horizontal(|ui| { + if let Some(file_list) = &app.input_files { + ui.add_enabled_ui(file_list.len() > 1 && !app.decode_all_devices, |ui| { + egui::ComboBox::from_label("") + .selected_text(format!("{:016x}", app.device_id)) + .show_ui(ui, |ui| { + for id in file_list.keys() { + ui.selectable_value( + &mut app.device_id, + *id, + format!("{:016x}", id), + ); + } + }); + }); + ui.add_enabled_ui(file_list.len() > 1, |ui| { + ui.checkbox(&mut app.decode_all_devices, "All"); + }); + } else { + ui.add_enabled( + false, + egui::Checkbox::new(&mut app.decode_all_devices, "All"), + ); + } + }); ui.end_row(); - ui.label("Output Prefix"); + let prefix_label = ui.label("Output Prefix"); + app.mark_doc("4", prefix_label.rect); ui.text_edit_singleline(&mut app.output_prefix); let extension = match app.output_format { OutputFormat::CSV => "csv", OutputFormat::PARQUET => "parquet", }; + let num_devices = app.input_files.as_ref().map_or(1, HashMap::len); + let example_prefix = output_prefix_for_device( + &app.output_prefix, + app.device_id, + app.device_id, + num_devices, + app.decode_all_devices, + ); ui.label(format!( "(e.g. {}_BATTERY_STATE.{extension})", - app.output_prefix + example_prefix )); ui.end_row(); }); @@ -297,14 +478,19 @@ fn core_options(app: &mut MyApp, ui: &mut egui::Ui) { fn decode_options(app: &mut MyApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { + if app.is_doc_capture() { + ui.add_space(DOC_MARKER_GUTTER); + } ui.vertical(|ui| { - ui.label("Output Format"); + let output_format_label = ui.label("Output Format"); + app.mark_doc("5", output_format_label.rect); ui.radio_value(&mut app.output_format, OutputFormat::CSV, "CSV"); ui.radio_value(&mut app.output_format, OutputFormat::PARQUET, "Parquet"); }); ui.separator(); ui.vertical(|ui| { - ui.label("File Output Control"); + let file_output_control = ui.label("File Output Control"); + app.mark_doc("6", file_output_control.rect); ui.checkbox(&mut app.linearize_output_files, "Linearize Output"); ui.label("Max Readings Per File"); ui.add_enabled_ui(app.linearize_output_files, |ui| { @@ -317,7 +503,8 @@ fn decode_options(app: &mut MyApp, ui: &mut egui::Ui) { }); ui.separator(); ui.vertical(|ui| { - ui.label("Time Output Format"); + let time_format_label = ui.label("Time Output Format"); + app.mark_doc("7", time_format_label.rect); ui.add_enabled_ui(app.output_format == OutputFormat::CSV, |ui| { ui.radio_value( &mut app.time_mode, @@ -333,7 +520,8 @@ fn decode_options(app: &mut MyApp, ui: &mut egui::Ui) { }); ui.separator(); ui.vertical(|ui| { - ui.label("Input Block Size"); + let block_size_label = ui.label("Input Block Size"); + app.mark_doc("8", block_size_label.rect); egui::ComboBox::from_id_salt("Block Size") .selected_text(format!("{:}", app.block_size)) .show_ui(ui, |ui| { @@ -345,53 +533,157 @@ fn decode_options(app: &mut MyApp, ui: &mut egui::Ui) { }); } +fn output_prefix_for_device( + base_prefix: &str, + selected_device_id: u64, + device_id: u64, + num_devices: usize, + decode_all_devices: bool, +) -> String { + if decode_all_devices && num_devices > 1 { + let selected_device_prefix = format!("{:016x}", selected_device_id); + if base_prefix.is_empty() || base_prefix == selected_device_prefix { + format!("{:016x}", device_id) + } else { + format!("{base_prefix}_{device_id:016x}") + } + } else { + base_prefix.to_string() + } +} + +fn merge_block_stats( + combined: &mut HashMap, + stats: HashMap, +) { + for (block_type, count) in stats { + *combined.entry(block_type).or_default() += count; + } +} + +fn merge_tdf_stats( + combined: &mut HashMap, HashMap>, + stats: HashMap, HashMap>, +) { + for (remote_id, tdfs) in stats { + let combined_tdfs = combined.entry(remote_id).or_default(); + for (tdf_id, count) in tdfs { + *combined_tdfs.entry(tdf_id).or_default() += count; + } + } +} + fn start_button(app: &mut MyApp, ui: &mut egui::Ui) { let start_button = egui::Button::new("DECODE") .fill(egui::Color32::from_rgb(0, 0x89, 0x47)) .min_size((100.0, ui.available_height()).into()); ui.add_space(ui.available_width() - 100.0); - if ui + let response = ui .add_enabled( app.runner_thread.is_none() && app.input_path.is_some(), start_button, ) - .clicked() - { + .on_hover_text("Decode"); + app.mark_doc("9", response.rect); + if response.clicked() { // Reset progress bars app.progress_copy.reset(); + app.progress_devices.reset(); app.progress_decode.reset(); app.progress_merge.reset(); app.block_stats = None; app.tdf_stats = None; app.output_files = None; - let p = app.input_path.as_ref().unwrap(); - let files = if p.is_dir() { + let input_path = app.input_path.as_ref().unwrap(); + let device_jobs = if input_path.is_dir() { let iot_bin_files: HashMap> = - infuse_decoder::fs_util::find_infuse_iot_files(&app.input_path.as_ref().unwrap()) - .unwrap(); - iot_bin_files.get(&app.device_id).unwrap().clone() - } else { - vec![p.clone()] - }; + infuse_decoder::fs_util::find_infuse_iot_files(input_path).unwrap(); + + if iot_bin_files.is_empty() { + let input_folder = input_path.display().to_string(); + app.runner_thread = Some(thread::spawn(move || { + return std::result::Result::Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("No valid files found in '{}'", input_folder), + )); + })); + return; + } - let mut run_args = infuse_decoder::RunArgs { - device_id: app.device_id, - block_size: app.block_size as usize, - input_files: files, - output_folder: app.output_folder.clone(), - output_prefix: app.output_prefix.clone(), - output_unix_time: app.time_mode == TimeOutput::UNIX, - output_format: app.output_format, - merge_output_files: app.linearize_output_files, - max_readings_per_output_file: app.max_readings_per_output_file, - copy_reporter: app.progress_copy.clone(), - decode_reporter: app.progress_decode.clone(), - merge_reporter: app.progress_merge.clone(), + let mut jobs: Vec<(u64, Vec)> = if app.decode_all_devices { + iot_bin_files.into_iter().collect() + } else { + match iot_bin_files.get(&app.device_id) { + Some(files) => vec![(app.device_id, files.clone())], + None => { + let device_id = app.device_id; + app.runner_thread = Some(thread::spawn(move || { + return std::result::Result::Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("No files found for device ID {device_id:016x}"), + )); + })); + return; + } + } + }; + jobs.sort_by_key(|(device_id, _)| *device_id); + jobs + } else { + vec![(app.device_id, vec![input_path.clone()])] }; + let num_devices = device_jobs.len(); + infuse_decoder::ProgressReporter::start( + &mut app.progress_devices, + "Devices decoded", + num_devices, + ); + let run_args = device_jobs + .into_iter() + .map(|(device_id, input_files)| infuse_decoder::RunArgs { + device_id, + block_size: app.block_size as usize, + input_files, + output_folder: app.output_folder.clone(), + output_prefix: output_prefix_for_device( + &app.output_prefix, + app.device_id, + device_id, + num_devices, + app.decode_all_devices, + ), + output_unix_time: app.time_mode == TimeOutput::UNIX, + output_format: app.output_format, + merge_output_files: app.linearize_output_files, + max_readings_per_output_file: app.max_readings_per_output_file, + copy_reporter: app.progress_copy.clone(), + decode_reporter: app.progress_decode.clone(), + merge_reporter: app.progress_merge.clone(), + }) + .collect::>(); + let mut device_reporter = app.progress_devices.clone(); + + app.runner_thread = Some(thread::spawn(move || { + let mut combined_block_stats = HashMap::new(); + let mut combined_tdf_stats = HashMap::new(); + let mut combined_output_files = Vec::new(); + + for mut run_args in run_args { + let (block_stats, tdf_stats, mut output_files) = + infuse_decoder::run(&mut run_args)?; + merge_block_stats(&mut combined_block_stats, block_stats); + merge_tdf_stats(&mut combined_tdf_stats, tdf_stats); + combined_output_files.append(&mut output_files); + infuse_decoder::ProgressReporter::increment(&mut device_reporter, 1); + } - // Spawn the thread to run the decode process - app.runner_thread = Some(thread::spawn(move || infuse_decoder::run(&mut run_args))); + Ok::<_, io::Error>(( + combined_block_stats, + combined_tdf_stats, + combined_output_files, + )) + })); }; } @@ -403,7 +695,8 @@ fn copyright_bar(ui: &mut egui::Ui) { ui.label(concat!( "v", env!("CARGO_PKG_VERSION"), - " © Embeint Inc 2024" + " © Embeint Inc 2024-", + env!("INFUSE_DECODER_BUILD_YEAR") )); }); @@ -532,30 +825,22 @@ fn gui_stats(app: &mut MyApp, ui: &mut egui::Ui) { }); col_files.push_id(2, |ui| { - TableBuilder::new(ui) + ui.heading("Output Files"); + + let scroll_height = (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0); + egui::ScrollArea::vertical() .id_salt("OutputFiles") - .striped(true) - .column(Column::remainder()) - .header(5.0, |mut header| { - header.col(|ui| { - ui.heading("Output Files"); - }); - }) - .body(|mut body| { + .auto_shrink([false, false]) + .max_height(scroll_height) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(files) = app.output_files.as_ref() { - for file in files.iter() { - body.row(5.0, |mut row| { - row.col(|ui| { - let name = format!( - "{}", - file.file_name().unwrap().to_str().unwrap() - ); - ui.add( - egui::Label::new(name) - .wrap_mode(egui::TextWrapMode::Truncate), - ); - }); - }); + for file in files { + let name = file.file_name().unwrap().to_str().unwrap(); + ui.add( + egui::Label::new(name).wrap_mode(egui::TextWrapMode::Truncate), + ); } } }); @@ -566,6 +851,10 @@ fn gui_stats(app: &mut MyApp, ui: &mut egui::Ui) { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + if let Some(doc_capture) = &mut self.doc_capture { + doc_capture.reset_markers(); + } + // Check if executing work has completed if let Some(handle) = self.runner_thread.as_ref() { if handle.is_finished() { @@ -579,7 +868,11 @@ impl eframe::App for MyApp { self.output_files = Some(files); } Err(e) => { - self.error_msg = Some(format!("{e:?}")); + self.error_msg = Some(if e.kind() == std::io::ErrorKind::NotFound { + e.to_string() + } else { + format!("{e:?}") + }); } } } @@ -604,6 +897,9 @@ impl eframe::App for MyApp { egui::Panel::top("top_panel").show_inside(ui, |ui| { ui.horizontal(|ui| { + if self.is_doc_capture() { + ui.add_space(DOC_MARKER_GUTTER); + } core_options(self, ui); start_button(self, ui); }); @@ -622,6 +918,10 @@ impl eframe::App for MyApp { .show(ui, |ui| { self.progress_copy.draw(ui); ui.end_row(); + if self.decode_all_devices { + self.progress_devices.draw_count(ui); + ui.end_row(); + } self.progress_decode.draw(ui); ui.end_row(); self.progress_merge.draw(ui); @@ -630,6 +930,7 @@ impl eframe::App for MyApp { ui.add_space(5.0); }); gui_stats(self, ui); + self.handle_doc_capture(ui.ctx()); } } @@ -648,9 +949,13 @@ fn load_icon() -> IconData { fn main() -> eframe::Result { let icon = load_icon(); + let mut viewport = egui::viewport::ViewportBuilder::default().with_icon(icon); + if doc_capture_enabled() { + viewport = viewport.with_inner_size([1160.0, 680.0]); + } let options = eframe::NativeOptions { - viewport: egui::viewport::ViewportBuilder::default().with_icon(icon), + viewport, ..Default::default() };