use chrono::{DateTime, Utc}; use clap::Parser; use fitparser::profile::MesgNum; use fitparser::{self, FitDataField, FitDataRecord}; use geo::algorithm::geodesic_distance::GeodesicDistance; use geo::Point; use geo_uri::GeoUri; use serde::{Deserialize, Serialize}; use serde_json::json; use std::fs::File; use std::io::{prelude::*, stdin, stdout}; use std::process; use std::str::FromStr; #[derive(Parser, Debug)] struct Args { #[arg(short = 't', long = "category")] category: Vec, #[arg(long = "hide")] hidden_location: Vec, file: Option, } const SEMICIRCLE: f64 = 11930465.0; #[derive(Debug, Copy, Clone)] struct ExclusionPoint { center: Point, radius: f64, } impl ExclusionPoint { pub fn contains(&self, pos: &Point) -> bool { let distance = self.center.geodesic_distance(pos); distance <= self.radius } } impl FromStr for ExclusionPoint { type Err = Box; fn from_str(s: &str) -> Result { let mut it = s.split(','); let latitude = it.next().unwrap().parse()?; let longitude = it.next().unwrap().parse()?; let radius = it.next().unwrap().parse()?; Ok(ExclusionPoint { center: Point::new(longitude, latitude), radius, }) } } impl From for ExclusionPoint { fn from(s: String) -> Self { Self::from_str(&s).unwrap() } } #[derive(Serialize, Deserialize, Clone)] struct Measure { num: f64, unit: String, } fn as_measure(value: &FitDataField) -> Option { let num = match *value.value() { fitparser::Value::Byte(v) => v as f64, fitparser::Value::SInt8(v) => v as f64, fitparser::Value::UInt8(v) => v as f64, fitparser::Value::SInt16(v) => v as f64, fitparser::Value::UInt16(v) => v as f64, fitparser::Value::SInt32(v) => v as f64, fitparser::Value::UInt32(v) => v as f64, fitparser::Value::Float32(v) => v as f64, fitparser::Value::Float64(v) => v, fitparser::Value::UInt8z(v) => { if v == 0 { return None; } else { v as f64 } } fitparser::Value::UInt16z(v) => { if v == 0 { return None; } else { v as f64 } } fitparser::Value::UInt32z(v) => { if v == 0 { return None; } else { v as f64 } } fitparser::Value::SInt64(v) => v as f64, fitparser::Value::UInt64(v) => v as f64, fitparser::Value::UInt64z(v) => { if v == 0 { return None; } else { v as f64 } } fitparser::Value::Timestamp(_) => return None, fitparser::Value::Enum(_) => return None, fitparser::Value::String(_) => return None, fitparser::Value::Array(_) => return None, }; let unit = value.units().to_string(); Some(Measure { num, unit }) } #[serde_with::skip_serializing_none] #[derive(Default, Serialize, Deserialize, Clone)] struct Session { sport: Option, start: Option>, start_position: Option, end_position: Option, duration: Option, timer: Option, avg_heart_rate: Option, max_heart_rate: Option, min_temperature: Option, avg_temperature: Option, max_temperature: Option, avg_speed: Option, max_speed: Option, ascent: Option, descent: Option, calories: Option, distance: Option, #[serde(skip_serializing_if = "Vec::is_empty")] laps: Vec, } #[derive(Default, Serialize, Deserialize)] struct LapSlice { index: u64, count: u64, } #[derive(Default)] struct PositionBuilder { latitude: Option, longitude: Option, } impl PositionBuilder { pub fn latitude(&mut self, field: &FitDataField) { if let Some(value) = as_measure(field) { self.latitude = Some(value.num / SEMICIRCLE); } else { self.latitude = None } } pub fn longitude(&mut self, field: &FitDataField) { if let Some(value) = as_measure(field) { self.longitude = Some(value.num / SEMICIRCLE); } else { self.longitude = None } } pub fn as_point(&self) -> Option { if let (Some(lat), Some(lon)) = (self.latitude, self.longitude) { Some(Point::new(lon, lat)) } else { None } } } impl From for Option { fn from(val: PositionBuilder) -> Self { if let Some(lat) = val.latitude { if let Some(long) = val.longitude { return GeoUri::builder() .latitude(round_geo(lat)) .longitude(round_geo(long)) .build() .ok(); } } None } } /// Truncate GPS noise fn round_geo(x: f64) -> f64 { (x * 100000.0).round() / 100000.0 } fn parse_session(args: &Args, record: &FitDataRecord) -> (Session, LapSlice) { let mut session = Session::default(); let mut laps = LapSlice::default(); let mut start_position = PositionBuilder::default(); let mut end_position = PositionBuilder::default(); for field in record.fields() { match field.name() { "sport" => session.sport = Some(field.value().to_string()), "start_time" => session.start = as_datetime(field), "total_elapsed_time" => session.duration = as_measure(field), "total_timer_time" => session.timer = as_measure(field), "avg_heart_rate" => session.avg_heart_rate = as_measure(field), "max_heart_rate" => session.max_heart_rate = as_measure(field), "min_temperature" => session.min_temperature = as_measure(field), "avg_temperature" => session.avg_temperature = as_measure(field), "max_temperature" => session.max_temperature = as_measure(field), "enhanced_avg_speed" => session.avg_speed = as_measure(field), "enhanced_max_speed" => session.max_speed = as_measure(field), "total_ascent" => session.ascent = as_measure(field), "total_descent" => session.descent = as_measure(field), "total_calories" => session.calories = as_measure(field), "total_distance" => session.distance = as_measure(field), "first_lap_index" => laps.index = as_u64(field).unwrap_or(0), "num_laps" => laps.count = as_u64(field).unwrap_or(0), "start_position_lat" => start_position.latitude(field), "start_position_long" => start_position.longitude(field), "end_position_lat" => end_position.latitude(field), "end_position_long" => end_position.longitude(field), _ => (), } } // Just say no to hidden locations if let Some(p) = start_position.as_point() { if !args.hidden_location.iter().any(|nope| nope.contains(&p)) { session.start_position = start_position.into(); } } if let Some(p) = end_position.as_point() { if !args.hidden_location.iter().any(|nope| nope.contains(&p)) { session.end_position = end_position.into(); } } (session, laps) } fn as_datetime(field: &FitDataField) -> Option> { if let fitparser::Value::Timestamp(ts) = field.value() { Some(ts.to_utc()) } else { None } } fn as_u64(field: &FitDataField) -> Option { let value = match *field.value() { fitparser::Value::Byte(v) => v as u64, fitparser::Value::UInt8(v) => v as u64, fitparser::Value::UInt16(v) => v as u64, fitparser::Value::UInt32(v) => v as u64, fitparser::Value::UInt64(v) => v, fitparser::Value::SInt8(v) => { if v >= 0 { v as u64 } else { 0 } } fitparser::Value::SInt16(v) => { if v >= 0 { v as u64 } else { 0 } } fitparser::Value::SInt32(v) => { if v >= 0 { v as u64 } else { 0 } } fitparser::Value::SInt64(v) => { if v >= 0 { v as u64 } else { 0 } } fitparser::Value::Float32(_) => return None, fitparser::Value::Float64(_) => return None, fitparser::Value::UInt8z(v) => { if v == 0 { return None; } else { v as u64 } } fitparser::Value::UInt16z(v) => { if v == 0 { return None; } else { v as u64 } } fitparser::Value::UInt32z(v) => { if v == 0 { return None; } else { v as u64 } } fitparser::Value::UInt64z(v) => { if v == 0 { return None; } else { v } } fitparser::Value::Timestamp(_) => return None, fitparser::Value::Enum(_) => return None, fitparser::Value::String(_) => return None, fitparser::Value::Array(_) => return None, }; Some(value) } fn run() -> Result<(), Box> { let args = Args::parse(); let mut reader: Box = match args.file { Some(ref filename) => Box::new(File::open(filename)?), None => Box::new(stdin()), }; let mut sessions = Vec::new(); let mut laps = Vec::new(); for msg in fitparser::from_reader(&mut reader)? { match msg.kind() { MesgNum::Session => { sessions.push(parse_session(&args, &msg)); } MesgNum::Lap => { laps.push(parse_session(&args, &msg)); } _ => (), } } let activity: Vec = sessions .into_iter() .map(|(mut s, l)| { let start = l.index as usize; let end = (l.index + l.count) as usize; if end > start { s.laps = laps[start..end].iter().map(|(s, _)| s.clone()).collect(); s } else { s } }) .collect(); let location = activity .iter() .flat_map(|s| s.start_position) .map(|uri| uri.to_string()) .take(1) .collect(); let properties = EntryProperties { published: activity.iter().flat_map(|s| s.start).min(), category: args.category.clone(), location, activity, }; let output = json!({ "type": ["h-entry"], "properties": properties }); serde_json::to_writer(stdout(), &output)?; Ok(()) } #[serde_with::skip_serializing_none] #[derive(Default, Serialize, Deserialize, Clone)] struct EntryProperties { published: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] category: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] location: Vec, activity: Vec, } fn main() { process::exit(match run() { Ok(_) => 0, Err(e) => { eprintln!("Error: {e}"); 1 } }) }