diff --git a/AGENTS.md b/AGENTS.md
index a5fb952..815c35b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,15 +13,14 @@
## Architecture & Code Organization
- **Library-first design:** Core logic in `src/lib.rs` and CLI entry point in `src/main.rs`.
- - **Shared code:** Parser modules (`src/parser/`) are used by both library and CLI (`parse_frames`, `parse_headers_from_text`).
- - **Duplicated code:** Export implementations exist separately in `src/export.rs` (library) and `src/main.rs` (CLI). CLI does NOT use library export functions.
- - **Current state:** **Partial unification** — parsing shared, export duplicated (~1800 lines in `src/main.rs`).
+ - **Shared code:** Parser modules (`src/parser/`) and export functions (`src/export.rs`) are shared by both library and CLI.
+ - **CLI as thin wrapper:** The CLI (`src/main.rs`) uses library export functions (`export_to_csv`, `export_to_gpx`, `export_to_event`) with CLI-specific status messages.
+ - **Current state:** **Full unification complete** — parsing and export layers unified, CLI reduced from ~1800 to ~1400 lines.
- **Decision criteria:** "Is this needed by crate consumers?" determines placement — shared logic in library, CLI-only logic in `src/main.rs`.
- **Feature flags:** `csv`, `cli`, `json`, `serde` control optional dependencies; default: `csv` + `cli`.
- **CRATE_USAGE.md reference:** See `CRATE_USAGE.md` for library API examples with feature flags.
-- **Code quality goals:** Reduce duplication by migrating CLI export logic to use library `export_to_csv()`, `export_to_gpx()`, `export_to_event()` functions.
- **Testing:** Comprehensive tests distributed across `src/main.rs`, `src/conversion.rs`, `src/parser/stream.rs`, and `src/parser/helpers.rs`.
-- **Public API:** `parse_bbl_file()`, `parse_bbl_bytes()`, `BBLLog`, `ExportOptions`, conversion utilities, parser helpers.
+- **Public API:** `parse_bbl_file()`, `parse_bbl_bytes()`, `BBLLog`, `ExportOptions`, `export_to_csv()`, `export_to_gpx()`, `export_to_event()`, conversion utilities, parser helpers.
## Algorithms
- **Method Selection:**
diff --git a/examples/export_demo.rs b/examples/export_demo.rs
index 4e460e3..05dfed1 100644
--- a/examples/export_demo.rs
+++ b/examples/export_demo.rs
@@ -108,6 +108,7 @@ fn main() -> Result<()> {
&log.gps_coordinates,
&log.home_coordinates,
&export_opts,
+ log.header.log_start_datetime.as_deref(),
)?;
println!("✓ GPX export complete");
} else {
diff --git a/examples/gpx_export.rs b/examples/gpx_export.rs
index b132304..cfae586 100644
--- a/examples/gpx_export.rs
+++ b/examples/gpx_export.rs
@@ -47,6 +47,7 @@ fn main() -> anyhow::Result<()> {
&log.gps_coordinates,
&log.home_coordinates,
&export_opts,
+ log.header.log_start_datetime.as_deref(),
)?;
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 8ce64ce..e7adde3 100644
--- a/examples/multi_export.rs
+++ b/examples/multi_export.rs
@@ -83,6 +83,7 @@ fn main() -> anyhow::Result<()> {
&log.gps_coordinates,
&log.home_coordinates,
&export_opts,
+ log.header.log_start_datetime.as_deref(),
)?;
println!(
"✓ GPX export complete ({} coordinates)",
diff --git a/src/export.rs b/src/export.rs
index 60f1455..8507ed5 100644
--- a/src/export.rs
+++ b/src/export.rs
@@ -25,6 +25,63 @@ pub struct ExportOptions {
pub force_export: bool,
}
+/// Extract the base filename from an input path with consistent fallback.
+/// Used by all export functions and path computation helpers to ensure
+/// consistent naming across CSV, GPX, and event exports.
+///
+/// Always returns "blackbox" as fallback for missing or non-UTF-8 filenames,
+/// ensuring compute_export_paths() predictions match actual export filenames.
+fn extract_base_name(input_path: &Path) -> &str {
+ input_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("blackbox")
+}
+
+/// Helper to compute export file paths with consistent naming across all export types.
+/// Ensures CLI status messages match actual filenames written by export functions.
+///
+/// # Arguments
+/// * `input_path` - Path to the input BBL file (used to extract base filename)
+/// * `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
+///
+/// # Returns
+/// Tuple of (csv_path, headers_path, gpx_path, event_path) using consistent naming
+pub fn compute_export_paths(
+ input_path: &Path,
+ export_options: &ExportOptions,
+ log_number: usize,
+ total_logs: usize,
+) -> (
+ std::path::PathBuf,
+ std::path::PathBuf,
+ std::path::PathBuf,
+ std::path::PathBuf,
+) {
+ let base_name = extract_base_name(input_path);
+
+ let output_dir = if let Some(ref dir) = export_options.output_dir {
+ std::path::Path::new(dir)
+ } else {
+ input_path.parent().unwrap_or(std::path::Path::new("."))
+ };
+
+ let log_suffix = if total_logs > 1 {
+ format!(".{:02}", log_number)
+ } else {
+ String::new()
+ };
+
+ let csv_path = output_dir.join(format!("{}{}.csv", base_name, log_suffix));
+ let headers_path = output_dir.join(format!("{}{}.headers.csv", base_name, log_suffix));
+ let gpx_path = output_dir.join(format!("{}{}.gps.gpx", base_name, log_suffix));
+ let event_path = output_dir.join(format!("{}{}.event", base_name, log_suffix));
+
+ (csv_path, headers_path, gpx_path, event_path)
+}
+
/// Pre-computed CSV field mapping for performance
#[derive(Debug)]
struct CsvFieldMap {
@@ -87,10 +144,7 @@ pub fn export_to_csv(
input_path: &Path,
export_options: &ExportOptions,
) -> Result<()> {
- let base_name = input_path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("blackbox");
+ let base_name = extract_base_name(input_path);
let output_dir = if let Some(ref dir) = export_options.output_dir {
Path::new(dir)
@@ -288,6 +342,20 @@ fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path) -> Result<()> {
}
/// Export GPS data to GPX format
+///
+/// # Arguments
+/// * `input_path` - Path to the input BBL file (used for output naming)
+/// * `log_index` - Index of the current log (0-based)
+/// * `total_logs` - Total number of logs in the file
+/// * `gps_coordinates` - GPS coordinate data to export
+/// * `_home_coordinates` - Home coordinates (reserved for future use)
+/// * `export_options` - Export configuration options
+/// * `log_start_datetime` - Optional log start datetime from header for accurate timestamps
+///
+/// # Performance Notes
+/// 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.
pub fn export_to_gpx(
input_path: &Path,
log_index: usize,
@@ -295,15 +363,13 @@ pub fn export_to_gpx(
gps_coordinates: &[GpsCoordinate],
_home_coordinates: &[GpsHomeCoordinate],
export_options: &ExportOptions,
+ log_start_datetime: Option<&str>,
) -> Result<()> {
if gps_coordinates.is_empty() {
return Ok(());
}
- let base_name = input_path
- .file_stem()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
+ let base_name = extract_base_name(input_path);
let output_dir = export_options
.output_dir
@@ -339,19 +405,14 @@ pub fn export_to_gpx(
}
}
- // Convert timestamp to ISO format
- let total_seconds = coord.timestamp_us / 1_000_000;
- let microseconds = coord.timestamp_us % 1_000_000;
-
- // Use March 26, 2025 as base date
- let hours = 5 + (total_seconds / 3600) % 24;
- let minutes = (total_seconds % 3600) / 60;
- let seconds = total_seconds % 60;
+ // Generate GPX timestamp from log_start_datetime + frame timestamp
+ // Following blackbox_decode approach: dateTime + (gpsFrameTime / 1000000)
+ let timestamp_str = generate_gpx_timestamp(log_start_datetime, coord.timestamp_us);
writeln!(
gpx_file,
- r#" {:.2}"#,
- coord.latitude, coord.longitude, coord.altitude, hours, minutes, seconds, microseconds
+ r#" {:.2}"#,
+ coord.latitude, coord.longitude, coord.altitude, timestamp_str
)?;
}
@@ -373,10 +434,7 @@ pub fn export_to_event(
return Ok(());
}
- let base_name = input_path
- .file_stem()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
+ let base_name = extract_base_name(input_path);
let output_dir = export_options
.output_dir
diff --git a/src/main.rs b/src/main.rs
index da1f6d5..56ce676 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,28 +1,27 @@
-use anyhow::{Context, Result};
+use anyhow::Result;
use clap::{Arg, Command};
use glob::glob;
use std::collections::{HashMap, HashSet};
use std::fs;
-use std::io::Write;
use std::path::{Path, PathBuf};
-// Import conversion functions from crate library to avoid code duplication
-use bbl_parser::conversion::{
- convert_amperage_to_amps, convert_vbat_to_volts, format_failsafe_phase,
- format_flight_mode_flags, format_state_flags, generate_gpx_timestamp,
-};
+// Import export functions from crate library
+use bbl_parser::export::{compute_export_paths, export_to_csv, export_to_event, export_to_gpx};
// Import parser types from crate library - using crate's unified implementations
use bbl_parser::parser::{parse_frames, parse_headers_from_text};
// Import types from crate library
-use bbl_parser::types::{
- BBLHeader, BBLLog, DecodedFrame, EventFrame, GpsCoordinate, GpsHomeCoordinate,
-};
+use bbl_parser::types::BBLLog;
// Test-only imports
#[cfg(test)]
-use bbl_parser::types::{FrameDefinition, FrameStats};
+use bbl_parser::conversion::{
+ convert_amperage_to_amps, convert_vbat_to_volts, format_failsafe_phase,
+ format_flight_mode_flags, format_state_flags,
+};
+#[cfg(test)]
+use bbl_parser::types::{BBLHeader, DecodedFrame, FrameDefinition, FrameStats};
// Import ExportOptions from crate library
use bbl_parser::ExportOptions;
@@ -42,14 +41,6 @@ const VERSION_STR: &str = concat!(
/// Maximum recursion depth to prevent stack overflow
const MAX_RECURSION_DEPTH: usize = 100;
-/// Get output directory from export options, falling back to file's parent directory or ".".
-fn get_output_dir<'a>(export_options: &'a ExportOptions, file_path: &'a Path) -> &'a str {
- export_options
- .output_dir
- .as_deref()
- .unwrap_or_else(|| file_path.parent().and_then(|p| p.to_str()).unwrap_or("."))
-}
-
/// Expand input paths to a list of BBL files.
/// If a path is a file, add it directly (will be filtered later for BBL/BFL/TXT extension).
/// If a path is a directory, recursively find all BBL files within it.
@@ -267,76 +258,6 @@ fn should_have_frame(frame_index: u32, sysconfig: &HashMap) -> bool
left_side < frame_interval_p_num as u32
}
-#[derive(Debug, Clone)]
-struct CsvExportOptions {
- output_dir: Option,
-}
-
-// Pre-computed CSV field mapping for performance
-#[derive(Debug)]
-struct CsvFieldMap {
- field_name_to_lookup: Vec<(String, String)>, // (csv_name, lookup_name)
-}
-
-impl CsvFieldMap {
- fn new(header: &BBLHeader) -> Self {
- let mut field_name_to_lookup = Vec::new();
-
- // Build optimized field mappings from all frame types
- let mut csv_field_names = Vec::new();
-
- // I frame fields
- for field_name in &header.i_frame_def.field_names {
- let trimmed = field_name.trim();
- let csv_name = if trimmed == "time" {
- "time (us)".to_string()
- } else if trimmed == "vbatLatest" {
- "vbatLatest (V)".to_string()
- } else if trimmed == "amperageLatest" {
- "amperageLatest (A)".to_string()
- } else {
- trimmed.to_string()
- };
-
- field_name_to_lookup.push((csv_name.clone(), trimmed.to_string()));
- csv_field_names.push(csv_name);
- }
-
- // Add computed fields IMMEDIATELY after I frame fields (like blackbox_decode does)
- if field_name_to_lookup
- .iter()
- .any(|(_, lookup)| lookup == "amperageLatest")
- {
- field_name_to_lookup.push(("energyCumulative (mAh)".to_string(), "".to_string()));
- csv_field_names.push("energyCumulative (mAh)".to_string());
- }
-
- // S frame fields (with flag formatting)
- for field_name in &header.s_frame_def.field_names {
- let trimmed = field_name.trim();
- if trimmed == "time" {
- continue;
- } // Skip duplicate
-
- let csv_name = if trimmed.contains("Flag") || trimmed == "failsafePhase" {
- format!("{trimmed} (flags)")
- } else {
- trimmed.to_string()
- };
-
- field_name_to_lookup.push((csv_name.clone(), trimmed.to_string()));
- csv_field_names.push(csv_name);
- }
-
- // NOTE: G-frame fields excluded from main CSV (will go to separate .gps.csv file in future)
- // NOTE: E-frame fields excluded from main CSV (will go to separate .event file in future)
-
- Self {
- field_name_to_lookup,
- }
- }
-}
-
fn build_command() -> Command {
let about_text = format!(
"\n\nRead and parse BBL blackbox log files. Exports to CSV by default (optionally GPX/JSON).\n {} {} ({})",
@@ -419,9 +340,6 @@ fn main() -> Result<()> {
force_export,
};
- // Keep legacy csv_options for compatibility
- let csv_options = CsvExportOptions { output_dir };
-
let mut processed_files = 0;
if debug {
@@ -514,7 +432,7 @@ fn main() -> Result<()> {
.unwrap_or("unknown");
println!("Processing: {filename}");
- match parse_bbl_file_streaming(path, debug, &export_options, &csv_options) {
+ match parse_bbl_file_streaming(path, debug, &export_options) {
Ok(processed_logs) => {
if debug {
println!(
@@ -1070,289 +988,10 @@ fn calculate_variance(values: &[f64]) -> f64 {
variance
}
-#[allow(dead_code)]
-fn export_logs_to_csv(
- logs: &[BBLLog],
- input_path: &Path,
- options: &CsvExportOptions,
- debug: bool,
-) -> Result<()> {
- let base_name = input_path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("blackbox");
-
- let output_dir = if let Some(ref dir) = options.output_dir {
- Path::new(dir)
- } else {
- input_path.parent().unwrap_or(Path::new("."))
- };
-
- // Create output directory if it doesn't exist
- if !output_dir.exists() {
- std::fs::create_dir_all(output_dir)?;
- if debug {
- println!("Created output directory: {output_dir:?}");
- }
- }
-
- if debug {
- println!(
- "Exporting {} logs to CSV in directory: {:?}",
- logs.len(),
- output_dir
- );
- }
-
- for log in logs {
- let log_suffix = if logs.len() > 1 {
- format!(".{:02}", log.log_number)
- } else {
- "".to_string()
- };
-
- // Export plaintext headers to separate CSV
- let header_csv_path = output_dir.join(format!("{base_name}{log_suffix}.headers.csv"));
- export_headers_to_csv(&log.header, &header_csv_path, debug)?;
- println!("Exported headers to: {}", header_csv_path.display());
-
- // Export flight data (I, P, S, G frames) to main CSV
- let flight_csv_path = output_dir.join(format!("{base_name}{log_suffix}.csv"));
- export_flight_data_to_csv(log, &flight_csv_path, debug)?;
- println!("Exported flight data to: {}", flight_csv_path.display());
- }
-
- Ok(())
-}
-
-fn export_single_log_to_csv(
- log: &BBLLog,
- input_path: &Path,
- options: &CsvExportOptions,
- debug: bool,
-) -> Result<()> {
- let base_name = input_path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("blackbox");
-
- let output_dir = if let Some(ref dir) = options.output_dir {
- Path::new(dir)
- } else {
- input_path.parent().unwrap_or(Path::new("."))
- };
-
- // Create output directory if it doesn't exist
- if !output_dir.exists() {
- std::fs::create_dir_all(output_dir)?;
- if debug {
- println!("Created output directory: {output_dir:?}");
- }
- }
-
- let log_suffix = if log.total_logs > 1 {
- format!(".{:02}", log.log_number)
- } else {
- "".to_string()
- };
-
- // Export plaintext headers to separate CSV
- let header_csv_path = output_dir.join(format!("{base_name}{log_suffix}.headers.csv"));
- export_headers_to_csv(&log.header, &header_csv_path, debug)?;
- println!("Exported headers to: {}", header_csv_path.display());
-
- // Export flight data (I, P, S, G frames) to main CSV
- let flight_csv_path = output_dir.join(format!("{base_name}{log_suffix}.csv"));
- export_flight_data_to_csv(log, &flight_csv_path, debug)?;
- println!("Exported flight data to: {}", flight_csv_path.display());
-
- Ok(())
-}
-
-fn export_headers_to_csv(header: &BBLHeader, output_path: &Path, _debug: bool) -> Result<()> {
- use std::fs::File;
- use std::io::{BufWriter, Write};
-
- let file = File::create(output_path)
- .with_context(|| format!("Failed to create headers CSV file: {output_path:?}"))?;
- let mut writer = BufWriter::new(file);
-
- // Write CSV header
- writeln!(writer, "Field,Value")?;
-
- // Parse and write all header lines
- for header_line in &header.all_headers {
- if let Some(content) = header_line.strip_prefix("H ") {
- // Remove "H " prefix and find the colon separator
- if let Some(colon_pos) = content.find(':') {
- let field_name = content[..colon_pos].trim();
- let field_value = content[colon_pos + 1..].trim();
-
- // Escape commas in values by wrapping in quotes
- let escaped_value = if field_value.contains(',') {
- format!("\"{}\"", field_value.replace("\"", "\"\""))
- } else {
- field_value.to_string()
- };
-
- writeln!(writer, "{field_name},{escaped_value}")?;
- }
- }
- }
-
- writer
- .flush()
- .with_context(|| format!("Failed to flush headers CSV file: {output_path:?}"))?;
-
- Ok(())
-}
-
-fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path, debug: bool) -> Result<()> {
- use std::fs::File;
- use std::io::{BufWriter, Write};
-
- let file = File::create(output_path)
- .with_context(|| format!("Failed to create flight data CSV file: {output_path:?}"))?;
- let mut writer = BufWriter::new(file);
-
- // Build optimized field mapping (like C reference - pre-computed, no string matching per frame)
- let csv_map = CsvFieldMap::new(&log.header);
- let field_names: Vec = csv_map
- .field_name_to_lookup
- .iter()
- .map(|(csv_name, _)| csv_name.clone())
- .collect();
-
- // Collect all I and P frames in chronological order
- // S frames are merged into I/P frames during parsing, matching blackbox_decode behavior
- let mut all_frames: Vec<(u64, char, &DecodedFrame)> = Vec::new();
-
- for frame in &log.frames {
- if frame.frame_type == 'I' || frame.frame_type == 'P' {
- all_frames.push((frame.timestamp_us, frame.frame_type, frame));
- }
- }
-
- // Sort by timestamp
- all_frames.sort_by_key(|(timestamp, _, _)| *timestamp);
-
- if all_frames.is_empty() {
- return Ok(()); // No data to export
- }
-
- // Write field names header
- for (i, field_name) in field_names.iter().enumerate() {
- if i > 0 {
- write!(writer, ", ")?;
- }
- write!(writer, "{field_name}")?;
- }
- writeln!(writer)?;
-
- // Optimized CSV writing with pre-computed mappings (like C reference)
- let mut cumulative_energy_mah = 0f32;
- let mut last_timestamp_us = 0u64;
- let mut latest_s_frame_data: HashMap = HashMap::new();
-
- for (output_iteration, (timestamp, frame_type, frame)) in all_frames.iter().enumerate() {
- // Update latest S-frame data if this is an S frame
- if *frame_type == 'S' {
- for (key, value) in &frame.data {
- latest_s_frame_data.insert(key.clone(), *value);
- }
- }
-
- // Calculate energyCumulative for this frame
- if let Some(current_raw) = frame.data.get("amperageLatest").copied() {
- if last_timestamp_us > 0 && *timestamp > last_timestamp_us {
- let time_delta_hours = (*timestamp - last_timestamp_us) as f32 / 3_600_000_000.0;
- let current_amps = convert_amperage_to_amps(current_raw);
- cumulative_energy_mah += current_amps * time_delta_hours * 1000.0;
- }
- last_timestamp_us = *timestamp;
- }
-
- // Write data row using optimized field mapping
- for (i, (csv_name, lookup_name)) in csv_map.field_name_to_lookup.iter().enumerate() {
- if i > 0 {
- write!(writer, ", ")?;
- }
-
- // Fast path for special fields using pre-computed indices
- if csv_name == "time (us)" {
- write!(writer, "{}", *timestamp as i32)?;
- } else if csv_name == "loopIteration" {
- let value = frame
- .data
- .get("loopIteration")
- .copied()
- .unwrap_or(output_iteration as i32);
- write!(writer, "{value:4}")?;
- } else if csv_name == "vbatLatest (V)" {
- let raw_value = frame.data.get("vbatLatest").copied().unwrap_or(0);
- write!(
- writer,
- "{:4.1}",
- convert_vbat_to_volts(raw_value, &log.header.firmware_revision)
- )?;
- } else if csv_name == "amperageLatest (A)" {
- let raw_value = frame.data.get("amperageLatest").copied().unwrap_or(0);
- write!(writer, "{:4.2}", convert_amperage_to_amps(raw_value))?;
- } else if csv_name == "energyCumulative (mAh)" {
- write!(writer, "{:5}", cumulative_energy_mah as i32)?;
- } else if csv_name.ends_with(" (flags)") {
- // Handle flag fields - output text values like blackbox_decode.c
- let raw_value = frame
- .data
- .get(lookup_name)
- .copied()
- .or_else(|| latest_s_frame_data.get(lookup_name).copied())
- .unwrap_or(0);
-
- let formatted = if lookup_name == "flightModeFlags" {
- format_flight_mode_flags(raw_value)
- } else if lookup_name == "stateFlags" {
- format_state_flags(raw_value)
- } else if lookup_name == "failsafePhase" {
- format_failsafe_phase(raw_value)
- } else {
- raw_value.to_string()
- };
- write!(writer, "{formatted}")?;
- } else {
- // Regular field lookup with S-frame fallback
- let value = frame
- .data
- .get(lookup_name)
- .copied()
- .or_else(|| latest_s_frame_data.get(lookup_name).copied())
- .unwrap_or(0);
- write!(writer, "{value:4}")?;
- }
- }
- writeln!(writer)?;
- }
-
- writer
- .flush()
- .with_context(|| format!("Failed to flush flight data CSV file: {output_path:?}"))?;
-
- if debug {
- println!(
- "Exported {} data rows with {} fields (optimized)",
- all_frames.len(),
- field_names.len()
- );
- }
-
- Ok(())
-}
-
fn parse_bbl_file_streaming(
file_path: &Path,
debug: bool,
export_options: &ExportOptions,
- csv_options: &CsvExportOptions,
) -> Result {
if debug {
println!("=== STREAMING BBL FILE PROCESSING ===");
@@ -1432,21 +1071,33 @@ fn parse_bbl_file_streaming(
// Export CSV immediately while data is hot in cache
if export_options.csv {
- if let Err(e) = export_single_log_to_csv(&log, file_path, csv_options, debug) {
- let filename = file_path
- .file_name()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
- eprintln!(
- "Warning: Failed to export CSV for {filename} log {}: {e}",
- log_index + 1
- );
+ match export_to_csv(&log, file_path, export_options) {
+ Ok(()) => {
+ let (csv_path, headers_path, _, _) = compute_export_paths(
+ file_path,
+ export_options,
+ log.log_number,
+ log_positions.len(),
+ );
+ println!("Exported headers to: {}", headers_path.display());
+ println!("Exported flight data to: {}", csv_path.display());
+ }
+ Err(e) => {
+ let filename = file_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("unknown");
+ eprintln!(
+ "Warning: Failed to export CSV for {filename} log {}: {e}",
+ log_index + 1
+ );
+ }
}
}
// Export GPS data to GPX if requested
if export_options.gpx && !log.gps_coordinates.is_empty() {
- if let Err(e) = export_gpx_file(
+ match export_to_gpx(
file_path,
log_index,
log_positions.len(),
@@ -1455,34 +1106,56 @@ fn parse_bbl_file_streaming(
export_options,
log.header.log_start_datetime.as_deref(),
) {
- let filename = file_path
- .file_name()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
- eprintln!(
- "Warning: Failed to export GPX for {filename} log {}: {e}",
- log_index + 1
- );
+ Ok(_) => {
+ let (_, _, gpx_path, _) = compute_export_paths(
+ file_path,
+ export_options,
+ log.log_number,
+ log_positions.len(),
+ );
+ println!("Exported GPS data to: {}", gpx_path.display());
+ }
+ Err(e) => {
+ let filename = file_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("unknown");
+ eprintln!(
+ "Warning: Failed to export GPX for {filename} log {}: {e}",
+ log_index + 1
+ );
+ }
}
}
// Export event data to JSON if requested
if export_options.event && !log.event_frames.is_empty() {
- if let Err(e) = export_event_file(
+ match export_to_event(
file_path,
log_index,
log_positions.len(),
&log.event_frames,
export_options,
) {
- let filename = file_path
- .file_name()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
- eprintln!(
- "Warning: Failed to export events for {filename} log {}: {e}",
- log_index + 1
- );
+ Ok(()) => {
+ let (_, _, _, event_path) = compute_export_paths(
+ file_path,
+ export_options,
+ log.log_number,
+ log_positions.len(),
+ );
+ println!("Exported event data to: {}", event_path.display());
+ }
+ Err(e) => {
+ let filename = file_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("unknown");
+ eprintln!(
+ "Warning: Failed to export events for {filename} log {}: {e}",
+ log_index + 1
+ );
+ }
}
}
@@ -1499,121 +1172,6 @@ fn parse_bbl_file_streaming(
Ok(processed_logs)
}
-// Note: Flag formatting functions now imported from bbl_parser::conversion module
-// (format_flight_mode_flags, format_state_flags, format_failsafe_phase)
-
-// GPS/GPX export functions
-// Note: GPS conversion functions now imported from bbl_parser::conversion module
-// (generate_gpx_timestamp for GPX timestamp generation)
-
-fn export_gpx_file(
- file_path: &Path,
- log_number: usize,
- total_logs: usize,
- gps_coords: &[GpsCoordinate],
- _home_coords: &[GpsHomeCoordinate], // TODO: Use home coordinates for reference point
- export_options: &ExportOptions,
- log_start_datetime: Option<&str>,
-) -> Result<()> {
- if gps_coords.is_empty() {
- return Ok(());
- }
-
- let base_name = file_path
- .file_stem()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
-
- let output_dir = get_output_dir(export_options, file_path);
-
- // Use consistent naming: only add suffix for multiple logs
- let log_suffix = if total_logs > 1 {
- format!(".{:02}", log_number + 1)
- } else {
- "".to_string()
- };
- let gpx_filename = format!("{}/{}{}.gps.gpx", output_dir, base_name, log_suffix);
-
- let mut gpx_file = std::fs::File::create(&gpx_filename)?;
- writeln!(gpx_file, r#""#)?;
- writeln!(
- gpx_file,
- r#""#
- )?;
- writeln!(
- gpx_file,
- "Blackbox flight log"
- )?;
- writeln!(gpx_file, "Blackbox flight log")?;
-
- for coord in gps_coords {
- // Only include coordinates with sufficient GPS satellite count (minimum 5)
- if let Some(num_sats) = coord.num_sats {
- if num_sats < 5 {
- continue;
- }
- }
-
- // Generate GPX timestamp from log_start_datetime + frame timestamp
- // Following blackbox_decode approach: dateTime + (gpsFrameTime / 1000000)
- let timestamp_str = generate_gpx_timestamp(log_start_datetime, coord.timestamp_us);
-
- writeln!(
- gpx_file,
- r#" {:.2}"#,
- coord.latitude, coord.longitude, coord.altitude, timestamp_str
- )?;
- }
-
- writeln!(gpx_file, "")?;
- writeln!(gpx_file, "")?;
-
- println!("Exported GPS data to: {}", gpx_filename);
- Ok(())
-}
-
-fn export_event_file(
- file_path: &Path,
- log_number: usize,
- total_logs: usize,
- events: &[EventFrame],
- export_options: &ExportOptions,
-) -> Result<()> {
- if events.is_empty() {
- return Ok(());
- }
-
- let base_name = file_path
- .file_stem()
- .and_then(|n| n.to_str())
- .unwrap_or("unknown");
-
- let output_dir = get_output_dir(export_options, file_path);
-
- // Use consistent naming: only add suffix for multiple logs
- let log_suffix = if total_logs > 1 {
- format!(".{:02}", log_number + 1)
- } else {
- "".to_string()
- };
- let event_filename = format!("{}/{}{}.event", output_dir, base_name, log_suffix);
-
- let mut event_file = std::fs::File::create(&event_filename)?;
-
- // Export as JSONL format (individual JSON objects per line) to match blackbox_decode
- for event in events.iter() {
- writeln!(
- event_file,
- r#"{{"name":"{}", "time":{}}}"#,
- event.event_name.replace('"', "\\\""),
- event.timestamp_us
- )?;
- }
-
- println!("Exported event data to: {}", event_filename);
- Ok(())
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1677,14 +1235,27 @@ mod tests {
}
#[test]
- fn test_csv_export_options() {
- let options = CsvExportOptions {
+ fn test_export_options() {
+ let options = ExportOptions {
+ csv: true,
+ gpx: false,
+ event: false,
output_dir: Some("/tmp".to_string()),
+ force_export: false,
};
assert_eq!(options.output_dir.as_ref().unwrap(), "/tmp");
+ assert!(options.csv);
+ assert!(!options.gpx);
+ assert!(!options.event);
+ assert!(!options.force_export);
- let options = CsvExportOptions { output_dir: None };
+ // Test default configuration (all false except output_dir which is None)
+ let options = ExportOptions::default();
assert!(options.output_dir.is_none());
+ assert!(!options.csv);
+ assert!(!options.gpx);
+ assert!(!options.event);
+ assert!(!options.force_export);
}
#[test]