Devices

Garmin FIT files

Garmin FIT files from watches and bike computers read: the activity's distance, time, pace, heart rate, cadence, power and ascent, its laps, the track's points, any message as data, and the track as GPX.

Version 1.0.0 · 5 functions · 2 crates

#sports#gps#files#sensors#health

Functions

What use fit.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use fit_summary, fit_laps from fit (Quillo 0.6).

fit_summary(path: str) → anyor an error

The activity in one map: { sport, sub_sport, start, distance_km, duration_s, moving_s, avg_speed_kmh, max_speed_kmh, pace, avg_hr, max_hr, avg_cadence, avg_power, max_power, ascent_m, descent_m, calories, laps, records, device: { manufacturer, product } }. From the file's session message when it has one, else worked out from the records.

fit_laps(path: str) → listor an error

The laps: [{ lap, start, distance_km, duration_s, avg_speed_kmh, pace, avg_hr, max_hr, avg_cadence, avg_power }].

fit_records(path: str, every: int) → listor an error

The record points, every every-th one (1 for all): [{ time, lat, lon, altitude, heart_rate, cadence, speed_kmh, distance_m, power, temperature }], fields a device did not record left out.

fit_messages(path: str, kind: str) → listor an error

Every message of a kind as data, with the names and units of Garmin's profile: kind like "session", "lap", "record", "event", "device_info", "file_id", "hrv", "sport". At most 10,000.

fit_gpx(path: str, name: str) → stror an error

The track as GPX 1.1 (with Garmin's heart rate and cadence extensions), for maps and other apps.

Get it

$ quillo install fit               # fit.qwer into this folder$ quillo install fit --example     # and its example, fit.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/fit.qwer$ curl -O https://quillolang.org/plugins/files/fit.qwe$ curl -O https://quillolang.org/plugins/files/fit_sample.fit

Then, in your program, all of it or only what you call:

main.qwe
use fit.qweruse fit_summary, fit_laps from fit      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
fitparser = "0.9"chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }

Example

fit.qwe
// A running club's site: members upload their watch's FIT file; the run is summed up (distance,// time, pace, heart rate, ascent), its laps listed, its track turned into points for a map and// into GPX for other apps.////   quillo run fit.qwe      then open http://localhost:8080/run and http://localhost:8080/track.gpxuse "fit.qwer"let FILE = "fit_sample.fit"route GET /run = { summary: fit_summary(FILE)?, laps: fit_laps(FILE)? }route GET /points = { first: slice(fit_records(FILE, 1)?, 0, 2), every_minute: len(fit_records(FILE, 12)?) }route GET /track.gpx = respond(200, fit_gpx(FILE, "Thames loop")?, { "content-type": "application/gpx+xml" })route GET /session = fit_messages(FILE, "session")?
$ quillo run fit.qwe$ curl 'localhost:8080/run'$ curl 'localhost:8080/points'$ curl 'localhost:8080/track.gpx'

Source

fit.qwer
//! Garmin FIT files read (from watches, bike computers, Wahoo, Zwift, Strava exports): the activity's summary (sport, distance, time, pace and speed, heart rate, cadence, power, ascent, calories), its laps, the record points with position, altitude, heart rate, cadence, speed and power, any message as data, and the track as GPX.//! version: 1.0.0//!//! [dependencies]//! fitparser = "0.9"//! chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }use fitparser::profile::MesgNum;use fitparser::Value as F;use serde_json::{json, Map, Value};const SEMI: f64 = 180.0 / 2147483648.0;fn val(v: &F) -> Value {    match v {        F::Timestamp(t) => json!(t.with_timezone(&chrono::Utc).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),        F::Byte(x) | F::Enum(x) | F::UInt8(x) | F::UInt8z(x) => json!(x),        F::SInt8(x) => json!(x),        F::SInt16(x) => json!(x),        F::UInt16(x) | F::UInt16z(x) => json!(x),        F::SInt32(x) => json!(x),        F::UInt32(x) | F::UInt32z(x) => json!(x),        F::SInt64(x) => json!(x),        F::UInt64(x) | F::UInt64z(x) => json!(x),        F::Float32(x) => json!((*x as f64 * 1e6).round() / 1e6),        F::Float64(x) => json!((x * 1e6).round() / 1e6),        F::String(s) => json!(s),        F::Array(a) => Value::Array(a.iter().map(val).collect()),        F::Invalid => Value::Null,    }}/// A message's fields as a map; positions in semicircles turned into degrees.fn fields(r: &fitparser::FitDataRecord) -> Map<String, Value> {    let mut m = Map::new();    for f in r.fields() {        let mut v = val(f.value());        if f.units() == "semicircles" {            if let Some(x) = v.as_f64() {                v = json!((x * SEMI * 1e7).round() / 1e7);            }        }        if !v.is_null() {            m.insert(f.name().to_string(), v);        }    }    m}fn read(path: &str) -> Result<Vec<fitparser::FitDataRecord>, String> {    let mut f = std::fs::File::open(path).map_err(|e| format!("{}: {}", path, e))?;    fitparser::from_reader(&mut f).map_err(|e| format!("{}: not a FIT file that reads: {}", path, e))}fn of_kind(recs: &[fitparser::FitDataRecord], k: MesgNum) -> Vec<Map<String, Value>> {    recs.iter().filter(|r| r.kind() == k).map(fields).collect()}fn num(m: &Map<String, Value>, k: &str) -> Option<f64> {    m.get(k).and_then(Value::as_f64)}fn pace(speed: Option<f64>) -> Value {    match speed {        Some(s) if s > 0.0 => {            let spk = 1000.0 / s;            json!(format!("{}:{:02}/km", (spk / 60.0).floor(), (spk % 60.0).round() as i64 % 60))        }        _ => Value::Null,    }}/// The activity in one map: `{ sport, sub_sport, start, distance_km, duration_s, moving_s, avg_speed_kmh, max_speed_kmh, pace, avg_hr, max_hr, avg_cadence, avg_power, max_power, ascent_m, descent_m, calories, laps, records, device: { manufacturer, product } }`./// From the file's session message when it has one, else worked out from the records.pub fn fit_summary(path: &str) -> Result<serde_json::Value, String> {    let recs = read(path)?;    let points = of_kind(&recs, MesgNum::Record);    let laps = of_kind(&recs, MesgNum::Lap);    let file_id = of_kind(&recs, MesgNum::FileId).into_iter().next().unwrap_or_default();    let s = of_kind(&recs, MesgNum::Session).into_iter().next();    let r1 = |x: Option<f64>, d: f64| x.map(|v| (v * d).round() / d);    let mut out = match &s {        Some(s) => {            let avg = num(s, "enhanced_avg_speed").or(num(s, "avg_speed"));            json!({                "sport": s.get("sport"), "sub_sport": s.get("sub_sport"), "start": s.get("start_time"),                "distance_km": r1(num(s, "total_distance").map(|d| d / 1000.0), 1000.0),                "duration_s": r1(num(s, "total_elapsed_time"), 1.0), "moving_s": r1(num(s, "total_timer_time"), 1.0),                "avg_speed_kmh": r1(avg.map(|v| v * 3.6), 100.0), "max_speed_kmh": r1(num(s, "enhanced_max_speed").or(num(s, "max_speed")).map(|v| v * 3.6), 100.0), "pace": pace(avg),                "avg_hr": s.get("avg_heart_rate"), "max_hr": s.get("max_heart_rate"), "avg_cadence": s.get("avg_cadence").or(s.get("avg_running_cadence")),                "avg_power": s.get("avg_power"), "max_power": s.get("max_power"), "ascent_m": s.get("total_ascent"), "descent_m": s.get("total_descent"), "calories": s.get("total_calories"),            })        }        None => json!({}),    };    // What the session leaves out, from the records.    let hrs: Vec<f64> = points.iter().filter_map(|p| num(p, "heart_rate")).collect();    let alts: Vec<f64> = points.iter().filter_map(|p| num(p, "enhanced_altitude").or(num(p, "altitude"))).collect();    let fill = |out: &mut Value, k: &str, v: Value| {        if out[k].is_null() && !v.is_null() {            out[k] = v;        }    };    if !hrs.is_empty() {        fill(&mut out, "avg_hr", json!((hrs.iter().sum::<f64>() / hrs.len() as f64).round()));        fill(&mut out, "max_hr", json!(hrs.iter().cloned().fold(0.0, f64::max)));    }    if alts.len() > 1 {        let (mut up, mut down) = (0.0, 0.0);        for w in alts.windows(2) {            let d = w[1] - w[0];            if d > 0.0 { up += d } else { down -= d }        }        fill(&mut out, "ascent_m", json!(up.round()));        fill(&mut out, "descent_m", json!(down.round()));    }    if let (Some(first), Some(last)) = (points.first(), points.last()) {        fill(&mut out, "start", first.get("timestamp").cloned().unwrap_or(Value::Null));        fill(&mut out, "distance_km", json!(r1(num(last, "distance").map(|d| d / 1000.0), 1000.0)));    }    out["laps"] = json!(laps.len());    out["records"] = json!(points.len());    out["device"] = json!({ "manufacturer": file_id.get("manufacturer"), "product": file_id.get("garmin_product").or(file_id.get("product")), "serial": file_id.get("serial_number"), "type": file_id.get("type") });    Ok(out)}/// The laps: `[{ lap, start, distance_km, duration_s, avg_speed_kmh, pace, avg_hr, max_hr, avg_cadence, avg_power }]`.pub fn fit_laps(path: &str) -> Result<Vec<serde_json::Value>, String> {    let recs = read(path)?;    Ok(of_kind(&recs, MesgNum::Lap)        .iter()        .enumerate()        .map(|(i, l)| {            let avg = num(l, "enhanced_avg_speed").or(num(l, "avg_speed"));            json!({                "lap": i + 1, "start": l.get("start_time"), "distance_km": num(l, "total_distance").map(|d| (d / 1000.0 * 1000.0).round() / 1000.0),                "duration_s": num(l, "total_elapsed_time").map(f64::round), "avg_speed_kmh": avg.map(|v| (v * 360.0).round() / 100.0), "pace": pace(avg),                "avg_hr": l.get("avg_heart_rate"), "max_hr": l.get("max_heart_rate"), "avg_cadence": l.get("avg_cadence").or(l.get("avg_running_cadence")), "avg_power": l.get("avg_power"),            })        })        .collect())}/// The record points, every `every`-th one (1 for all): `[{ time, lat, lon, altitude, heart_rate, cadence, speed_kmh, distance_m, power, temperature }]`, fields a device did not record left out.pub fn fit_records(path: &str, every: i64) -> Result<Vec<serde_json::Value>, String> {    let recs = read(path)?;    let every = every.max(1) as usize;    Ok(of_kind(&recs, MesgNum::Record)        .iter()        .step_by(every)        .map(|p| {            let mut m = Map::new();            let mut put = |k: &str, v: Option<Value>| {                if let Some(v) = v.filter(|v| !v.is_null()) {                    m.insert(k.into(), v);                }            };            put("time", p.get("timestamp").cloned());            put("lat", p.get("position_lat").cloned());            put("lon", p.get("position_long").cloned());            put("altitude", num(p, "enhanced_altitude").or(num(p, "altitude")).map(|a| json!((a * 10.0).round() / 10.0)));            put("heart_rate", p.get("heart_rate").cloned());            put("cadence", p.get("cadence").cloned());            put("speed_kmh", num(p, "enhanced_speed").or(num(p, "speed")).map(|s| json!((s * 360.0).round() / 100.0)));            put("distance_m", num(p, "distance").map(|d| json!((d * 10.0).round() / 10.0)));            put("power", p.get("power").cloned());            put("temperature", p.get("temperature").cloned());            Value::Object(m)        })        .collect())}/// Every message of a kind as data, with the names and units of Garmin's profile: `kind` like "session", "lap", "record", "event", "device_info", "file_id", "hrv", "sport". At most 10,000.pub fn fit_messages(path: &str, kind: &str) -> Result<Vec<serde_json::Value>, String> {    let recs = read(path)?;    let want = kind.replace(['_', ' '], "").to_lowercase();    Ok(recs.iter().filter(|r| format!("{:?}", r.kind()).to_lowercase() == want).take(10_000).map(|r| Value::Object(fields(r))).collect())}/// The track as GPX 1.1 (with Garmin's heart rate and cadence extensions), for maps and other apps.pub fn fit_gpx(path: &str, name: &str) -> Result<String, String> {    let pts = fit_records(path, 1)?;    let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");    let mut g = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\" creator=\"Quillo fit plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\" xmlns:gpxtpx=\"http://www.garmin.com/xmlschemas/TrackPointExtension/v1\">\n");    g.push_str(&format!("<trk><name>{}</name><trkseg>\n", esc(name)));    for p in pts.iter().filter(|p| p["lat"].is_number() && p["lon"].is_number()) {        g.push_str(&format!("<trkpt lat=\"{}\" lon=\"{}\">", p["lat"], p["lon"]));        if p["altitude"].is_number() {            g.push_str(&format!("<ele>{}</ele>", p["altitude"]));        }        if let Some(t) = p["time"].as_str() {            g.push_str(&format!("<time>{}</time>", t));        }        if p["heart_rate"].is_number() || p["cadence"].is_number() {            g.push_str("<extensions><gpxtpx:TrackPointExtension>");            if p["heart_rate"].is_number() {                g.push_str(&format!("<gpxtpx:hr>{}</gpxtpx:hr>", p["heart_rate"]));            }            if p["cadence"].is_number() {                g.push_str(&format!("<gpxtpx:cad>{}</gpxtpx:cad>", p["cadence"]));            }            g.push_str("</gpxtpx:TrackPointExtension></extensions>");        }        g.push_str("</trkpt>\n");    }    g.push_str("</trkseg></trk>\n</gpx>\n");    Ok(g)}
GPX tracks gpx.qwer · 1.0.0GPX tracks from watches and phones read: distance, climbing, duration, moving pace, per-kilometre splits, and the points for a map line.gpssportsmapsfilesimport7 views · 8 downloadsAircraft ADS-B adsb.qwer · 1.0.0Aircraft ADS-B and Mode S messages from a 1090 MHz receiver decoded: call signs, altitudes, positions from even and odd pairs or near the receiver, speeds, tracks, vertical rates and squawks, CRC-checked, and the aircraft seen put together.sensorslocationtrackinggpssignals7 views · 7 downloadsShip AIS messages ais.qwer · 1.0.0Ship AIS messages decoded from a receiver's NMEA lines: positions, speeds and courses, names, call signs, sizes, destinations and ETAs, class B yachts and navigation marks, multi-part messages joined and checksums checked.marinegpslocationsensorstracking6 views · 7 downloadsDisk space disk_space.qwer · 1.0.0Free and total space of the disk a path is on, for health checks, refusing uploads before the disk fills, and admin pages.fileshealthadminuploads8 views · 8 downloadsCAN bus and DBC files can_dbc.qwer · 1.0.0CAN bus frames made readable with a DBC file: messages and signals read with Intel or Motorola layout, scale, units, value names and multiplexing; frames decoded, frames built from values, and candump logs decoded.vehiclescarssensorsiotsignals8 views · 9 downloadsG-code for 3D printers gcode.qwer · 1.0.0G-code from 3D printers and CNC machines read: print time with acceleration, filament length, weight and cost, the print's size, layers and their heights, temperatures and the slicer's notes, and a layer drawn as SVG.filesdevicescalculatorssvg6 views · 7 downloads
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

On Garmin FIT files: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.