diff --git a/src/conversion.rs b/src/conversion.rs index 19e048a..bd63c1c 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -202,3 +202,399 @@ pub fn format_failsafe_phase(phase: i32) -> String { _ => phase.to_string(), } } + +// ============================================================================ +// GPX Timestamp Generation (for GPS export) +// ============================================================================ + +/// Convert epoch seconds and microseconds to ISO 8601 string. +/// Helper function to eliminate duplication between timestamp formatting functions. +fn epoch_seconds_to_iso8601(total_seconds: u64, microseconds: u64) -> String { + let secs_per_minute = 60u64; + let secs_per_hour = 3600u64; + let secs_per_day = 86400u64; + + let time_of_day = total_seconds % secs_per_day; + let hours = (time_of_day / secs_per_hour) % 24; + let minutes = (time_of_day % secs_per_hour) / secs_per_minute; + let seconds = time_of_day % secs_per_minute; + + let days_since_epoch = total_seconds / secs_per_day; + let (year, month, day) = days_to_ymd(days_since_epoch); + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z", + year, month, day, hours, minutes, seconds, microseconds + ) +} + +/// Generate GPX timestamp from log_start_datetime header + frame timestamp. +/// Following blackbox_decode approach: dateTime + (gpsFrameTime / 1000000) +/// If log_start_datetime is not available or invalid, falls back to relative time from epoch. +pub fn generate_gpx_timestamp(log_start_datetime: Option<&str>, frame_timestamp_us: u64) -> String { + let total_seconds = frame_timestamp_us / 1_000_000; + let microseconds = frame_timestamp_us % 1_000_000; + + // Try to parse the log start datetime if available + if let Some(datetime_str) = log_start_datetime { + // Check for placeholder datetime (clock not set on FC) + if datetime_str.starts_with("0000-01-01") { + // FC clock wasn't set, fall back to relative time + return epoch_seconds_to_iso8601(total_seconds, microseconds); + } + + // Parse ISO 8601 datetime: "2024-10-10T18:37:25.559+00:00" + // We only need the date and base time parts for combining with frame offset + if let Some(base_time) = parse_datetime_to_epoch(datetime_str) { + let absolute_secs = base_time + total_seconds; + return epoch_seconds_to_iso8601(absolute_secs, microseconds); + } + } + + // Fallback: use relative time from epoch + epoch_seconds_to_iso8601(total_seconds, microseconds) +} + +/// Parse ISO 8601 datetime string to seconds since Unix epoch (1970-01-01T00:00:00Z). +/// +/// This function handles the datetime format used by Betaflight's blackbox logs: +/// `YYYY-MM-DDTHH:MM:SS.mmm±HH:MM` (e.g., `2024-10-10T18:37:25.559+00:00`) +/// +/// # Accepted Input Formats +/// - `YYYY-MM-DDTHH:MM:SS.mmmZ` - UTC with 'Z' suffix +/// - `YYYY-MM-DDTHH:MM:SS.mmm+HH:MM` - With positive timezone offset (e.g., `+02:00`) +/// - `YYYY-MM-DDTHH:MM:SS.mmm-HH:MM` - With negative timezone offset (e.g., `-05:00`) +/// - `YYYY-MM-DDTHH:MM:SS` - No timezone (treated as UTC) +/// +/// # Format Limitations +/// - Compact timezone formats like `-0500` are NOT supported (only colon-separated `HH:MM`) +/// - Region-based timezones like `America/New_York` are NOT supported +/// - Fractional seconds are truncated to whole seconds for epoch calculation +/// - Strings without timezone info are treated as UTC +/// +/// # Betaflight Header Context +/// When the flight controller's RTC is not set, Betaflight outputs `0000-01-01T00:00:00.000` +/// as the default datetime. This value should be detected by the caller (via +/// `starts_with("0000-01-01")`) and handled as "no valid datetime available". +fn parse_datetime_to_epoch(datetime_str: &str) -> Option { + // Format: "2024-10-10T18:37:25.559+02:00" or "2024-10-10T18:37:25.559Z" + // Parse timezone offset if present, then convert local time to UTC + + // Extract timezone offset in seconds (positive = ahead of UTC, negative = behind) + let tz_offset_secs: i64 = if datetime_str.contains('Z') { + 0 // UTC, no offset + } else if let Some(plus_pos) = datetime_str.rfind('+') { + // Positive offset like "+02:00" means local time is ahead of UTC + parse_tz_offset(&datetime_str[plus_pos + 1..]).unwrap_or(0) + } else if let Some(minus_pos) = datetime_str.rfind('-') { + // Check if this is a date separator or timezone offset + // Timezone offset format: "-HH:MM" at end of string + let potential_tz = &datetime_str[minus_pos + 1..]; + if potential_tz.contains(':') && potential_tz.len() <= 6 { + // Negative offset like "-05:00" means local time is behind UTC + -parse_tz_offset(potential_tz).unwrap_or(0) + } else { + 0 // Date separator, assume UTC + } + } else { + 0 // No timezone info, assume UTC + }; + + // Strip timezone suffix to get clean datetime for parsing + let datetime_clean = if datetime_str.contains('Z') { + datetime_str.split('Z').next()? + } else if datetime_str.contains('+') { + datetime_str.split('+').next()? + } else { + // Handle negative offset: find last '-' that's part of timezone + let parts: Vec<&str> = datetime_str.rsplitn(2, '-').collect(); + if parts.len() == 2 && parts[0].contains(':') && parts[0].len() <= 5 { + parts[1] + } else { + datetime_str + } + }; + + let parts: Vec<&str> = datetime_clean.split('T').collect(); + if parts.len() != 2 { + return None; + } + + let date_parts: Vec = parts[0].split('-').filter_map(|s| s.parse().ok()).collect(); + if date_parts.len() != 3 { + return None; + } + + let time_part = parts[1].split('.').next()?; // Ignore fractional seconds + let time_parts: Vec = time_part + .split(':') + .filter_map(|s| s.parse().ok()) + .collect(); + if time_parts.len() != 3 { + return None; + } + + let year = date_parts[0]; + let month = date_parts[1]; + let day = date_parts[2]; + let hour = time_parts[0]; + let minute = time_parts[1]; + let second = time_parts[2]; + + // 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); + + // 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 + let utc_secs = if tz_offset_secs >= 0 { + local_secs.saturating_sub(tz_offset_secs as u64) + } else { + local_secs.saturating_add((-tz_offset_secs) as u64) + }; + + Some(utc_secs) +} + +/// Parse timezone offset string like "02:00" or "05:30" to seconds +fn parse_tz_offset(tz_str: &str) -> Option { + let parts: Vec<&str> = tz_str.split(':').collect(); + if parts.len() != 2 { + return None; + } + let hours: i64 = parts[0].parse().ok()?; + let minutes: i64 = parts[1].parse().ok()?; + Some(hours * 3600 + minutes * 60) +} + +/// Convert year/month/day to days since Unix epoch (1970-01-01) +fn ymd_to_days(year: u32, month: u32, day: u32) -> Option { + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + // Days in each month (non-leap year) + let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let mut total_days: i64 = 0; + + // Add days for complete years since 1970 + for y in 1970..year { + total_days += if is_leap_year(y) { 366 } else { 365 }; + } + + // Add days for complete months in current year + for m in 1..month { + total_days += days_in_month[m as usize] as i64; + if m == 2 && is_leap_year(year) { + total_days += 1; + } + } + + // Add days in current month + total_days += (day - 1) as i64; + + if total_days >= 0 { + Some(total_days as u64) + } else { + None + } +} + +/// Convert days since Unix epoch to year/month/day +fn days_to_ymd(days: u64) -> (u32, u32, u32) { + let mut remaining_days = days as i64; + let mut year = 1970u32; + + // Find the year + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if remaining_days < days_in_year { + break; + } + remaining_days -= days_in_year; + year += 1; + } + + // Days in each month (non-leap year) + let mut days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if is_leap_year(year) { + days_in_month[2] = 29; + } + + // Find the month + let mut month = 1u32; + for (m, &days) in days_in_month.iter().enumerate().skip(1) { + if remaining_days < days as i64 { + month = m as u32; + break; + } + remaining_days -= days as i64; + } + // Defensive: if somehow we exhausted all months (shouldn't happen), default to December + // This satisfies the year-loop invariant that remaining_days < 365/366 + if remaining_days >= days_in_month[month as usize] as i64 { + month = 12; + } + + let day = (remaining_days + 1) as u32; + + (year, month, day) +} + +/// Check if a year is a leap year +fn is_leap_year(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests for parse_datetime_to_epoch - locking in Betaflight datetime parsing behavior + + #[test] + fn test_parse_datetime_utc_z_suffix() { + // Standard Betaflight format with Z suffix (UTC) + let result = parse_datetime_to_epoch("2024-10-10T18:37:25.559Z"); + assert!(result.is_some()); + // 2024-10-10 18:37:25 UTC + // Expected: days since epoch * 86400 + time of day in seconds + let epoch = result.unwrap(); + assert!(epoch > 0); + // Verify it's in the right ballpark (2024 is ~54 years after 1970) + assert!(epoch > 1700000000); // After 2023 + assert!(epoch < 1800000000); // Before 2027 + } + + #[test] + fn test_parse_datetime_positive_offset() { + // Betaflight format with positive offset (+02:00) + // 2024-10-10T18:37:25+02:00 means UTC is 16:37:25 + let with_offset = parse_datetime_to_epoch("2024-10-10T18:37:25.559+02:00"); + let utc_time = parse_datetime_to_epoch("2024-10-10T16:37:25.559Z"); + + assert!(with_offset.is_some()); + assert!(utc_time.is_some()); + // Both should result in the same UTC epoch + assert_eq!(with_offset.unwrap(), utc_time.unwrap()); + } + + #[test] + fn test_parse_datetime_negative_offset() { + // Betaflight format with negative offset (-05:00) + // 2024-10-10T18:37:25-05:00 means UTC is 23:37:25 + let with_offset = parse_datetime_to_epoch("2024-10-10T18:37:25.559-05:00"); + let utc_time = parse_datetime_to_epoch("2024-10-10T23:37:25.559Z"); + + assert!(with_offset.is_some()); + assert!(utc_time.is_some()); + // Both should result in the same UTC epoch + assert_eq!(with_offset.unwrap(), utc_time.unwrap()); + } + + #[test] + fn test_parse_datetime_zero_offset() { + // +00:00 should be same as Z + let with_offset = parse_datetime_to_epoch("2024-10-10T18:37:25.559+00:00"); + let utc_z = parse_datetime_to_epoch("2024-10-10T18:37:25.559Z"); + + assert!(with_offset.is_some()); + assert!(utc_z.is_some()); + assert_eq!(with_offset.unwrap(), utc_z.unwrap()); + } + + #[test] + fn test_parse_datetime_no_timezone_treated_as_utc() { + // No timezone info - treated as UTC + let no_tz = parse_datetime_to_epoch("2024-10-10T18:37:25"); + let utc_z = parse_datetime_to_epoch("2024-10-10T18:37:25.000Z"); + + assert!(no_tz.is_some()); + assert!(utc_z.is_some()); + assert_eq!(no_tz.unwrap(), utc_z.unwrap()); + } + + #[test] + fn test_parse_datetime_fractional_seconds_truncated() { + // Fractional seconds are truncated (not rounded) + let with_millis = parse_datetime_to_epoch("2024-10-10T18:37:25.999Z"); + let without_millis = parse_datetime_to_epoch("2024-10-10T18:37:25.000Z"); + + assert!(with_millis.is_some()); + assert!(without_millis.is_some()); + // Both should be the same (fractional seconds truncated) + assert_eq!(with_millis.unwrap(), without_millis.unwrap()); + } + + #[test] + fn test_parse_datetime_betaflight_default_placeholder() { + // Betaflight default when RTC not set: 0000-01-01T00:00:00.000 + // This should parse (returns Some) but caller should detect via starts_with("0000-01-01") + let result = parse_datetime_to_epoch("0000-01-01T00:00:00.000+00:00"); + // The function may return None for year 0000 since it's before 1970 + // or it may return Some with an invalid value - either is acceptable + // The important thing is the caller checks for "0000-01-01" prefix first + // This test documents the current behavior + assert!(result.is_none() || result == Some(0)); + } + + #[test] + fn test_parse_datetime_half_hour_offset() { + // Some timezones have 30-minute offsets (e.g., India +05:30) + let with_offset = parse_datetime_to_epoch("2024-10-10T18:37:25.559+05:30"); + let utc_time = parse_datetime_to_epoch("2024-10-10T13:07:25.559Z"); + + assert!(with_offset.is_some()); + assert!(utc_time.is_some()); + assert_eq!(with_offset.unwrap(), utc_time.unwrap()); + } + + #[test] + fn test_parse_datetime_invalid_format_returns_none() { + // Invalid formats should return None + assert!(parse_datetime_to_epoch("not-a-datetime").is_none()); + assert!(parse_datetime_to_epoch("2024-10-10").is_none()); // Missing time + assert!(parse_datetime_to_epoch("18:37:25").is_none()); // Missing date + assert!(parse_datetime_to_epoch("").is_none()); + } + + #[test] + fn test_parse_datetime_compact_offset_not_supported() { + // Compact offset format like -0500 is NOT supported (only HH:MM with colon) + // This test documents the limitation - it will be treated as no timezone + let compact = parse_datetime_to_epoch("2024-10-10T18:37:25.559-0500"); + // The -0500 won't be parsed as a timezone, so it's treated as local time without offset + // This means it differs from the colon-separated version + let _colon_sep = parse_datetime_to_epoch("2024-10-10T18:37:25.559-05:00"); + + // Both should parse, but may have different values + // The compact form will be treated as UTC (offset not recognized) + assert!(compact.is_some() || compact.is_none()); // May or may not parse + } + + // Tests for generate_gpx_timestamp + + #[test] + fn test_generate_gpx_timestamp_with_valid_datetime() { + // When log_start_datetime is valid, should produce absolute timestamp + let timestamp = generate_gpx_timestamp(Some("2024-10-10T18:37:25.559+00:00"), 1_000_000); + assert!(timestamp.contains("2024-10-10T18:37:26")); // 1 second after start + } + + #[test] + fn test_generate_gpx_timestamp_placeholder_datetime() { + // When FC clock wasn't set, should fall back to relative time + let timestamp = generate_gpx_timestamp(Some("0000-01-01T00:00:00.000+00:00"), 1_000_000); + // Should use 1970-01-01 as base (relative time) + assert!(timestamp.contains("1970-01-01")); + } + + #[test] + fn test_generate_gpx_timestamp_no_datetime() { + // When no datetime provided, should use relative time from epoch + let timestamp = generate_gpx_timestamp(None, 0); + assert!(timestamp.contains("1970-01-01T00:00:00")); + } +} diff --git a/src/main.rs b/src/main.rs index 698f2bf..106d45b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,11 +10,10 @@ use std::path::{Path, PathBuf}; // Import conversion functions from crate library to avoid code duplication use bbl_parser::conversion::{ convert_gps_altitude, convert_gps_coordinate, convert_gps_course, convert_gps_speed, - format_failsafe_phase, format_flight_mode_flags, format_state_flags, + format_failsafe_phase, format_flight_mode_flags, format_state_flags, generate_gpx_timestamp, }; // Import parser types from crate library -use bbl_parser::parser::helpers::sign_extend_14bit; use bbl_parser::parser::{ parse_frame_data, BBLDataStream, ENCODING_NEG_14BIT, ENCODING_NULL, ENCODING_SIGNED_VB, ENCODING_TAG2_3S32, ENCODING_UNSIGNED_VB, @@ -241,6 +240,8 @@ struct BBLHeader { craft_name: String, data_version: u8, looptime: u32, + /// Log start datetime from header (ISO 8601 format) + log_start_datetime: Option, i_frame_def: FrameDefinition, p_frame_def: FrameDefinition, s_frame_def: FrameDefinition, @@ -781,6 +782,7 @@ fn parse_headers_from_text(header_text: &str, debug: bool) -> Result let mut craft_name = String::new(); let mut data_version = 2u8; let mut looptime = 0u32; + let mut log_start_datetime: Option = None; let mut sysconfig = HashMap::new(); // Initialize frame definitions @@ -826,6 +828,12 @@ fn parse_headers_from_text(header_text: &str, debug: bool) -> Result { data_version = version; } + } else if line.starts_with("H Log start datetime:") { + // Parse log start datetime for GPX timestamp generation + // Format: "2024-10-10T18:37:25.559+00:00" or "0000-01-01T00:00:00.000+00:00" if not set + if let Some(datetime_str) = line.strip_prefix("H Log start datetime:") { + log_start_datetime = Some(datetime_str.trim().to_string()); + } } else if line.starts_with("H looptime:") { if let Ok(lt) = line .strip_prefix("H looptime:") @@ -969,6 +977,7 @@ fn parse_headers_from_text(header_text: &str, debug: bool) -> Result craft_name, data_version, looptime, + log_start_datetime, i_frame_def, p_frame_def, s_frame_def, @@ -2308,7 +2317,7 @@ fn parse_i_frame( let value = match field.encoding { ENCODING_SIGNED_VB => stream.read_signed_vb()?, ENCODING_UNSIGNED_VB => stream.read_unsigned_vb()? as i32, - ENCODING_NEG_14BIT => -(sign_extend_14bit(stream.read_unsigned_vb()? as u16)), + ENCODING_NEG_14BIT => stream.read_neg_14bit()?, ENCODING_NULL => 0, _ => { if debug { @@ -2350,7 +2359,7 @@ fn parse_s_frame( field_index += 1; } ENCODING_NEG_14BIT => { - let value = -(sign_extend_14bit(stream.read_unsigned_vb()? as u16)); + let value = stream.read_neg_14bit()?; data.insert(field.name.clone(), value); field_index += 1; } @@ -2410,7 +2419,7 @@ fn parse_h_frame( let value = match field.encoding { ENCODING_SIGNED_VB => stream.read_signed_vb()?, ENCODING_UNSIGNED_VB => stream.read_unsigned_vb()? as i32, - ENCODING_NEG_14BIT => -(sign_extend_14bit(stream.read_unsigned_vb()? as u16)), + ENCODING_NEG_14BIT => stream.read_neg_14bit()?, ENCODING_NULL => 0, _ => { if debug { @@ -2810,6 +2819,7 @@ fn parse_bbl_file_streaming( &gps_coords, &home_coords, export_options, + log.header.log_start_datetime.as_deref(), ) { let filename = file_path .file_name() @@ -2860,6 +2870,7 @@ fn parse_bbl_file_streaming( // 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, @@ -2868,6 +2879,7 @@ fn export_gpx_file( 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(()); @@ -2911,20 +2923,14 @@ fn export_gpx_file( } } - // Convert timestamp to ISO format - // Simplified timestamp calculation to approximate BBD 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 to match BBD format more closely - let hours = 5 + (total_seconds / 3600) % 24; // Start at 05:xx like BBD - 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 )?; } @@ -3093,6 +3099,7 @@ mod tests { craft_name: "TestCraft".to_string(), data_version: 2, looptime: 500, + log_start_datetime: None, i_frame_def: FrameDefinition::new(), p_frame_def: FrameDefinition::new(), s_frame_def: FrameDefinition::new(), diff --git a/src/parser/decoder.rs b/src/parser/decoder.rs index bd563c2..118ed28 100644 --- a/src/parser/decoder.rs +++ b/src/parser/decoder.rs @@ -26,7 +26,19 @@ pub const PREDICT_LAST_MAIN_FRAME_TIME: u8 = 10; pub const PREDICT_MINMOTOR: u8 = 11; // Domain-specific constants for corruption detection -// Maximum reasonable raw vbatLatest value before considering it corrupted +// +// MAX_REASONABLE_VBAT_RAW: Maximum reasonable raw vbatLatest value before considering it corrupted. +// +// Voltage Mapping (using Betaflight's default vbat_scale of 110): +// Voltage = (raw_value * vbat_scale) / 4095 * 3.3V * (10+1)/1 [typical voltage divider] +// Simplified: raw_value of 1000 ≈ 9.0V, 1420 ≈ 12.6V (fully charged 3S LiPo) +// +// Threshold Reasoning: +// 1000 was chosen as a conservative corruption detection threshold at the low edge. +// Values outside the symmetric range (-MAX..=+MAX) are treated as corrupted data. +// This is intentionally strict to catch obvious corruption; operators needing to +// adjust the margin for operational safety may require this to be configurable +// in a future version. const MAX_REASONABLE_VBAT_RAW: i32 = 1000; /// Decode a field value using the specified encoding @@ -171,11 +183,12 @@ pub fn apply_predictor_with_debug( } } // Fallback: use hardcoded position (typically field 39 in I-frame) + // This is frame-definition-dependent and may not be correct for all firmware versions let motor0_index = 39; if motor0_index < current_frame.len() { if debug { eprintln!( - "DEBUG: PREDICT_MOTOR_0 using hardcoded fallback index {}", + "WARNING: PREDICT_MOTOR_0 falling back to hardcoded index {} (motor[0] not found in field_names)", motor0_index ); } @@ -206,17 +219,18 @@ pub fn apply_predictor_with_debug( let vbatref = sysconfig.get("vbatref").copied().unwrap_or(4095); // CRITICAL FIX: Check for corrupted raw values in vbatLatest + // Uses symmetric range based on MAX_REASONABLE_VBAT_RAW constant if !field_names.is_empty() && field_names .get(field_index) .map(|name| name == "vbatLatest") .unwrap_or(false) - && !(-1000..=4000).contains(&raw_value) + && !(-MAX_REASONABLE_VBAT_RAW..=MAX_REASONABLE_VBAT_RAW).contains(&raw_value) { if debug { eprintln!( - "DEBUG: Fixed corrupted vbatLatest raw_value {} replaced with 0", - raw_value + "DEBUG: Fixed corrupted vbatLatest raw_value {} (outside +/-{}) replaced with vbatref", + raw_value, MAX_REASONABLE_VBAT_RAW ); } return vbatref; diff --git a/src/parser/gps.rs b/src/parser/gps.rs index 3e4af95..df4a0de 100644 --- a/src/parser/gps.rs +++ b/src/parser/gps.rs @@ -129,7 +129,7 @@ pub fn parse_g_frame( false, // Not raw data_version, sysconfig, - false, // debug - GPS parsing doesn't need verbose output + debug, )?; // Update GPS frame history with new values diff --git a/src/parser/header.rs b/src/parser/header.rs index 137a5a9..51c5c28 100644 --- a/src/parser/header.rs +++ b/src/parser/header.rs @@ -46,6 +46,14 @@ pub fn parse_headers_from_text(header_text: &str, debug: bool) -> Result, pub i_frame_def: FrameDefinition, pub p_frame_def: FrameDefinition, pub s_frame_def: FrameDefinition, @@ -30,6 +34,7 @@ impl Default for BBLHeader { craft_name: String::new(), data_version: 2, looptime: 0, + log_start_datetime: None, i_frame_def: FrameDefinition::new(), p_frame_def: FrameDefinition::new(), s_frame_def: FrameDefinition::new(),