Core ride logic, FTMS client, FIT encoder and probe CLI

Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+115
View File
@@ -0,0 +1,115 @@
//! Profiles shipped with the app, so there is always something to ride and the
//! YAML schema (`crates/core/src/profile.rs`) has worked examples.
use crate::commands::SampleProfile;
const OVER_UNDERS: &str = r#"name: Over-unders
description: Ten minutes up to threshold, then eight over-under cycles, then easy.
looping: false
blocks:
- type: ramp
channel: power
from: 110
to: 210
extent: { seconds: 600 }
- type: wave
channel: power
shape: sine
midpoint: 245
amplitude: 45
period: { seconds: 120 }
repeats: 8
- type: constant
channel: power
value: 120
extent: { seconds: 300 }
"#;
const HILL_REPEATS: &str = r#"name: Hill repeats
description: Four kilometres of rolling terrain, looped. Gradient by distance.
looping: true
blocks:
- type: segments
segments:
- { distance_m: 600, gradient_pct: 1.0 }
- { distance_m: 900, gradient_pct: 6.5 }
- { distance_m: 300, gradient_pct: 9.0 }
- { distance_m: 500, gradient_pct: -3.0 }
- { distance_m: 700, gradient_pct: 4.0 }
- { distance_m: 1000, gradient_pct: -2.0 }
"#;
const SAWTOOTH_GRADE: &str = r#"name: Sawtooth grade
description: A gradient sawtooth for shakedown testing — every 400 m ramps 0 to 8%.
looping: true
blocks:
- type: wave
channel: gradient
shape: sawtooth
midpoint: 4.0
amplitude: 4.0
period: { metres: 400 }
repeats: 20
"#;
const STEADY_ENDURANCE: &str = r#"name: Steady endurance
description: Ninety minutes at a fixed grade, with a gentle triangular trim.
looping: false
blocks:
- type: constant
channel: gradient
value: 2.0
extent: { seconds: 900 }
- type: wave
channel: gradient
shape: triangle
midpoint: 3.0
amplitude: 2.5
period: { seconds: 600 }
repeats: 7
- type: constant
channel: gradient
value: 0.0
extent: { seconds: 600 }
"#;
/// A real GPX, bundled so the route view has something to draw on first run.
const SAMPLE_CLIMB_GPX: &str = include_str!("../../testdata/sample-climb.gpx");
pub fn all() -> Vec<SampleProfile> {
let mut out: Vec<SampleProfile> = [OVER_UNDERS, HILL_REPEATS, SAWTOOTH_GRADE, STEADY_ENDURANCE]
.iter()
.map(|yaml| {
let (name, summary) = header(yaml);
SampleProfile {
name,
summary,
text: (*yaml).to_string(),
is_gpx: false,
}
})
.collect();
out.insert(
0,
SampleProfile {
name: "Sample climb".into(),
summary: "3 km GPX with real GPS elevation noise — smoothed on import.".into(),
text: SAMPLE_CLIMB_GPX.to_string(),
is_gpx: true,
},
);
out
}
fn header(yaml: &str) -> (String, String) {
let mut name = String::from("Profile");
let mut summary = String::new();
for line in yaml.lines() {
if let Some(rest) = line.strip_prefix("name: ") {
name = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("description: ") {
summary = rest.trim().to_string();
}
}
(name, summary)
}