aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs406
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
+ }
+ })
+}