diff --git a/Cargo.lock b/Cargo.lock index a828b9e..0a4bf39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,48 +60,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "array-init" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" - [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" -[[package]] -name = "binrw" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173901312e9850391d4d7c1318c4e099fdc037d61870fca427429830efdb4e5f" -dependencies = [ - "array-init", - "binrw_derive", - "bytemuck", -] - -[[package]] -name = "binrw_derive" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb515fdd6f8d3a357c8e19b8ec59ef53880807864329b1cb1cba5c53bf76557e" -dependencies = [ - "either", - "owo-colors", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bytemuck" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" - [[package]] name = "clap" version = "4.5.8" @@ -133,7 +97,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.68", + "syn", ] [[package]] @@ -149,48 +113,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] -name = "copyless" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2df960f5d869b2dd8532793fde43eb5427cceb126c929747a26823ab0eeb536" - -[[package]] -name = "deranged" -version = "0.3.11" +name = "csv" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" dependencies = [ - "powerfmt", + "csv-core", + "itoa", + "ryu", + "serde", ] [[package]] -name = "either" -version = "1.13.0" +name = "csv-core" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] [[package]] -name = "fit-rust" -version = "0.1.8" +name = "deranged" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c542bdd1281334d7fb81df835bce95147cd926d6f3cc175f2764c71b49214b08" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ - "binrw", - "copyless", - "paste", + "powerfmt", ] [[package]] name = "fit2gpx" -version = "0.1.2" +version = "0.2.0" dependencies = [ "clap", - "fit-rust", + "fit_file", "geo-types", "gpx", "time", ] +[[package]] +name = "fit_file" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c392291c055671190fc15e037402d8273eafdf73a1eeb1ca5b74fb5babe34a" +dependencies = [ + "csv", +] + [[package]] name = "geo-types" version = "0.7.13" @@ -238,6 +209,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "num-conv" version = "0.1.0" @@ -254,18 +231,6 @@ dependencies = [ "libm", ] -[[package]] -name = "owo-colors" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "powerfmt" version = "0.2.0" @@ -290,6 +255,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "serde" version = "1.0.203" @@ -307,7 +278,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn", ] [[package]] @@ -316,17 +287,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.68" @@ -355,7 +315,7 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c3a42b7..91d03c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fit2gpx" -version = "0.1.2" +version = "0.2.0" edition = "2021" authors = ["Jeromos Kovács "] @@ -14,7 +14,7 @@ license = "MIT" [dependencies] clap = { version = "4.5.8", features = ["derive"] } -fit-rust = "0.1.8" +fit_file = "0.6.0" geo-types = "0.7.13" gpx = "0.10.0" time = { version = "0.3.36", default-features = false } diff --git a/README.md b/README.md index f098f95..b2090ca 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,22 @@ ## Purpose This is a simple Rust library and binary for converting .FIT files to .GPX files. -A faster alternative to [fit2gpx](https://github.com/dodo-saba/fit2gpx) +A ***faster*** alternative to [fit2gpx](https://github.com/dodo-saba/fit2gpx) - [FIT](https://developer.garmin.com/fit/overview/) is a GIS data file format used by Garmin GPS sport devices and Garmin software - [GPX](https://docs.fileformat.com/gis/gpx/) is an XML based format for GPS tracks. ## Why another one -- cause it's 70 times as fast +- cause it's about 80 times as fast - cause it's fun ## Why not this one - it doesn't support strava bulk-export stuff -## Deps +## Direct dependencies -- [fit-rust](https://crates.io/crates/fit-rust) +- [fit_file](https://crates.io/crates/fit_file) - [gpx](https://crates.io/crates/gpx) +- [clap](https://crates.io/crates/clap) diff --git a/src/main.rs b/src/main.rs index f307070..0f85acd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,7 @@ use clap::Parser; -use fit_rust::{ - protocol::{message_type::MessageType, value::Value, DataMessage, FitMessage}, - Fit, -}; use geo_types::{coord, Point}; use gpx::{Gpx, GpxVersion, Track, TrackSegment, Waypoint}; -use std::{fs, fs::File, io::BufWriter}; +use std::{fs::File, io::BufWriter}; use time::OffsetDateTime; type Res = Result>; @@ -16,183 +12,137 @@ struct Args { pub files: Vec, } -#[derive(Clone, Copy, Default, Debug, PartialEq)] -struct RecordData { - /// latitude - pub lat: Option, - /// longitude - pub lon: Option, - /// altitude - pub alt: Option, - // heart-rate - pub hr: Option, - /// timestamp - pub time: Option, - - pub cadence: Option, - pub distance: Option, - pub speed: Option, - pub power: Option, - pub temperature: Option, - pub right_balance: Option, -} -impl RecordData { - // crazy check - fn invalid(&self) -> bool { - self.lat.is_none() && self.lon.is_none() - } -} -impl From for RecordData { - fn from(value: DataMessage) -> Self { - if let MessageType::Record = value.message_type { - let lat = value_to_float(df_at(&value, 0)); - let lon = value_to_float(df_at(&value, 1)); - let alt = value_to_float(df_at(&value, 2)).map(|alt| alt / 5. - 500.); - - let hr = value_to_float(df_at(&value, 3)); - let hr: Option = hr.map(|hr| hr as u8); - - let cadence = value_to_float(df_at(&value, 4)); - let cadence: Option = cadence.map(|cad| cad as u8); - - let distance = value_to_float(df_at(&value, 5)).map(|d| d / 100000.); - - let speed = value_to_float(df_at(&value, 6)).map(|v| v / 1000. * 3.6); - - let power = value_to_float(df_at(&value, 7)); - let power = power.map(|power| power as u16); - - let temperature = value_to_float(df_at(&value, 13)); - let temperature = temperature.map(|temperature| temperature as i8); - - let right_balance = value_to_float(df_at(&value, 30)); - let right_balance = right_balance.map(|right_balance| right_balance as u8); - - let t = df_at(&value, 253); - let time = if let Some(Value::Time(t)) = t { - if let Ok(t) = OffsetDateTime::from_unix_timestamp((*t).into()) { - Some(t) - } else { - None - } - } else { - None - }; - - RecordData { - lat, - lon, - alt, - hr, - time, - cadence, - distance, - speed, - power, - temperature, - right_balance, - } - } else { - RecordData::default() - } +// FitRecordMsg to gpx Waypoint +fn frm_to_gwp(frm: FitRecordMsg) -> Waypoint { + // "Time: {}\tLat: {}\tLon: {}\tAlt: {:.1}m\tDist: {:.3}km\tSpeed: {:.1}km/h\tHR: {}", + // eprintln!("time: {:?}", frm.timestamp); + let time = frm.timestamp.unwrap_or(0); + let time = OffsetDateTime::from_unix_timestamp(time.into()).ok(); + + let lat = fit_file::semicircles_to_degrees(frm.position_lat.unwrap_or(0)); + let lon = fit_file::semicircles_to_degrees(frm.position_long.unwrap_or(0)); + + let alt = if let Some(enh_alt) = frm.enhanced_altitude { + Some(enh_alt) + } else { + frm.altitude.map(|alt| alt.into()) } -} -impl From for Waypoint { - fn from(value: RecordData) -> Self { - let geo_point: Point = - Point(coord! {x: value.lon.unwrap_or(0.), y: value.lat.unwrap_or(0.)}); + .map(|alt| alt as f32 / 5. - 500.); - let mut wp = Waypoint::new(geo_point); - wp.elevation = value.alt; - wp.time = value.time.map(|t| t.into()); - wp.speed = value.speed; + // let dist = frm.distance.unwrap_or(0) as f32 / 100000.; - wp + let speed = if let Some(enh_spd) = frm.enhanced_speed { + Some(enh_spd) + } else { + frm.speed.map(|spd| spd.into()) } + .map(|spd| spd as f64); + // .map(|spd| spd as f64 / 1000. * 3.6); + + // let hr = frm + // .heart_rate + // .map(|hr| hr.checked_add(1)) + // .unwrap_or(Some(0)) + // .unwrap_or(0); + + let geo_point: Point = Point(coord! {x: lon, y: lat}); + + let mut wp = Waypoint::new(geo_point); + + wp.elevation = alt.map(|alt| alt.into()); + wp.time = time.map(|t| t.into()); + wp.speed = speed; + + wp } -fn main() { - // collecting cli args - let args = Args::parse(); +fn no_lat_lon(frm: &FitRecordMsg) -> bool { + frm.position_long.is_none() && frm.position_lat.is_none() +} - let mut handles = vec![]; - for file in args.files.iter() { - if !file.ends_with(".fit") { - eprintln!("invalid file: {file:?}"); - continue; +use fit_file::{fit_file, FitRecordMsg, FitSessionMsg}; + +/// Called for each record message as it is processed. +fn callback( + timestamp: u32, + global_message_num: u16, + _local_msg_type: u8, + _message_index: u16, + fields: Vec, + data: &mut Context, +) { + if global_message_num == fit_file::GLOBAL_MSG_NUM_DEVICE_INFO { + // let msg = FitDeviceInfoMsg::new(fields); + // println!("{msg:#?}"); + } else if global_message_num == fit_file::GLOBAL_MSG_NUM_SESSION { + let msg = FitSessionMsg::new(fields); + let sport_names = fit_file::init_sport_name_map(); + let sport_id = msg.sport.unwrap(); + + println!("Sport: {}", sport_names.get(&sport_id).unwrap()); + } else if global_message_num == fit_file::GLOBAL_MSG_NUM_RECORD { + let mut msg = FitRecordMsg::new(fields); + + data.num_records_processed += 1; + + if let Some(ts) = msg.timestamp { + assert_eq!(timestamp, ts); + } else { + msg.timestamp = Some(timestamp); } - let file = file.clone(); - let jh = std::thread::spawn(move || { - let _ = fit2gpx(&file).inspect_err(|e| eprintln!("error: {e:#?}")); - }); - handles.push(jh); - } - for handle in handles { - let res = handle.join(); - if let Err(e) = res { - eprintln!("error: {e:#?}"); - continue; + + if no_lat_lon(&msg) { + data.no_lat_lon_sum += 1; } + + // println!( + // "timestamp: {:?}|{:?}|{:?}|{}", + // msg.timestamp, msg.time128, msg.time_from_course, timestamp + // ); + let wp = frm_to_gwp(msg); + data.track_segment.points.push(wp); + // assert!(msg.timestamp.is_some()); + + // println!("{msg:#?}"); + + // println!( + // // "Timestamp: {} Latitude: {} Longitude: {} Altitude: {} Distance: {} Speed: {} HeartRate: {}", + // "Time: {}\tLat: {}\tLon: {}\tAlt: {:.1}m\tDist: {:.3}km\tSpeed: {:.1}km/h\tHR: {}", + // msg.timestamp.unwrap_or(0), + // fit_file::semicircles_to_degrees(msg.position_lat.unwrap_or(0)), + // fit_file::semicircles_to_degrees(msg.position_long.unwrap_or(0)), + // msg.enhanced_altitude.unwrap_or(0) as f32 / 5. - 500., + // msg.distance.unwrap_or(0) as f32 / 100000., + // msg.enhanced_speed.unwrap_or(0) as f32 / 1000. * 3.6, + // msg.heart_rate + // .map(|hr| hr.checked_add(1)) + // .unwrap_or(Some(0)) + // .unwrap_or(0), + // ); } } -fn fit2gpx(f_in: &String) -> Res<()> { - let file = fs::read(f_in)?; - let fit: Fit = Fit::read(file)?; - eprintln!("file: {f_in}"); - // let mut log_file = File::create([f_in, ".log"].concat())?; - - // println!("\n\nHEADER:"); - // println!("\theader size: {}", &fit.header.header_size); - // println!("\tprotocol version: {}", &fit.header.protocol_version); - // println!("\tprofile version: {}", &fit.header.profile_version); - // println!("\tdata_size: {}", &fit.header.data_size); - // println!("\tdata_type: {}", &fit.header.data_type); - // println!("\tcrc: {:?}", &fit.header.crc); - // println!("-----------------------------\n"); - - let mut track_segment = TrackSegment { points: vec![] }; - let mut ongoing_activity = true; - - for data in &fit.data { - match data { - FitMessage::Definition(_msg) => { - // println!("\nDefinition: {:#?}", msg.data); - } - FitMessage::Data(msg) => { - // writeln!(log_file, "\nData: {msg:#?}")?; - // println!("\nData: {:#?}", msg); - if let MessageType::Record = msg.data.message_type { - let rec_dat: RecordData = msg.data.clone().into(); - if rec_dat.invalid() { - eprintln!("doesn't contain lat/lon data: {:?}", msg.data); - continue; - } - // eprintln!("{rec_dat:#?}"); - - // Add track point - let wp: Waypoint = rec_dat.into(); - // if wp.point().x_y() == (0., 0.) { - // eprintln!("warn: guess it's invalid: {msg:#?}"); - // } - if ongoing_activity { - track_segment.points.push(wp); - } else { - eprintln!("warn: NOT in an activity right now"); - // std::io::stdin().read_line(&mut String::new())?; - } - } else if let MessageType::Activity = msg.data.message_type { - let start_stop = df_at(&msg.data, 4); - if let Some(Value::Enum(start_stop)) = start_stop { - if start_stop == &"start" { - ongoing_activity = true; - } else if start_stop == &"stop" { - ongoing_activity = false; - } - } - } - } - } +/// Context structure. An instance of this will be passed to the parser and ultimately to the callback function so we can use it for whatever. +#[derive(Default)] +struct Context { + no_lat_lon_sum: u32, + num_records_processed: u16, + track_segment: TrackSegment, +} + +fn fit2gpx(f_in: &str) -> Res<()> { + let file = std::fs::File::open(f_in)?; + + let mut reader = std::io::BufReader::new(file); + let mut cx = Context::default(); + fit_file::read(&mut reader, callback, &mut cx)?; + + let percent_no_lat_lon = cx.no_lat_lon_sum as f32 / cx.track_segment.points.len() as f32; + if cx.no_lat_lon_sum > 0 && percent_no_lat_lon < 0.9 { + eprintln!("less than 90% ({} out of {} = {}) doesn't contain latitude and longitude => deleting these points", cx.no_lat_lon_sum, cx.track_segment.points.len(), percent_no_lat_lon); + cx.track_segment + .points + .retain(|point| point.point().x_y() != (0., 0.)); } // Instantiate Gpx struct @@ -204,7 +154,7 @@ fn fit2gpx(f_in: &String) -> Res<()> { links: vec![], type_: None, number: None, - segments: vec![track_segment], + segments: vec![cx.track_segment], }; let gpx = Gpx { version: GpxVersion::Gpx11, @@ -222,39 +172,58 @@ fn fit2gpx(f_in: &String) -> Res<()> { // Write to file gpx::write(&gpx, buf)?; - + println!("{} records processed", cx.num_records_processed); Ok(()) } -fn value_to_float(val: Option<&Value>) -> Option { - match val { - Some(Value::U8(x)) => Some(*x as f64), - Some(Value::U16(x)) => Some(*x as f64), - Some(Value::U32(x)) => Some(*x as f64), - Some(Value::U64(x)) => Some(*x as f64), - - Some(Value::I8(x)) => Some(*x as f64), - Some(Value::I16(x)) => Some(*x as f64), - Some(Value::I32(x)) => Some(*x as f64), - Some(Value::I64(x)) => Some(*x as f64), - - Some(Value::F32(x)) => Some(*x as f64), - Some(Value::F64(x)) => Some(*x), - _x => { - // eprintln!("invalid f64: {x:?}"); - None - } - } -} +// if let MessageType::Record = msg.data.message_type { +// let rec_dat: RecordData = msg.data.clone().into(); +// if rec_dat.no_lat_lon() { +// no_lat_lon_sum += 1; +// // eprintln!("doesn't contain lat/lon data: {:?}", msg.data); +// // continue; +// } +// // eprintln!("{rec_dat:#?}"); +// // Add track point +// let wp: Waypoint = rec_dat.into(); +// // if wp.point().x_y() == (0., 0.) { +// // eprintln!("warn: guess it's invalid: {msg:#?}"); +// // } +// if ongoing_activity { +// track_segment.points.push(wp); +// } else { +// eprintln!("warn: NOT in an activity right now"); +// // std::io::stdin().read_line(&mut String::new())?; +// } +// } else if let MessageType::Activity = msg.data.message_type { +// let start_stop = df_at(&msg.data, 4); +// if let Some(Value::Enum(start_stop)) = start_stop { +// if start_stop == &"start" { +// ongoing_activity = true; +// } else if start_stop == &"stop" { +// ongoing_activity = false; -/// datafield at num -fn df_at(data_msg: &DataMessage, num: u8) -> Option<&Value> { - // eprintln!("data-msg: {data_msg:#?}"); - let x = data_msg - .values - .iter() - .filter(|df| df.field_num == num) - .collect::>(); +fn main() { + // collecting cli args + let args = Args::parse(); - Some(&x.first()?.value) + let mut handles = vec![]; + for file in args.files.iter() { + if !file.ends_with(".fit") { + eprintln!("invalid file: {file:?}"); + continue; + } + let file = file.clone(); + let jh = std::thread::spawn(move || { + let _ = fit2gpx(&file).inspect_err(|e| eprintln!("error: {e:#?}")); + }); + handles.push(jh); + } + for handle in handles { + let res = handle.join(); + if let Err(e) = res { + eprintln!("error: {e:#?}"); + continue; + } + } }