diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d2c8f0d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,406 @@ +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<String>, + + #[arg(long = "hide")] + hidden_location: Vec<ExclusionPoint>, + + file: Option<String>, +} + +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<dyn std::error::Error>; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + 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<String> 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<Measure> { + 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<String>, + start: Option<DateTime<Utc>>, + start_position: Option<GeoUri>, + end_position: Option<GeoUri>, + duration: Option<Measure>, + timer: Option<Measure>, + avg_heart_rate: Option<Measure>, + max_heart_rate: Option<Measure>, + min_temperature: Option<Measure>, + avg_temperature: Option<Measure>, + max_temperature: Option<Measure>, + avg_speed: Option<Measure>, + max_speed: Option<Measure>, + ascent: Option<Measure>, + descent: Option<Measure>, + calories: Option<Measure>, + distance: Option<Measure>, + #[serde(skip_serializing_if = "Vec::is_empty")] + laps: Vec<Session>, +} + +#[derive(Default, Serialize, Deserialize)] +struct LapSlice { + index: u64, + count: u64, +} + +#[derive(Default)] +struct PositionBuilder { + latitude: Option<f64>, + longitude: Option<f64>, +} + +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<Point> { + if let (Some(lat), Some(lon)) = (self.latitude, self.longitude) { + Some(Point::new(lon, lat)) + } else { + None + } + } +} + +impl From<PositionBuilder> for Option<GeoUri> { + 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<DateTime<Utc>> { + if let fitparser::Value::Timestamp(ts) = field.value() { + Some(ts.to_utc()) + } else { + None + } +} + +fn as_u64(field: &FitDataField) -> Option<u64> { + 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<dyn std::error::Error>> { + let args = Args::parse(); + let mut reader: Box<dyn Read> = 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<Session> = 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<DateTime<Utc>>, + #[serde(skip_serializing_if = "Vec::is_empty")] + category: Vec<String>, + #[serde(skip_serializing_if = "Vec::is_empty")] + location: Vec<String>, + activity: Vec<Session>, +} + +fn main() { + process::exit(match run() { + Ok(_) => 0, + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + }) +} |
