diff --git a/examples/csv_export.rs b/examples/csv_export.rs index a8b8152..d83334e 100644 --- a/examples/csv_export.rs +++ b/examples/csv_export.rs @@ -54,7 +54,7 @@ fn main() -> anyhow::Result<()> { // Export to CSV println!("\nExporting to CSV..."); - export_to_csv(&log, Path::new(&input_file), &export_opts)?; + export_to_csv(&log, Path::new(&input_file), &export_opts, None)?; println!("✓ CSV export complete"); Ok(()) diff --git a/examples/event_export.rs b/examples/event_export.rs index 053fad2..b0b6c17 100644 --- a/examples/event_export.rs +++ b/examples/event_export.rs @@ -47,6 +47,7 @@ fn main() -> anyhow::Result<()> { 1, // total_logs (assuming single log for this example) &log.event_frames, &export_opts, + None, )?; println!("✓ Event export complete"); println!(" Exported {} events", log.event_frames.len()); diff --git a/examples/export_demo.rs b/examples/export_demo.rs index 05dfed1..a7afdf3 100644 --- a/examples/export_demo.rs +++ b/examples/export_demo.rs @@ -84,7 +84,7 @@ fn main() -> Result<()> { // Export CSV println!("=== Exporting Data ==="); println!("Exporting CSV files..."); - export_to_csv(&log, input_path, &export_opts)?; + export_to_csv(&log, input_path, &export_opts, None)?; println!("✓ CSV export complete"); // Compute log index once (log_number is 1-based) @@ -109,6 +109,7 @@ fn main() -> Result<()> { &log.home_coordinates, &export_opts, log.header.log_start_datetime.as_deref(), + None, )?; println!("✓ GPX export complete"); } else { @@ -127,6 +128,7 @@ fn main() -> Result<()> { log.total_logs, &log.event_frames, &export_opts, + None, )?; println!("✓ Event export complete"); diff --git a/examples/gpx_export.rs b/examples/gpx_export.rs index cfae586..ae44831 100644 --- a/examples/gpx_export.rs +++ b/examples/gpx_export.rs @@ -48,6 +48,7 @@ fn main() -> anyhow::Result<()> { &log.home_coordinates, &export_opts, log.header.log_start_datetime.as_deref(), + None, )?; println!("✓ GPX export complete"); println!(" Exported {} GPS coordinates", log.gps_coordinates.len()); diff --git a/examples/multi_export.rs b/examples/multi_export.rs index e7adde3..ad48387 100644 --- a/examples/multi_export.rs +++ b/examples/multi_export.rs @@ -67,7 +67,7 @@ fn main() -> anyhow::Result<()> { // CSV Export (always works) println!("Exporting CSV..."); - export_to_csv(&log, Path::new(&input_file), &export_opts)?; + export_to_csv(&log, Path::new(&input_file), &export_opts, None)?; println!("✓ CSV export complete"); // Compute log index once (log_number is 1-based) @@ -84,6 +84,7 @@ fn main() -> anyhow::Result<()> { &log.home_coordinates, &export_opts, log.header.log_start_datetime.as_deref(), + None, )?; println!( "✓ GPX export complete ({} coordinates)", @@ -107,6 +108,7 @@ fn main() -> anyhow::Result<()> { log.total_logs, &log.event_frames, &export_opts, + None, )?; println!( "✓ Event export complete ({} events)", diff --git a/examples/multi_flight_export.rs b/examples/multi_flight_export.rs index 6b484ed..4fbf83f 100644 --- a/examples/multi_flight_export.rs +++ b/examples/multi_flight_export.rs @@ -60,7 +60,7 @@ fn main() -> anyhow::Result<()> { // Export to CSV println!(" Exporting to CSV..."); - export_to_csv(&log, Path::new(&input_file), &export_opts)?; + export_to_csv(&log, Path::new(&input_file), &export_opts, None)?; // Display export result with optional flight number suffix if log.total_logs > 1 { diff --git a/src/conversion.rs b/src/conversion.rs index bd63c1c..6f4731b 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -343,8 +343,7 @@ fn parse_datetime_to_epoch(datetime_str: &str) -> Option { // Convert to days since epoch (simplified, doesn't handle all edge cases) let days = ymd_to_days(year, month, day)?; - let local_secs = - (days as u64) * 86400 + (hour as u64) * 3600 + (minute as u64) * 60 + (second as u64); + let local_secs = days * 86400 + (hour as u64) * 3600 + (minute as u64) * 60 + (second as u64); // Convert local time to UTC by subtracting the offset // If offset is +02:00, local time is 2 hours ahead of UTC, so subtract 2 hours diff --git a/src/export.rs b/src/export.rs index 88a03d1..17dbdd1 100644 --- a/src/export.rs +++ b/src/export.rs @@ -83,6 +83,95 @@ fn extract_base_name(input_path: &Path) -> &str { .unwrap_or("blackbox") } +/// Sanitize a `base_name_override` value for safe use in file path construction. +/// Returns the final path component only, preventing directory traversal via `../` +/// segments or absolute paths. Returns `None` if the value is empty or ends in `..`. +fn sanitize_base_name_override(base_name_override: Option<&str>) -> Option<&str> { + let raw = base_name_override?; + Path::new(raw).file_name().and_then(|s| s.to_str()) +} + +/// Return a human-readable vendor name for a known filename prefix. +/// Falls back to `"Unknown"` for unrecognised prefixes. +pub fn vendor_name_for_prefix(prefix: &str) -> &'static str { + match prefix { + "EMUF_" => "EmuFlight", + "BTFL_" => "Betaflight", + "INAV_" => "iNav", + "QUIC_" => "Quicksilver", + _ => "Unknown", + } +} + +/// Known firmware vendor filename prefixes mapped to their revision keywords. +/// To add a new firmware: append `("PREFIX_", "keyword")` where keyword is a +/// lowercase substring of that firmware's `H Firmware revision:` header value. +/// For forks that share a base firmware's revision string, add an entry here +/// for filename detection and add a corresponding entry to `FORK_REVISION_MAP`. +const KNOWN_FIRMWARE_PREFIXES: &[(&str, &str)] = &[ + ("EMUF_", "emuflight"), + ("BTFL_", "betaflight"), + ("INAV_", "inav"), + ("QUIC_", "quicksilver"), // Quicksilver; see FORK_REVISION_MAP for rename exemption +]; + +/// Fork firmware prefixes that intentionally report another vendor's revision string. +/// Sessions in files with these prefixes are NOT renamed even when the revision string +/// maps to a different canonical prefix — the mismatch is by design. +/// +/// Each entry is `(file_prefix, reported_canonical_prefix)`. +/// To add a fork: append `("FORK_", "BASE_")` where BASE_ is what +/// `firmware_prefix_for_revision` returns for that fork's revision headers. +const FORK_REVISION_MAP: &[(&str, &str)] = &[ + // Quicksilver writes Betaflight revision headers for Blackbox Explorer compatibility. + ("QUIC_", "BTFL_"), +]; + +/// Return the canonical filename prefix for a firmware revision string (e.g. `"BTFL_"`), +/// or `None` if the vendor is not recognised. +pub fn firmware_prefix_for_revision(revision: &str) -> Option<&'static str> { + let rev_lower = revision.trim().to_lowercase(); + for &(prefix, keyword) in KNOWN_FIRMWARE_PREFIXES { + if rev_lower.contains(keyword) { + return Some(prefix); + } + } + None +} + +fn detect_bbl_filename_prefix(stem: &str) -> Option<&'static str> { + KNOWN_FIRMWARE_PREFIXES + .iter() + .find(|&&(prefix, _)| stem.starts_with(prefix)) + .map(|&(prefix, _)| prefix) +} + +/// Compute a corrected base name for a session whose firmware vendor differs from +/// the BBL filename prefix. Returns `Some(corrected_stem)` when a replacement is +/// needed; `None` when the vendors match, either is unrecognised, or the file prefix +/// is a known fork that intentionally uses a different base firmware's revision string. +/// +/// # Examples +/// - `EMUF_BLACKBOX_LOG_...BBL` + `EmuFlight 0.4.3` → `None` (matches) +/// - `EMUF_BLACKBOX_LOG_...BBL` + `Betaflight 2025.12.0-beta` → `Some("BTFL_BLACKBOX_LOG_...")` +/// - `QUIC_Twiglet_...BFL` + `Betaflight 4.3.0` → `None` (fork exemption) +pub fn corrected_session_base_name(bbl_path: &Path, firmware_revision: &str) -> Option { + let stem = bbl_path.file_stem()?.to_str()?; + let bbl_prefix = detect_bbl_filename_prefix(stem)?; + let session_prefix = firmware_prefix_for_revision(firmware_revision)?; + if bbl_prefix == session_prefix { + return None; + } + // Known fork: the revision mismatch is by design — do not rename + if FORK_REVISION_MAP + .iter() + .any(|&(fp, bp)| fp == bbl_prefix && bp == session_prefix) + { + return None; + } + Some(format!("{}{}", session_prefix, &stem[bbl_prefix.len()..])) +} + /// Helper to compute export file paths with consistent naming across all export types. /// Ensures CLI status messages match actual filenames written by export functions. /// @@ -91,6 +180,7 @@ fn extract_base_name(input_path: &Path) -> &str { /// * `export_options` - Export configuration with optional output directory /// * `log_number` - 1-based log number (for .NN suffix when multiple logs) /// * `total_logs` - Total number of logs in the file +/// * `base_name_override` - Optional replacement stem (e.g. from `corrected_session_base_name`) /// /// # Returns /// Tuple of (csv_path, headers_path, gpx_path, event_path) using consistent naming @@ -99,13 +189,15 @@ pub fn compute_export_paths( export_options: &ExportOptions, log_number: usize, total_logs: usize, + base_name_override: Option<&str>, ) -> ( std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, ) { - let base_name = extract_base_name(input_path); + let base_name = sanitize_base_name_override(base_name_override) + .unwrap_or_else(|| extract_base_name(input_path)); let output_dir = if let Some(ref dir) = export_options.output_dir { std::path::Path::new(dir) @@ -192,8 +284,10 @@ pub fn export_to_csv( log: &BBLLog, input_path: &Path, export_options: &ExportOptions, + base_name_override: Option<&str>, ) -> Result { - let base_name = extract_base_name(input_path); + let base_name = sanitize_base_name_override(base_name_override) + .unwrap_or_else(|| extract_base_name(input_path)); let output_dir = if let Some(ref dir) = export_options.output_dir { Path::new(dir) @@ -414,6 +508,7 @@ fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path) -> Result<()> { /// For very large GPS traces, the `log_start_datetime` is parsed via `generate_gpx_timestamp()` /// on each trackpoint. Future optimization: consider caching the parsed base epoch once per log /// to avoid repeated parsing overhead when exporting thousands of GPS points. +#[allow(clippy::too_many_arguments)] pub fn export_to_gpx( input_path: &Path, log_index: usize, @@ -422,14 +517,20 @@ pub fn export_to_gpx( home_coordinates: &[GpsHomeCoordinate], export_options: &ExportOptions, log_start_datetime: Option<&str>, + base_name_override: Option<&str>, ) -> Result { if gps_coordinates.is_empty() { return Ok(ExportReport::default()); } // Use compute_export_paths to ensure consistent naming with CSV exports - let (_, _, gpx_path, _) = - compute_export_paths(input_path, export_options, log_index + 1, total_logs); + let (_, _, gpx_path, _) = compute_export_paths( + input_path, + export_options, + log_index + 1, + total_logs, + base_name_override, + ); // Create output directory if it doesn't exist (match export_to_csv behavior) if let Some(parent) = gpx_path.parent() { @@ -505,14 +606,20 @@ pub fn export_to_event( total_logs: usize, event_frames: &[EventFrame], export_options: &ExportOptions, + base_name_override: Option<&str>, ) -> Result { if event_frames.is_empty() { return Ok(ExportReport::default()); } // Use compute_export_paths to ensure consistent naming with CSV exports - let (_, _, _, event_path) = - compute_export_paths(input_path, export_options, log_index + 1, total_logs); + let (_, _, _, event_path) = compute_export_paths( + input_path, + export_options, + log_index + 1, + total_logs, + base_name_override, + ); // Create output directory if it doesn't exist (match export_to_csv behavior) if let Some(parent) = event_path.parent() { @@ -571,6 +678,7 @@ mod tests { home_coords, &export_opts, None, + None, )?; // Read back the generated GPX file @@ -858,6 +966,7 @@ mod tests { &home_coords, &export_opts, None, + None, ); assert!( result.is_ok(), @@ -915,4 +1024,76 @@ mod tests { Ok(()) } + + #[test] + fn test_firmware_prefix_for_revision() { + assert_eq!( + firmware_prefix_for_revision("EmuFlight 0.4.3"), + Some("EMUF_") + ); + assert_eq!( + firmware_prefix_for_revision("EMUFLIGHT 0.4.3"), + Some("EMUF_") + ); + assert_eq!( + firmware_prefix_for_revision("Betaflight 4.5.0"), + Some("BTFL_") + ); + assert_eq!( + firmware_prefix_for_revision("Betaflight 2025.12.0-beta (abc) STM32H743"), + Some("BTFL_") + ); + assert_eq!(firmware_prefix_for_revision("INAV 7.1.2"), Some("INAV_")); + assert_eq!(firmware_prefix_for_revision(""), None); + assert_eq!(firmware_prefix_for_revision("Unknown firmware"), None); + } + + #[test] + fn test_corrected_session_base_name_matching_vendor() { + let path = std::path::Path::new("/logs/EMUF_BLACKBOX_LOG_QUAD_20260531.BBL"); + assert_eq!(corrected_session_base_name(path, "EmuFlight 0.4.3"), None); + + let path = std::path::Path::new("/logs/BTFL_BLACKBOX_LOG_QUAD_20260531.BBL"); + assert_eq!(corrected_session_base_name(path, "Betaflight 4.5.0"), None); + } + + #[test] + fn test_corrected_session_base_name_mismatched_vendor() { + let path = std::path::Path::new("/logs/EMUF_BLACKBOX_LOG_QUAD_20260531.BBL"); + assert_eq!( + corrected_session_base_name(path, "Betaflight 2025.12.0-beta"), + Some("BTFL_BLACKBOX_LOG_QUAD_20260531".to_string()) + ); + + let path = std::path::Path::new("/logs/BTFL_BLACKBOX_LOG_QUAD_20260531.BBL"); + assert_eq!( + corrected_session_base_name(path, "EmuFlight 0.4.3"), + Some("EMUF_BLACKBOX_LOG_QUAD_20260531".to_string()) + ); + } + + #[test] + fn test_corrected_session_base_name_unknown_prefix() { + // BBL without a known prefix — no correction regardless of firmware + let path = std::path::Path::new("/logs/BLACKBOX_LOG_QUAD_20260531.BBL"); + assert_eq!(corrected_session_base_name(path, "EmuFlight 0.4.3"), None); + assert_eq!(corrected_session_base_name(path, "Betaflight 4.5.0"), None); + } + + #[test] + fn test_corrected_session_base_name_fork_exemption() { + // Quicksilver (QUIC_) intentionally writes Betaflight revision headers — must NOT rename + let path = std::path::Path::new("/logs/QUIC_Twiglet_2026-06-09_file_0.bfl"); + assert_eq!( + corrected_session_base_name(path, "Betaflight 4.3.0"), + None, + "QUIC_ files with Betaflight revision must not be renamed to BTFL_" + ); + // Sanity: an actual mismatch on a non-fork prefix still renames + let path = std::path::Path::new("/logs/EMUF_BLACKBOX_LOG_20260531.BBL"); + assert_eq!( + corrected_session_base_name(path, "Betaflight 4.3.0"), + Some("BTFL_BLACKBOX_LOG_20260531".to_string()) + ); + } } diff --git a/src/lib.rs b/src/lib.rs index e26815d..fd75df5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,7 @@ //! force_export: false, //! }; //! let log = parse_bbl_file(Path::new("flight.BBL"), export_options.clone(), false).unwrap(); -//! let report = export_to_csv(&log, Path::new("flight.BBL"), &export_options).unwrap(); +//! let report = export_to_csv(&log, Path::new("flight.BBL"), &export_options, None).unwrap(); //! if let Some(path) = report.csv_path { //! println!("Exported to: {}", path.display()); //! } diff --git a/src/main.rs b/src/main.rs index 720e2c5..73d5420 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,10 @@ use std::fs; use std::path::{Path, PathBuf}; // Import export functions from crate library -use bbl_parser::export::{export_to_csv, export_to_event, export_to_gpx}; +use bbl_parser::export::{ + corrected_session_base_name, export_to_csv, export_to_event, export_to_gpx, + firmware_prefix_for_revision, vendor_name_for_prefix, +}; // Import parser functions from crate library - using crate's unified implementations use bbl_parser::parser::parse_single_log; @@ -747,6 +750,7 @@ fn parse_bbl_file_streaming( } let mut processed_logs = 0; + let mut session_firmware: Vec<(usize, String)> = Vec::new(); for (log_index, &start_pos) in log_positions.iter().enumerate() { if debug { @@ -773,6 +777,9 @@ fn parse_bbl_file_streaming( export_options, )?; + // Record firmware for transition detection (before any early-continue) + session_firmware.push((log.log_number, log.header.firmware_revision.clone())); + // Display log info immediately display_log_info(&log); @@ -789,9 +796,18 @@ fn parse_bbl_file_streaming( continue; } + // Correct the output prefix when this session's firmware vendor differs from the BBL filename + let base_name_override = + corrected_session_base_name(file_path, &log.header.firmware_revision); + // Export CSV immediately while data is hot in cache if export_options.csv { - match export_to_csv(&log, file_path, export_options) { + match export_to_csv( + &log, + file_path, + export_options, + base_name_override.as_deref(), + ) { Ok(report) => { if let Some(headers_path) = report.headers_path { println!("Exported headers to: {}", headers_path.display()); @@ -823,6 +839,7 @@ fn parse_bbl_file_streaming( &log.home_coordinates, export_options, log.header.log_start_datetime.as_deref(), + base_name_override.as_deref(), ) { Ok(report) => { if let Some(gpx_path) = report.gpx_path { @@ -850,6 +867,7 @@ fn parse_bbl_file_streaming( log_positions.len(), &log.event_frames, export_options, + base_name_override.as_deref(), ) { Ok(report) => { if let Some(event_path) = report.event_path { @@ -879,9 +897,61 @@ fn parse_bbl_file_streaming( // Log goes out of scope here, memory is freed immediately } + // Warn when sessions within a single BBL file span multiple firmware vendors + if log_positions.len() > 1 { + print_firmware_transition_warning(file_path, &session_firmware); + } + Ok(processed_logs) } +fn print_firmware_transition_warning(file_path: &Path, session_firmware: &[(usize, String)]) { + if session_firmware.len() <= 1 { + return; + } + + // Group consecutive sessions by firmware vendor prefix + let mut groups: Vec<(usize, usize, String, String)> = Vec::new(); // (first, last, prefix, revision) + for (log_num, revision) in session_firmware { + let prefix = firmware_prefix_for_revision(revision) + .unwrap_or("UNKN_") + .to_string(); + if let Some(last_group) = groups.last_mut() { + if last_group.2 == prefix { + last_group.1 = *log_num; + continue; + } + } + groups.push((*log_num, *log_num, prefix, revision.clone())); + } + + // Only warn when multiple distinct firmware vendors are present + let unique_vendors: std::collections::HashSet<&str> = + groups.iter().map(|(_, _, p, _)| p.as_str()).collect(); + if unique_vendors.len() <= 1 { + return; + } + + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + println!("\nWARNING: Firmware transition detected in {filename}"); + for (first, last, prefix, revision) in &groups { + let vendor_name = vendor_name_for_prefix(prefix.as_str()); + if first == last { + println!(" Session {:03}: {} ({})", first, vendor_name, revision); + } else { + println!( + " Sessions {:03}-{:03}: {} ({})", + first, last, vendor_name, revision + ); + } + } + println!(" The flash was not erased before reflashing. Consider excluding earlier sessions."); +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/export_integration_tests.rs b/tests/export_integration_tests.rs index 0468efe..fe1fc00 100644 --- a/tests/export_integration_tests.rs +++ b/tests/export_integration_tests.rs @@ -36,7 +36,7 @@ fn test_export_gpx_creates_output_directory() { force_export: false, }; - let result = export_to_gpx(&bbl_path, 0, 1, &gps_coords, &[], &export_opts, None); + let result = export_to_gpx(&bbl_path, 0, 1, &gps_coords, &[], &export_opts, None, None); assert!( result.is_ok(), "GPX export should succeed and create directories" @@ -85,7 +85,7 @@ fn test_export_event_creates_output_directory() { force_export: false, }; - let result = export_to_event(&bbl_path, 0, 1, &event_frames, &export_opts); + let result = export_to_event(&bbl_path, 0, 1, &event_frames, &export_opts, None); assert!( result.is_ok(), "Event export should succeed and create directory" @@ -124,7 +124,7 @@ fn test_export_event_empty_returns_ok() { force_export: false, }; - let result = export_to_event(&bbl_path, 0, 1, &[], &export_opts); + let result = export_to_event(&bbl_path, 0, 1, &[], &export_opts, None); assert!( result.is_ok(), "Event export should succeed with empty events" @@ -153,7 +153,7 @@ fn test_compute_export_paths_single_log() { }; let (csv_path, _headers_path, gpx_path, event_path) = - compute_export_paths(&input_path, &export_opts, 1, 1); + compute_export_paths(&input_path, &export_opts, 1, 1, None); // Verify no .NN suffix for single log assert!( @@ -185,7 +185,7 @@ fn test_compute_export_paths_multi_log() { }; let (csv_path, _headers_path, gpx_path, event_path) = - compute_export_paths(&input_path, &export_opts, 2, 3); + compute_export_paths(&input_path, &export_opts, 2, 3, None); // Verify .NN suffix is applied for multi-log assert!( @@ -246,7 +246,7 @@ fn test_gpx_empty_coordinates_returns_ok() { }; // Should return Ok even with empty GPS coordinates - let result = export_to_gpx(&bbl_path, 0, 1, &[], &[], &export_opts, None); + let result = export_to_gpx(&bbl_path, 0, 1, &[], &[], &export_opts, None, None); assert!( result.is_ok(), "Export should succeed with empty GPS coordinates"