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
Generated
+3743 -22
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/core", "crates/ble", "crates/fit", "crates/probe"] members = ["crates/core", "crates/ble", "crates/fit", "crates/probe", "src-tauri"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
+3 -2
View File
@@ -491,9 +491,10 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| ID | Requirement | Priority | | ID | Requirement | Priority |
|----|-------------|----------| |----|-------------|----------|
| FR-9.5 | Large, legible readouts (power, cadence, speed, gradient, gear, elapsed) readable at ~1 m | Must | | FR-9.5 | Large, legible readouts readable at ~1 m. **The screen is route-led, not power-led** — ETA, distance remaining, speed, gradient and elevation take visual priority; power, cadence and heart rate are present but subordinate | Must |
| FR-9.6 | Live streaming charts of power and gradient/target | Must | | FR-9.6 | Live streaming charts of power and gradient/target | Must |
| FR-9.7 | Route elevation profile or waveform preview with current position marked | Must | | FR-9.7 | **The route elevation profile with current position marked is the primary visual element** of the ride screen, not a supporting chart. For waveform profiles, the profile preview serves the same role | Must |
| FR-9.15 | **Estimated time to finish (ETA)**, alongside distance covered and remaining | Must |
| FR-9.8 | Prominent display of active mode, current gear, and current target | Must | | FR-9.8 | Prominent display of active mode, current gear, and current target | Must |
| FR-9.9 | Visible feedback on every button press, so the rider knows input registered | Must | | FR-9.9 | Visible feedback on every button press, so the rider knows input registered | Must |
| FR-9.10 | On-screen and keyboard equivalents for all controller actions | Must | | FR-9.10 | On-screen and keyboard equivalents for all controller actions | Must |
+804
View File
@@ -0,0 +1,804 @@
//! What the trainer says it can do, and the gate that stops us asking it for
//! anything else.
//!
//! Two sources:
//!
//! * **Fitness Machine Feature** (`0x2ACC`) — two little-endian uint32
//! bitfields: machine features, then target-setting features. The second is
//! what tells us whether `SetTargetInclination`, `SetTargetResistanceLevel`,
//! `SetTargetPower` and `SetIndoorBikeSimulationParameters` are supported.
//! * **Supported * Range** characteristics — `0x2AD5` inclination, `0x2AD6`
//! resistance level, `0x2AD8` power. Each is `min, max, increment`.
//!
//! Together these satisfy FR-2.6: never send an unsupported or out-of-range
//! command. [`gate_target`] is the single choke point; it is a pure function so
//! the whole of SAF-3 is unit-testable without hardware.
use bikecontrol_core::types::{ControlTarget, SafetyLimits};
use crate::control_point::OpCode;
/// Why a [`ControlTarget`] cannot be sent to this trainer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UnsupportedTarget {
#[error("trainer does not advertise support for {0}")]
OpCodeUnsupported(OpCode),
#[error(
"trainer's supported range for {what} is {min}..={max}, which excludes every value \
permitted by the configured safety limits"
)]
EmptyRange {
what: &'static str,
min: i32,
max: i32,
},
}
/// Decoded Fitness Machine Feature characteristic (`0x2ACC`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FitnessMachineFeature {
/// Fitness Machine Features bitfield (what the machine *measures*).
pub machine: u32,
/// Target Setting Features bitfield (what the machine can be *told*).
pub target: u32,
}
/// Bit positions in the Fitness Machine Features field.
pub mod machine_feature {
pub const AVERAGE_SPEED: u32 = 1 << 0;
pub const CADENCE: u32 = 1 << 1;
pub const TOTAL_DISTANCE: u32 = 1 << 2;
pub const INCLINATION: u32 = 1 << 3;
pub const ELEVATION_GAIN: u32 = 1 << 4;
pub const PACE: u32 = 1 << 5;
pub const STEP_COUNT: u32 = 1 << 6;
pub const RESISTANCE_LEVEL: u32 = 1 << 7;
pub const STRIDE_COUNT: u32 = 1 << 8;
pub const EXPENDED_ENERGY: u32 = 1 << 9;
pub const HEART_RATE_MEASUREMENT: u32 = 1 << 10;
pub const METABOLIC_EQUIVALENT: u32 = 1 << 11;
pub const ELAPSED_TIME: u32 = 1 << 12;
pub const REMAINING_TIME: u32 = 1 << 13;
pub const POWER_MEASUREMENT: u32 = 1 << 14;
pub const FORCE_ON_BELT_AND_POWER_OUTPUT: u32 = 1 << 15;
pub const USER_DATA_RETENTION: u32 = 1 << 16;
}
/// Bit positions in the Target Setting Features field.
pub mod target_feature {
pub const SPEED: u32 = 1 << 0;
pub const INCLINATION: u32 = 1 << 1;
pub const RESISTANCE: u32 = 1 << 2;
pub const POWER: u32 = 1 << 3;
pub const HEART_RATE: u32 = 1 << 4;
pub const EXPENDED_ENERGY: u32 = 1 << 5;
pub const STEP_NUMBER: u32 = 1 << 6;
pub const STRIDE_NUMBER: u32 = 1 << 7;
pub const DISTANCE: u32 = 1 << 8;
pub const TRAINING_TIME: u32 = 1 << 9;
pub const TIME_IN_TWO_HR_ZONES: u32 = 1 << 10;
pub const TIME_IN_THREE_HR_ZONES: u32 = 1 << 11;
pub const TIME_IN_FIVE_HR_ZONES: u32 = 1 << 12;
/// Bit 13 — `SetIndoorBikeSimulationParameters` (`0x11`). Open question A-1.
pub const INDOOR_BIKE_SIMULATION: u32 = 1 << 13;
pub const WHEEL_CIRCUMFERENCE: u32 = 1 << 14;
pub const SPIN_DOWN: u32 = 1 << 15;
pub const CADENCE: u32 = 1 << 16;
}
/// A characteristic (`0x2ACC` etc.) was shorter than its definition requires.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("{what} characteristic is {len} bytes; {need} are required")]
pub struct FieldTooShort {
pub what: &'static str,
pub len: usize,
pub need: usize,
}
impl FitnessMachineFeature {
/// Decode the 8-byte Fitness Machine Feature characteristic.
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
if data.len() < 8 {
return Err(FieldTooShort {
what: "Fitness Machine Feature",
len: data.len(),
need: 8,
});
}
Ok(Self {
machine: u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
target: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
})
}
pub fn has_machine(self, bit: u32) -> bool {
self.machine & bit != 0
}
pub fn has_target(self, bit: u32) -> bool {
self.target & bit != 0
}
pub fn supports_inclination_target(self) -> bool {
self.has_target(target_feature::INCLINATION)
}
pub fn supports_resistance_target(self) -> bool {
self.has_target(target_feature::RESISTANCE)
}
pub fn supports_power_target(self) -> bool {
self.has_target(target_feature::POWER)
}
/// A-1: whether `0x11` is advertised. Advertised support and *actual*
/// support are not the same thing — `probe set` writes the op code to find
/// out for certain.
pub fn supports_simulation(self) -> bool {
self.has_target(target_feature::INDOOR_BIKE_SIMULATION)
}
/// Human-readable list of set machine-feature bits, for the probe CLI.
pub fn machine_feature_names(self) -> Vec<&'static str> {
use machine_feature as m;
let table: [(u32, &'static str); 17] = [
(m::AVERAGE_SPEED, "Average Speed"),
(m::CADENCE, "Cadence"),
(m::TOTAL_DISTANCE, "Total Distance"),
(m::INCLINATION, "Inclination"),
(m::ELEVATION_GAIN, "Elevation Gain"),
(m::PACE, "Pace"),
(m::STEP_COUNT, "Step Count"),
(m::RESISTANCE_LEVEL, "Resistance Level"),
(m::STRIDE_COUNT, "Stride Count"),
(m::EXPENDED_ENERGY, "Expended Energy"),
(m::HEART_RATE_MEASUREMENT, "Heart Rate Measurement"),
(m::METABOLIC_EQUIVALENT, "Metabolic Equivalent"),
(m::ELAPSED_TIME, "Elapsed Time"),
(m::REMAINING_TIME, "Remaining Time"),
(m::POWER_MEASUREMENT, "Power Measurement"),
(m::FORCE_ON_BELT_AND_POWER_OUTPUT, "Force on Belt and Power Output"),
(m::USER_DATA_RETENTION, "User Data Retention"),
];
table
.iter()
.filter(|(bit, _)| self.machine & bit != 0)
.map(|(_, name)| *name)
.collect()
}
/// Human-readable list of set target-setting bits, for the probe CLI.
pub fn target_feature_names(self) -> Vec<&'static str> {
use target_feature as t;
let table: [(u32, &'static str); 17] = [
(t::SPEED, "Speed Target Setting"),
(t::INCLINATION, "Inclination Target Setting (0x03)"),
(t::RESISTANCE, "Resistance Target Setting (0x04)"),
(t::POWER, "Power Target Setting (0x05)"),
(t::HEART_RATE, "Heart Rate Target Setting"),
(t::EXPENDED_ENERGY, "Targeted Expended Energy Configuration"),
(t::STEP_NUMBER, "Targeted Step Number Configuration"),
(t::STRIDE_NUMBER, "Targeted Stride Number Configuration"),
(t::DISTANCE, "Targeted Distance Configuration"),
(t::TRAINING_TIME, "Targeted Training Time Configuration"),
(t::TIME_IN_TWO_HR_ZONES, "Targeted Time in Two HR Zones"),
(t::TIME_IN_THREE_HR_ZONES, "Targeted Time in Three HR Zones"),
(t::TIME_IN_FIVE_HR_ZONES, "Targeted Time in Five HR Zones"),
(t::INDOOR_BIKE_SIMULATION, "Indoor Bike Simulation Parameters (0x11)"),
(t::WHEEL_CIRCUMFERENCE, "Wheel Circumference Configuration"),
(t::SPIN_DOWN, "Spin Down Control"),
(t::CADENCE, "Targeted Cadence Configuration"),
];
table
.iter()
.filter(|(bit, _)| self.target & bit != 0)
.map(|(_, name)| *name)
.collect()
}
}
/// Supported Resistance Level Range (`0x2AD6`): sint16 min, sint16 max,
/// uint16 increment.
///
/// The spec assigns these a resolution of 0.1, but resistance level is a
/// trainer-specific unit and the D100 reference works in raw integers capped at
/// 100. We therefore keep the **raw** values as authoritative for clamping —
/// they are in the same units as [`ControlTarget::Resistance`] and
/// [`crate::control_point::set_target_resistance`] — and expose the 0.1-scaled
/// interpretation separately for display. **Needs hardware verification**
/// (TASK-1) to know which the D100 actually means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResistanceLevelRange {
pub min: i16,
pub max: i16,
pub increment: u16,
}
impl ResistanceLevelRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (min, max, increment) = decode_range("Supported Resistance Level Range", data)?;
Ok(Self {
min,
max,
increment,
})
}
/// The spec-scaled interpretation (0.1 units), for display only.
pub fn scaled(&self) -> (f32, f32, f32) {
(
self.min as f32 * 0.1,
self.max as f32 * 0.1,
self.increment as f32 * 0.1,
)
}
}
/// Supported Power Range (`0x2AD8`): sint16 min W, sint16 max W, uint16
/// increment W. Resolution 1 W.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PowerRange {
pub min_w: i16,
pub max_w: i16,
pub increment_w: u16,
}
impl PowerRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (min_w, max_w, increment_w) = decode_range("Supported Power Range", data)?;
Ok(Self {
min_w,
max_w,
increment_w,
})
}
}
/// Supported Inclination Range (`0x2AD5`): sint16 min, sint16 max, uint16
/// increment, all with 0.1% resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InclinationRange {
raw_min: i16,
raw_max: i16,
raw_increment: u16,
}
impl InclinationRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (raw_min, raw_max, raw_increment) =
decode_range("Supported Inclination Range", data)?;
Ok(Self {
raw_min,
raw_max,
raw_increment,
})
}
pub fn min_percent(&self) -> f32 {
self.raw_min as f32 * 0.1
}
pub fn max_percent(&self) -> f32 {
self.raw_max as f32 * 0.1
}
pub fn increment_percent(&self) -> f32 {
self.raw_increment as f32 * 0.1
}
}
fn decode_range(what: &'static str, data: &[u8]) -> Result<(i16, i16, u16), FieldTooShort> {
if data.len() < 6 {
return Err(FieldTooShort {
what,
len: data.len(),
need: 6,
});
}
Ok((
i16::from_le_bytes([data[0], data[1]]),
i16::from_le_bytes([data[2], data[3]]),
u16::from_le_bytes([data[4], data[5]]),
))
}
/// Everything the trainer told us about itself. Any field may be `None` if the
/// corresponding characteristic is absent or unreadable — a trainer is not
/// required to expose the optional range characteristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TrainerCapabilities {
pub feature: Option<FitnessMachineFeature>,
pub resistance_range: Option<ResistanceLevelRange>,
pub power_range: Option<PowerRange>,
pub inclination_range: Option<InclinationRange>,
}
impl TrainerCapabilities {
/// Whether the op code used for a given target is advertised.
///
/// If the Fitness Machine Feature characteristic could not be read we
/// return `true` — refusing to control a trainer that simply did not expose
/// `0x2ACC` would be worse than trying and reading the error response, and
/// the response indication (FR-2.7) is the real backstop.
pub fn supports(&self, target: &ControlTarget, use_simulation: bool) -> bool {
let Some(f) = self.feature else {
return true;
};
match target {
ControlTarget::Gradient { .. } => {
if use_simulation {
f.supports_simulation()
} else {
f.supports_inclination_target()
}
}
ControlTarget::Resistance { .. } => f.supports_resistance_target(),
ControlTarget::Power { .. } => f.supports_power_target(),
}
}
/// The op code that would carry this target.
pub fn op_code_for(target: &ControlTarget, use_simulation: bool) -> OpCode {
match target {
ControlTarget::Gradient { .. } if use_simulation => {
OpCode::SetIndoorBikeSimulationParameters
}
ControlTarget::Gradient { .. } => OpCode::SetTargetInclination,
ControlTarget::Resistance { .. } => OpCode::SetTargetResistanceLevel,
ControlTarget::Power { .. } => OpCode::SetTargetPower,
}
}
}
/// Clamp `target` first to the configured [`SafetyLimits`] (SAF-3) and then to
/// the range the trainer itself reported (FR-2.6, SAF-3), and reject it
/// outright if the op code is not advertised.
///
/// This is the **only** function the client uses to prepare a target for
/// transmission. Everything else — profiles, waveforms, the D-pad, the UI —
/// funnels through here, which is what makes "clamp at the point of
/// transmission" true rather than aspirational.
pub fn gate_target(
limits: &SafetyLimits,
caps: &TrainerCapabilities,
target: ControlTarget,
use_simulation: bool,
) -> Result<ControlTarget, UnsupportedTarget> {
if !caps.supports(&target, use_simulation) {
return Err(UnsupportedTarget::OpCodeUnsupported(
TrainerCapabilities::op_code_for(&target, use_simulation),
));
}
// 1. Configured safety limits.
let target = limits.clamp(target);
// 2. The trainer's own reported range, where it gave us one.
let target = match target {
ControlTarget::Gradient { percent } => {
if let Some(r) = caps.inclination_range {
let (lo, hi) = (r.min_percent(), r.max_percent());
if lo > hi {
return Err(UnsupportedTarget::EmptyRange {
what: "inclination",
min: r.raw_min as i32,
max: r.raw_max as i32,
});
}
ControlTarget::Gradient {
percent: percent.clamp(lo, hi),
}
} else {
ControlTarget::Gradient { percent }
}
}
ControlTarget::Resistance { level } => {
if let Some(r) = caps.resistance_range {
if r.min > r.max {
return Err(UnsupportedTarget::EmptyRange {
what: "resistance level",
min: r.min as i32,
max: r.max as i32,
});
}
ControlTarget::Resistance {
level: level.clamp(r.min, r.max),
}
} else {
ControlTarget::Resistance { level }
}
}
ControlTarget::Power { watts } => {
if let Some(r) = caps.power_range {
if r.min_w > r.max_w {
return Err(UnsupportedTarget::EmptyRange {
what: "power",
min: r.min_w as i32,
max: r.max_w as i32,
});
}
// ControlTarget::Power is u16; the range is sint16. Negative
// minima are meaningless for a trainer, so floor at zero.
let lo = r.min_w.max(0) as u16;
let hi = r.max_w.max(0) as u16;
ControlTarget::Power {
watts: watts.clamp(lo, hi),
}
} else {
ControlTarget::Power { watts }
}
}
};
Ok(target)
}
/// Encode a gated target into control point bytes.
///
/// `use_simulation` selects `0x11` over `0x03` for gradient; `sim_template`
/// supplies the rolling/aero coefficients that accompany the grade.
pub fn encode_target(
target: ControlTarget,
use_simulation: bool,
sim_template: crate::control_point::SimulationParameters,
) -> (OpCode, Vec<u8>) {
use crate::control_point as cp;
match target {
ControlTarget::Gradient { percent } if use_simulation => (
OpCode::SetIndoorBikeSimulationParameters,
cp::set_simulation_parameters(cp::SimulationParameters {
grade_percent: percent,
..sim_template
}),
),
ControlTarget::Gradient { percent } => (
OpCode::SetTargetInclination,
cp::set_target_inclination(percent),
),
ControlTarget::Resistance { level } => (
OpCode::SetTargetResistanceLevel,
cp::set_target_resistance(level),
),
ControlTarget::Power { watts } => (
OpCode::SetTargetPower,
// watts is u16 but the wire format is sint16; the gate has already
// clamped it to the trainer's range, and `min(i16::MAX)` keeps a
// pathological value from wrapping negative.
cp::set_target_power(watts.min(i16::MAX as u16) as i16),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feature(machine: u32, target: u32) -> FitnessMachineFeature {
FitnessMachineFeature { machine, target }
}
#[test]
fn feature_decodes_two_little_endian_u32s() {
// machine = 0x00000086, target = 0x0000200C
let bytes = [0x86, 0x00, 0x00, 0x00, 0x0c, 0x20, 0x00, 0x00];
let f = FitnessMachineFeature::decode(&bytes).unwrap();
assert_eq!(f.machine, 0x0000_0086);
assert_eq!(f.target, 0x0000_200C);
assert!(f.has_machine(machine_feature::CADENCE));
assert!(f.has_machine(machine_feature::RESISTANCE_LEVEL));
assert!(!f.has_machine(machine_feature::AVERAGE_SPEED));
assert!(f.supports_resistance_target());
assert!(f.supports_power_target());
assert!(f.supports_simulation());
assert!(!f.supports_inclination_target());
}
#[test]
fn feature_decode_rejects_short_data() {
assert_eq!(
FitnessMachineFeature::decode(&[0u8; 7]).unwrap_err(),
FieldTooShort {
what: "Fitness Machine Feature",
len: 7,
need: 8
}
);
}
#[test]
fn feature_extra_trailing_bytes_are_tolerated() {
let f = FitnessMachineFeature::decode(&[0x02, 0, 0, 0, 0x04, 0, 0, 0, 0xff]).unwrap();
assert_eq!(f.machine, 2);
assert_eq!(f.target, 4);
}
#[test]
fn feature_name_lists() {
let f = feature(
machine_feature::CADENCE | machine_feature::POWER_MEASUREMENT,
target_feature::RESISTANCE | target_feature::INDOOR_BIKE_SIMULATION,
);
assert_eq!(
f.machine_feature_names(),
vec!["Cadence", "Power Measurement"]
);
assert_eq!(
f.target_feature_names(),
vec![
"Resistance Target Setting (0x04)",
"Indoor Bike Simulation Parameters (0x11)"
]
);
}
#[test]
fn resistance_range_decodes() {
// min 0, max 100, increment 1
let r = ResistanceLevelRange::decode(&[0x00, 0x00, 0x64, 0x00, 0x01, 0x00]).unwrap();
assert_eq!(
r,
ResistanceLevelRange {
min: 0,
max: 100,
increment: 1
}
);
let (lo, hi, inc) = r.scaled();
assert_eq!((lo, hi, inc), (0.0, 10.0, 0.1));
}
#[test]
fn resistance_range_handles_negative_minimum() {
let r = ResistanceLevelRange::decode(&[0xf6, 0xff, 0x64, 0x00, 0x02, 0x00]).unwrap();
assert_eq!(r.min, -10);
assert_eq!(r.max, 100);
assert_eq!(r.increment, 2);
}
#[test]
fn power_and_inclination_ranges_decode() {
let p = PowerRange::decode(&[0x32, 0x00, 0x58, 0x02, 0x01, 0x00]).unwrap();
assert_eq!(p.min_w, 50);
assert_eq!(p.max_w, 600);
assert_eq!(p.increment_w, 1);
// -10.0% .. +20.0%, 0.5% increment
let i = InclinationRange::decode(&[0x9c, 0xff, 0xc8, 0x00, 0x05, 0x00]).unwrap();
assert_eq!(i.min_percent(), -10.0);
assert_eq!(i.max_percent(), 20.0);
assert_eq!(i.increment_percent(), 0.5);
}
#[test]
fn range_decode_rejects_short_data() {
assert!(ResistanceLevelRange::decode(&[0, 0, 0, 0, 0]).is_err());
assert!(PowerRange::decode(&[]).is_err());
assert!(InclinationRange::decode(&[1, 2, 3]).is_err());
}
// -- gate_target -------------------------------------------------------
fn caps_all() -> TrainerCapabilities {
TrainerCapabilities {
feature: Some(feature(
0,
target_feature::INCLINATION
| target_feature::RESISTANCE
| target_feature::POWER
| target_feature::INDOOR_BIKE_SIMULATION,
)),
resistance_range: Some(ResistanceLevelRange {
min: 0,
max: 100,
increment: 1,
}),
power_range: Some(PowerRange {
min_w: 50,
max_w: 600,
increment_w: 1,
}),
inclination_range: Some(InclinationRange {
raw_min: -100,
raw_max: 200,
raw_increment: 5,
}),
}
}
#[test]
fn gate_passes_an_in_range_target_unchanged() {
let l = SafetyLimits::default();
let c = caps_all();
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap(),
ControlTarget::Power { watts: 200 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 3.5 }, false).unwrap(),
ControlTarget::Gradient { percent: 3.5 }
);
}
#[test]
fn gate_clamps_to_safety_limits() {
let l = SafetyLimits::default(); // -10..15 %, 0..100 res, 50..600 W
let c = TrainerCapabilities::default(); // trainer told us nothing
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 99.0 }, false).unwrap(),
ControlTarget::Gradient { percent: 15.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: -99.0 }, false).unwrap(),
ControlTarget::Gradient { percent: -10.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 5000 }, false).unwrap(),
ControlTarget::Power { watts: 600 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 500 }, false).unwrap(),
ControlTarget::Resistance { level: 100 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: -500 }, false).unwrap(),
ControlTarget::Resistance { level: 0 }
);
}
/// The trainer's reported range is *narrower* than the safety limits, so it
/// must win. This is FR-2.6.
#[test]
fn gate_clamps_to_the_trainers_narrower_range() {
let l = SafetyLimits {
min_gradient_pct: -25.0,
max_gradient_pct: 25.0,
min_resistance: -200,
max_resistance: 200,
min_power_w: 0,
max_power_w: 2000,
};
let c = caps_all(); // incl -10..20 %, res 0..100, power 50..600 W
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 24.0 }, false).unwrap(),
ControlTarget::Gradient { percent: 20.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 150 }, false).unwrap(),
ControlTarget::Resistance { level: 100 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 1500 }, false).unwrap(),
ControlTarget::Power { watts: 600 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 10 }, false).unwrap(),
ControlTarget::Power { watts: 50 }
);
}
#[test]
fn gate_rejects_unadvertised_op_codes() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
feature: Some(feature(0, target_feature::RESISTANCE)),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetPower)
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, false).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetInclination)
);
assert!(gate_target(&l, &c, ControlTarget::Resistance { level: 5 }, false).is_ok());
}
/// A-1 in gate form: if the trainer does not advertise bit 13, asking for
/// gradient in simulation mode is refused before anything is transmitted.
#[test]
fn gate_rejects_simulation_mode_when_unadvertised() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
feature: Some(feature(0, target_feature::INCLINATION)),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, true).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetIndoorBikeSimulationParameters)
);
// ...but plain inclination is fine.
assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, false).is_ok());
}
#[test]
fn gate_allows_everything_when_the_feature_characteristic_is_missing() {
let l = SafetyLimits::default();
let c = TrainerCapabilities::default();
assert!(gate_target(&l, &c, ControlTarget::Power { watts: 100 }, false).is_ok());
assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, true).is_ok());
}
#[test]
fn gate_rejects_an_inverted_range() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
resistance_range: Some(ResistanceLevelRange {
min: 50,
max: 10,
increment: 1,
}),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 20 }, false).unwrap_err(),
UnsupportedTarget::EmptyRange {
what: "resistance level",
min: 50,
max: 10
}
);
}
#[test]
fn gate_handles_a_negative_power_minimum() {
let l = SafetyLimits {
min_power_w: 0,
..SafetyLimits::default()
};
let c = TrainerCapabilities {
power_range: Some(PowerRange {
min_w: -100,
max_w: 400,
increment_w: 1,
}),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 0 }, false).unwrap(),
ControlTarget::Power { watts: 0 }
);
}
// -- encode_target -----------------------------------------------------
#[test]
fn encode_target_picks_the_right_op_code_and_bytes() {
let sim = crate::control_point::SimulationParameters::default();
assert_eq!(
encode_target(ControlTarget::Gradient { percent: 5.0 }, false, sim),
(OpCode::SetTargetInclination, vec![0x03, 0x32, 0x00])
);
assert_eq!(
encode_target(ControlTarget::Resistance { level: 40 }, false, sim),
(OpCode::SetTargetResistanceLevel, vec![0x04, 0x28, 0x00])
);
assert_eq!(
encode_target(ControlTarget::Power { watts: 250 }, false, sim),
(OpCode::SetTargetPower, vec![0x05, 0xfa, 0x00])
);
}
#[test]
fn encode_target_in_simulation_mode_carries_the_grade() {
let sim = crate::control_point::SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 999.0, // must be overridden by the target
crr: 0.004,
wind_resistance_coefficient: 0.51,
};
let (op, bytes) = encode_target(ControlTarget::Gradient { percent: 4.5 }, true, sim);
assert_eq!(op, OpCode::SetIndoorBikeSimulationParameters);
assert_eq!(bytes, vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]);
}
#[test]
fn encode_target_power_never_wraps_negative() {
let sim = crate::control_point::SimulationParameters::default();
let (_, bytes) = encode_target(ControlTarget::Power { watts: 60000 }, false, sim);
let raw = i16::from_le_bytes([bytes[1], bytes[2]]);
assert!(raw > 0, "power must not wrap to a negative sint16");
assert_eq!(raw, i16::MAX);
}
}
File diff suppressed because it is too large Load Diff
+488
View File
@@ -0,0 +1,488 @@
//! Encoders and decoders for the FTMS **Fitness Machine Control Point**
//! (`0x2AD9`).
//!
//! The control point is a request/response characteristic: the central writes a
//! procedure (with response), and the fitness machine replies with an
//! **indication** of the form `[0x80, request_op_code, result_code, ...params]`.
//! Treating writes as fire-and-forget is a bug (FR-2.7) — a trainer that has
//! not granted control will silently ignore everything until `RequestControl`
//! succeeds, and there is no other way to find that out.
//!
//! The op-code table and the sint16 encodings for inclination, resistance and
//! target power were ported from `obostjancic/smart-trainer-control`
//! (`src/lib/bike/ftms-control.ts`), MIT licensed, Copyright (c) 2025 Ogi —
//! a working Van Rysel D100 client. See REQUIREMENTS.md §3.2.
/// FTMS Fitness Machine Control Point op codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum OpCode {
RequestControl = 0x00,
Reset = 0x01,
SetTargetSpeed = 0x02,
SetTargetInclination = 0x03,
SetTargetResistanceLevel = 0x04,
SetTargetPower = 0x05,
SetTargetHeartRate = 0x06,
StartOrResume = 0x07,
StopOrPause = 0x08,
SetTargetedExpendedEnergy = 0x09,
SetTargetedNumberOfSteps = 0x0A,
SetTargetedNumberOfStrides = 0x0B,
SetTargetedDistance = 0x0C,
SetTargetedTrainingTime = 0x0D,
SetTargetedTimeInTwoHeartRateZones = 0x0E,
SetTargetedTimeInThreeHeartRateZones = 0x0F,
SetTargetedTimeInFiveHeartRateZones = 0x10,
SetIndoorBikeSimulationParameters = 0x11,
SetWheelCircumference = 0x12,
SetSpinDownControl = 0x13,
SetTargetedCadence = 0x14,
}
impl OpCode {
pub fn from_u8(v: u8) -> Option<Self> {
use OpCode::*;
Some(match v {
0x00 => RequestControl,
0x01 => Reset,
0x02 => SetTargetSpeed,
0x03 => SetTargetInclination,
0x04 => SetTargetResistanceLevel,
0x05 => SetTargetPower,
0x06 => SetTargetHeartRate,
0x07 => StartOrResume,
0x08 => StopOrPause,
0x09 => SetTargetedExpendedEnergy,
0x0A => SetTargetedNumberOfSteps,
0x0B => SetTargetedNumberOfStrides,
0x0C => SetTargetedDistance,
0x0D => SetTargetedTrainingTime,
0x0E => SetTargetedTimeInTwoHeartRateZones,
0x0F => SetTargetedTimeInThreeHeartRateZones,
0x10 => SetTargetedTimeInFiveHeartRateZones,
0x11 => SetIndoorBikeSimulationParameters,
0x12 => SetWheelCircumference,
0x13 => SetSpinDownControl,
0x14 => SetTargetedCadence,
_ => return None,
})
}
pub fn as_u8(self) -> u8 {
self as u8
}
}
impl std::fmt::Display for OpCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?} (0x{:02x})", self.as_u8())
}
}
/// The first byte of every control point indication.
pub const RESPONSE_CODE: u8 = 0x80;
/// Result codes carried in a control point indication.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultCode {
Success,
OpCodeNotSupported,
InvalidParameter,
OperationFailed,
ControlNotPermitted,
/// Reserved or vendor-specific.
Unknown(u8),
}
impl ResultCode {
pub fn from_u8(v: u8) -> Self {
match v {
0x01 => ResultCode::Success,
0x02 => ResultCode::OpCodeNotSupported,
0x03 => ResultCode::InvalidParameter,
0x04 => ResultCode::OperationFailed,
0x05 => ResultCode::ControlNotPermitted,
other => ResultCode::Unknown(other),
}
}
pub fn as_u8(self) -> u8 {
match self {
ResultCode::Success => 0x01,
ResultCode::OpCodeNotSupported => 0x02,
ResultCode::InvalidParameter => 0x03,
ResultCode::OperationFailed => 0x04,
ResultCode::ControlNotPermitted => 0x05,
ResultCode::Unknown(v) => v,
}
}
pub fn is_success(self) -> bool {
matches!(self, ResultCode::Success)
}
}
impl std::fmt::Display for ResultCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResultCode::Success => write!(f, "Success"),
ResultCode::OpCodeNotSupported => write!(f, "Op Code not supported"),
ResultCode::InvalidParameter => write!(f, "Invalid parameter"),
ResultCode::OperationFailed => write!(f, "Operation failed"),
ResultCode::ControlNotPermitted => {
write!(f, "Control not permitted (RequestControl first)")
}
ResultCode::Unknown(v) => write!(f, "Unknown result code 0x{v:02x}"),
}
}
}
/// A decoded control point indication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ControlPointResponse {
/// The op code this response refers to, if it is one we know.
pub request_op_code: Option<OpCode>,
/// The raw op code byte, even when unrecognised.
pub raw_request_op_code: u8,
pub result: ResultCode,
/// Response parameters, present only for some procedures.
pub parameters: Vec<u8>,
}
impl ControlPointResponse {
pub fn is_success(&self) -> bool {
self.result.is_success()
}
}
/// Why a control point indication could not be decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ResponseError {
#[error("control point indication is {len} bytes; at least 3 are required")]
TooShort { len: usize },
#[error("control point indication started with 0x{first:02x}, expected 0x80")]
NotAResponse { first: u8 },
}
/// Decode a control point indication. Pure function; no hardware needed.
pub fn decode_response(data: &[u8]) -> Result<ControlPointResponse, ResponseError> {
if data.len() < 3 {
return Err(ResponseError::TooShort { len: data.len() });
}
if data[0] != RESPONSE_CODE {
return Err(ResponseError::NotAResponse { first: data[0] });
}
Ok(ControlPointResponse {
request_op_code: OpCode::from_u8(data[1]),
raw_request_op_code: data[1],
result: ResultCode::from_u8(data[2]),
parameters: data[3..].to_vec(),
})
}
// ---------------------------------------------------------------------------
// Request encoders
// ---------------------------------------------------------------------------
/// `StopOrPause` (`0x08`) takes a one-byte control parameter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopOrPause {
Stop = 0x01,
Pause = 0x02,
}
/// Parameters for `SetIndoorBikeSimulationParameters` (`0x11`).
///
/// Whether the D100 accepts this at all is open question **A-1** — the MIT
/// reference drives grade with `SetTargetInclination` (`0x03`) instead. The
/// `probe set` subcommand exists to resolve it against real hardware.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulationParameters {
/// Wind speed, m/s. Encoded as sint16 with 0.001 resolution.
pub wind_speed_mps: f32,
/// Grade, percent. Encoded as sint16 with 0.01 resolution.
pub grade_percent: f32,
/// Coefficient of rolling resistance. Encoded as uint8 with 0.0001
/// resolution (so the representable range is 0.0000 0.0255).
pub crr: f32,
/// Wind resistance coefficient, kg/m. Encoded as uint8 with 0.01
/// resolution (representable range 0.00 2.55).
pub wind_resistance_coefficient: f32,
}
impl Default for SimulationParameters {
fn default() -> Self {
Self {
wind_speed_mps: 0.0,
grade_percent: 0.0,
crr: 0.004,
wind_resistance_coefficient: 0.51,
}
}
}
/// `[0x00]` — take control of the machine. Must succeed before any target
/// setting procedure is accepted (FR-2.1).
pub fn request_control() -> Vec<u8> {
vec![OpCode::RequestControl.as_u8()]
}
/// `[0x01]` — reset the machine to its default state. Part of the shutdown
/// safety sequence (SAF-2).
pub fn reset() -> Vec<u8> {
vec![OpCode::Reset.as_u8()]
}
/// `[0x07]` — start or resume.
pub fn start_or_resume() -> Vec<u8> {
vec![OpCode::StartOrResume.as_u8()]
}
/// `[0x08, param]` — stop or pause.
pub fn stop_or_pause(what: StopOrPause) -> Vec<u8> {
vec![OpCode::StopOrPause.as_u8(), what as u8]
}
/// `[0x03, sint16 LE]` — target inclination in percent, 0.1 resolution.
///
/// Values outside the sint16 range after scaling are saturated rather than
/// wrapped: wrapping a +400% mistake into a large negative grade would be a
/// safety hazard.
pub fn set_target_inclination(percent: f32) -> Vec<u8> {
let raw = scale_to_i16(percent, 10.0);
let mut v = vec![OpCode::SetTargetInclination.as_u8()];
v.extend_from_slice(&raw.to_le_bytes());
v
}
/// `[0x04, sint16 LE]` — target resistance level, in the trainer's own units.
///
/// Note: the FTMS specification defines this parameter as a *uint8* with 0.1
/// resolution, but the working D100 reference implementation (§3.2) sends a
/// sint16 and the trainer accepts it. We follow the reference, since it is the
/// only behaviour confirmed on this hardware. **Needs hardware confirmation**
/// if a different trainer is ever targeted.
pub fn set_target_resistance(level: i16) -> Vec<u8> {
let mut v = vec![OpCode::SetTargetResistanceLevel.as_u8()];
v.extend_from_slice(&level.to_le_bytes());
v
}
/// `[0x05, sint16 LE]` — target power in watts.
pub fn set_target_power(watts: i16) -> Vec<u8> {
let mut v = vec![OpCode::SetTargetPower.as_u8()];
v.extend_from_slice(&watts.to_le_bytes());
v
}
/// `[0x11, sint16 wind, sint16 grade, uint8 crr, uint8 cw]` — indoor bike
/// simulation parameters. See [`SimulationParameters`] and open question A-1.
pub fn set_simulation_parameters(p: SimulationParameters) -> Vec<u8> {
let wind = scale_to_i16(p.wind_speed_mps, 1000.0);
let grade = scale_to_i16(p.grade_percent, 100.0);
let crr = scale_to_u8(p.crr, 10_000.0);
let cw = scale_to_u8(p.wind_resistance_coefficient, 100.0);
let mut v = vec![OpCode::SetIndoorBikeSimulationParameters.as_u8()];
v.extend_from_slice(&wind.to_le_bytes());
v.extend_from_slice(&grade.to_le_bytes());
v.push(crr);
v.push(cw);
v
}
/// Scale a physical value by `factor` and saturate into sint16.
fn scale_to_i16(value: f32, factor: f32) -> i16 {
if !value.is_finite() {
return 0;
}
let scaled = (value * factor).round();
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}
/// Scale a physical value by `factor` and saturate into uint8.
fn scale_to_u8(value: f32, factor: f32) -> u8 {
if !value.is_finite() {
return 0;
}
let scaled = (value * factor).round();
scaled.clamp(0.0, u8::MAX as f32) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_procedures_are_single_bytes() {
assert_eq!(request_control(), vec![0x00]);
assert_eq!(reset(), vec![0x01]);
assert_eq!(start_or_resume(), vec![0x07]);
assert_eq!(stop_or_pause(StopOrPause::Stop), vec![0x08, 0x01]);
assert_eq!(stop_or_pause(StopOrPause::Pause), vec![0x08, 0x02]);
}
#[test]
fn inclination_is_sint16_tenths_of_a_percent_little_endian() {
// +5.0% -> 50 -> 0x0032
assert_eq!(set_target_inclination(5.0), vec![0x03, 0x32, 0x00]);
// 0% -> 0
assert_eq!(set_target_inclination(0.0), vec![0x03, 0x00, 0x00]);
// -7.5% -> -75 -> 0xFFB5
assert_eq!(set_target_inclination(-7.5), vec![0x03, 0xb5, 0xff]);
// Rounding, not truncation.
assert_eq!(set_target_inclination(1.26), vec![0x03, 0x0d, 0x00]);
}
#[test]
fn inclination_saturates_instead_of_wrapping() {
// 5000% * 10 = 50000, past i16::MAX. Must clamp to +32767, not wrap to
// a large negative grade.
assert_eq!(set_target_inclination(5000.0), vec![0x03, 0xff, 0x7f]);
assert_eq!(set_target_inclination(-5000.0), vec![0x03, 0x00, 0x80]);
assert_eq!(set_target_inclination(f32::NAN), vec![0x03, 0x00, 0x00]);
assert_eq!(set_target_inclination(f32::INFINITY), vec![0x03, 0x00, 0x00]);
}
#[test]
fn resistance_is_sint16_little_endian() {
assert_eq!(set_target_resistance(0), vec![0x04, 0x00, 0x00]);
assert_eq!(set_target_resistance(50), vec![0x04, 0x32, 0x00]);
assert_eq!(set_target_resistance(100), vec![0x04, 0x64, 0x00]);
assert_eq!(set_target_resistance(-1), vec![0x04, 0xff, 0xff]);
}
#[test]
fn power_is_sint16_watts_little_endian() {
assert_eq!(set_target_power(200), vec![0x05, 0xc8, 0x00]);
assert_eq!(set_target_power(600), vec![0x05, 0x58, 0x02]);
// Negative target power is nonsense but must still encode as sint16
// rather than panic; the safety gate is what prevents it being sent.
assert_eq!(set_target_power(-10), vec![0x05, 0xf6, 0xff]);
}
#[test]
fn simulation_parameters_layout() {
let p = SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 4.5, // * 100 = 450 = 0x01C2
crr: 0.004, // * 10000 = 40 = 0x28
wind_resistance_coefficient: 0.51, // * 100 = 51 = 0x33
};
assert_eq!(
set_simulation_parameters(p),
vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]
);
assert_eq!(set_simulation_parameters(p).len(), 7);
}
#[test]
fn simulation_parameters_negative_grade_and_wind() {
let p = SimulationParameters {
wind_speed_mps: -1.5, // * 1000 = -1500 = 0xFA24
grade_percent: -8.0, // * 100 = -800 = 0xFCE0
crr: 0.0,
wind_resistance_coefficient: 0.0,
};
assert_eq!(
set_simulation_parameters(p),
vec![0x11, 0x24, 0xfa, 0xe0, 0xfc, 0x00, 0x00]
);
}
#[test]
fn simulation_parameter_bytes_saturate() {
let p = SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 0.0,
crr: 1.0, // * 10000 = 10000, way past u8
wind_resistance_coefficient: -5.0, // negative, clamps to 0
};
let b = set_simulation_parameters(p);
assert_eq!(b[5], 0xff);
assert_eq!(b[6], 0x00);
}
#[test]
fn response_decoding() {
let r = decode_response(&[0x80, 0x05, 0x01]).unwrap();
assert_eq!(r.request_op_code, Some(OpCode::SetTargetPower));
assert_eq!(r.result, ResultCode::Success);
assert!(r.is_success());
assert!(r.parameters.is_empty());
}
#[test]
fn response_decoding_reports_every_error_code() {
for (byte, expect) in [
(0x02u8, ResultCode::OpCodeNotSupported),
(0x03, ResultCode::InvalidParameter),
(0x04, ResultCode::OperationFailed),
(0x05, ResultCode::ControlNotPermitted),
(0x77, ResultCode::Unknown(0x77)),
] {
let r = decode_response(&[0x80, 0x11, byte]).unwrap();
assert_eq!(r.result, expect);
assert!(!r.is_success());
}
}
/// A-1: this is exactly the byte sequence that tells us the D100 rejects
/// simulation mode.
#[test]
fn sim_mode_rejection_is_recognisable() {
let r = decode_response(&[0x80, 0x11, 0x02]).unwrap();
assert_eq!(
r.request_op_code,
Some(OpCode::SetIndoorBikeSimulationParameters)
);
assert_eq!(r.result, ResultCode::OpCodeNotSupported);
}
#[test]
fn response_with_parameters() {
let r = decode_response(&[0x80, 0x13, 0x01, 0xaa, 0xbb]).unwrap();
assert_eq!(r.parameters, vec![0xaa, 0xbb]);
}
#[test]
fn unknown_op_code_in_response_is_preserved_not_dropped() {
let r = decode_response(&[0x80, 0xfe, 0x02]).unwrap();
assert_eq!(r.request_op_code, None);
assert_eq!(r.raw_request_op_code, 0xfe);
}
#[test]
fn malformed_responses_are_errors() {
assert_eq!(
decode_response(&[]).unwrap_err(),
ResponseError::TooShort { len: 0 }
);
assert_eq!(
decode_response(&[0x80, 0x05]).unwrap_err(),
ResponseError::TooShort { len: 2 }
);
assert_eq!(
decode_response(&[0x01, 0x05, 0x01]).unwrap_err(),
ResponseError::NotAResponse { first: 0x01 }
);
}
#[test]
fn op_code_round_trips() {
for v in 0x00u8..=0x14 {
let op = OpCode::from_u8(v).expect("all of 0x00..=0x14 are defined");
assert_eq!(op.as_u8(), v);
}
assert_eq!(OpCode::from_u8(0x15), None);
assert_eq!(OpCode::from_u8(0xff), None);
}
#[test]
fn result_code_round_trips() {
for v in 0x00u8..=0xff {
assert_eq!(ResultCode::from_u8(v).as_u8(), v);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Error type for the BLE layer.
//!
//! NFR-4: no BLE dropout, malformed packet or missing characteristic may crash
//! the app. Everything that can go wrong on the wire is a value here, not a
//! panic.
use crate::capabilities::{FieldTooShort, UnsupportedTarget};
use crate::control_point::{OpCode, ResponseError, ResultCode};
use crate::indoor_bike_data::DecodeError;
#[derive(Debug, thiserror::Error)]
pub enum FtmsError {
#[error("bluetooth error: {0}")]
Bluetooth(#[from] btleplug::Error),
#[error("no bluetooth adapter available")]
NoAdapter,
#[error("no device found matching {0} (the trainer may be asleep — pedal it and retry)")]
NotFound(String),
#[error("peripheral does not expose the Fitness Machine Service (0x1826)")]
NotAFitnessMachine,
#[error("the trainer is missing a required characteristic: {0}")]
MissingCharacteristic(&'static str),
#[error("{0} is not writable on this trainer")]
NotWritable(&'static str),
#[error("could not decode Indoor Bike Data: {0}")]
IndoorBikeData(#[from] DecodeError),
#[error("could not decode a control point indication: {0}")]
ControlPointResponse(#[from] ResponseError),
#[error("could not decode a capability characteristic: {0}")]
Capability(#[from] FieldTooShort),
#[error("{0}")]
Unsupported(#[from] UnsupportedTarget),
/// The trainer answered, but with an error. FR-2.7 — this is the whole
/// reason control writes are not fire-and-forget.
#[error("trainer rejected {op}: {result}")]
Rejected { op: OpCode, result: ResultCode },
/// The trainer answered a *different* op code than the one in flight.
#[error("trainer answered op code 0x{got:02x} while 0x{expected:02x} was in flight")]
MismatchedResponse { expected: u8, got: u8 },
#[error("trainer did not acknowledge {op} within {timeout_ms} ms")]
Unacknowledged { op: OpCode, timeout_ms: u64 },
/// SAF-4 — repeated unacknowledged writes stop the control path and the
/// rider must be alerted.
#[error(
"control halted after {failures} consecutive unacknowledged control point writes (SAF-4); \
reconnect the trainer to resume"
)]
ControlHalted { failures: u32 },
#[error("not connected to the trainer")]
NotConnected,
#[error("the FTMS client has shut down")]
ClientShutDown,
#[error("gave up reconnecting after {attempts} attempts: {reason}")]
ReconnectFailed { attempts: u32, reason: String },
}
+520
View File
@@ -0,0 +1,520 @@
//! Decoder for the FTMS **Indoor Bike Data** characteristic (`0x2AD2`).
//!
//! The packet is variable length. A leading little-endian 16-bit flags field
//! declares which fields follow, and the fields must be consumed in *strict*
//! specification order — there is no tagging, so a single mis-ordered or
//! mis-sized field turns everything after it into garbage.
//!
//! **The C1 flag (bit 0) is inverted.** It is named "More Data", and
//! Instantaneous Speed is present when the bit is **clear**. Every other bit is
//! a normal "present when set" flag. This trips up nearly every first
//! implementation; see REQUIREMENTS.md §5.2.
//!
//! Field order and units per the Bluetooth SIG FTMS specification v1.0:
//!
//! | # | Field | Flag | Type | Resolution |
//! |---|-------|------|------|------------|
//! | 1 | Instantaneous Speed | bit 0 **clear** | uint16 | 0.01 km/h |
//! | 2 | Average Speed | bit 1 set | uint16 | 0.01 km/h |
//! | 3 | Instantaneous Cadence | bit 2 set | uint16 | 0.5 rpm |
//! | 4 | Average Cadence | bit 3 set | uint16 | 0.5 rpm |
//! | 5 | Total Distance | bit 4 set | uint24 | 1 m |
//! | 6 | Resistance Level | bit 5 set | sint16 | 1 (unitless) |
//! | 7 | Instantaneous Power | bit 6 set | sint16 | 1 W |
//! | 8 | Average Power | bit 7 set | sint16 | 1 W |
//! | 9 | Total Energy / Energy per Hour / Energy per Minute | bit 8 set | uint16, uint16, uint8 | kcal |
//! | 10 | Heart Rate | bit 9 set | uint8 | 1 bpm |
//! | 11 | Metabolic Equivalent | bit 10 set | uint8 | 0.1 |
//! | 12 | Elapsed Time | bit 11 set | uint16 | 1 s |
//! | 13 | Remaining Time | bit 12 set | uint16 | 1 s |
//!
//! Portions of the field ordering and scaling in this module were ported from
//! `obostjancic/smart-trainer-control` (`src/lib/bike/ftms.ts`), MIT licensed,
//! Copyright (c) 2025 Ogi — a working Van Rysel D100 client. See
//! REQUIREMENTS.md §3.2. This Rust version differs in that a truncated packet
//! is a hard error rather than being silently zero-filled (NFR-4).
use bikecontrol_core::types::Telemetry;
/// Bit positions in the Indoor Bike Data flags field.
pub mod flag {
/// Bit 0 — **inverted**: Instantaneous Speed is present when this is CLEAR.
pub const MORE_DATA: u16 = 1 << 0;
pub const AVERAGE_SPEED: u16 = 1 << 1;
pub const INSTANTANEOUS_CADENCE: u16 = 1 << 2;
pub const AVERAGE_CADENCE: u16 = 1 << 3;
pub const TOTAL_DISTANCE: u16 = 1 << 4;
pub const RESISTANCE_LEVEL: u16 = 1 << 5;
pub const INSTANTANEOUS_POWER: u16 = 1 << 6;
pub const AVERAGE_POWER: u16 = 1 << 7;
pub const EXPENDED_ENERGY: u16 = 1 << 8;
pub const HEART_RATE: u16 = 1 << 9;
pub const METABOLIC_EQUIVALENT: u16 = 1 << 10;
pub const ELAPSED_TIME: u16 = 1 << 11;
pub const REMAINING_TIME: u16 = 1 << 12;
}
/// Why an Indoor Bike Data packet could not be decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
/// Fewer than the two flag bytes were present.
#[error("indoor bike data packet is {len} bytes; at least 2 (flags) are required")]
MissingFlags { len: usize },
/// The flags promised a field the packet was too short to contain.
#[error(
"indoor bike data packet truncated: field {field} needs {need} byte(s) at offset {offset}, \
but the packet is only {len} bytes"
)]
Truncated {
field: &'static str,
offset: usize,
need: usize,
len: usize,
},
}
/// Every field FTMS can put in an Indoor Bike Data packet, already scaled into
/// physical units. `None` means the trainer did not send the field.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct IndoorBikeData {
/// Raw flags field, retained for diagnostics (NFR-8).
pub flags: u16,
/// km/h.
pub instant_speed_kph: Option<f32>,
/// km/h.
pub average_speed_kph: Option<f32>,
/// rpm.
pub instant_cadence_rpm: Option<f32>,
/// rpm.
pub average_cadence_rpm: Option<f32>,
/// metres.
pub total_distance_m: Option<u32>,
/// Trainer-specific resistance units.
pub resistance_level: Option<i16>,
/// watts.
pub instant_power_w: Option<i16>,
/// watts.
pub average_power_w: Option<i16>,
/// kcal.
pub total_energy_kcal: Option<u16>,
/// kcal/h.
pub energy_per_hour_kcal: Option<u16>,
/// kcal/min.
pub energy_per_minute_kcal: Option<u8>,
/// bpm.
pub heart_rate_bpm: Option<u8>,
pub metabolic_equivalent: Option<f32>,
/// seconds.
pub elapsed_time_s: Option<u16>,
/// seconds.
pub remaining_time_s: Option<u16>,
/// Number of bytes consumed. If this is less than the packet length the
/// trainer appended data we do not understand — worth logging, not an error.
pub consumed: usize,
}
impl IndoorBikeData {
/// Project onto the shared [`Telemetry`] contract. `elapsed_ms` is the
/// ride-clock timestamp; the packet's own Elapsed Time field is the
/// *machine's* session clock and is deliberately not used for it.
pub fn to_telemetry(&self, elapsed_ms: u64) -> Telemetry {
Telemetry {
elapsed_ms,
power_w: self.instant_power_w,
cadence_rpm: self.instant_cadence_rpm,
speed_kph: self.instant_speed_kph,
resistance_level: self.resistance_level,
heart_rate_bpm: self.heart_rate_bpm,
total_distance_m: self.total_distance_m,
total_energy_kcal: self.total_energy_kcal,
}
}
}
/// A bounds-checked little-endian cursor. Every read names the field it is
/// reading so a truncated packet produces a diagnosable error.
struct Cursor<'a> {
data: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, offset: 0 }
}
fn take(&mut self, field: &'static str, n: usize) -> Result<&'a [u8], DecodeError> {
let end = self.offset.checked_add(n).ok_or(DecodeError::Truncated {
field,
offset: self.offset,
need: n,
len: self.data.len(),
})?;
if end > self.data.len() {
return Err(DecodeError::Truncated {
field,
offset: self.offset,
need: n,
len: self.data.len(),
});
}
let out = &self.data[self.offset..end];
self.offset = end;
Ok(out)
}
fn u8(&mut self, field: &'static str) -> Result<u8, DecodeError> {
Ok(self.take(field, 1)?[0])
}
fn u16(&mut self, field: &'static str) -> Result<u16, DecodeError> {
let b = self.take(field, 2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn i16(&mut self, field: &'static str) -> Result<i16, DecodeError> {
Ok(self.u16(field)? as i16)
}
/// uint24, little-endian.
fn u24(&mut self, field: &'static str) -> Result<u32, DecodeError> {
let b = self.take(field, 3)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], 0]))
}
}
/// Decode one Indoor Bike Data notification.
///
/// This is a pure function over bytes — no I/O, no state — so it is fully
/// testable without hardware.
pub fn decode(data: &[u8]) -> Result<IndoorBikeData, DecodeError> {
if data.len() < 2 {
return Err(DecodeError::MissingFlags { len: data.len() });
}
let mut cur = Cursor::new(data);
let flags = cur.u16("flags")?;
let present = |bit: u16| flags & bit != 0;
let mut out = IndoorBikeData {
flags,
..Default::default()
};
// 1. Instantaneous Speed — INVERTED FLAG: present when bit 0 is CLEAR.
if !present(flag::MORE_DATA) {
out.instant_speed_kph = Some(cur.u16("instantaneous speed")? as f32 / 100.0);
}
// 2. Average Speed.
if present(flag::AVERAGE_SPEED) {
out.average_speed_kph = Some(cur.u16("average speed")? as f32 / 100.0);
}
// 3. Instantaneous Cadence.
if present(flag::INSTANTANEOUS_CADENCE) {
out.instant_cadence_rpm = Some(cur.u16("instantaneous cadence")? as f32 / 2.0);
}
// 4. Average Cadence.
if present(flag::AVERAGE_CADENCE) {
out.average_cadence_rpm = Some(cur.u16("average cadence")? as f32 / 2.0);
}
// 5. Total Distance — uint24.
if present(flag::TOTAL_DISTANCE) {
out.total_distance_m = Some(cur.u24("total distance")?);
}
// 6. Resistance Level — sint16.
if present(flag::RESISTANCE_LEVEL) {
out.resistance_level = Some(cur.i16("resistance level")?);
}
// 7. Instantaneous Power — sint16 watts.
if present(flag::INSTANTANEOUS_POWER) {
out.instant_power_w = Some(cur.i16("instantaneous power")?);
}
// 8. Average Power — sint16 watts.
if present(flag::AVERAGE_POWER) {
out.average_power_w = Some(cur.i16("average power")?);
}
// 9. Expended Energy — three fields under one flag.
if present(flag::EXPENDED_ENERGY) {
out.total_energy_kcal = Some(cur.u16("total energy")?);
out.energy_per_hour_kcal = Some(cur.u16("energy per hour")?);
out.energy_per_minute_kcal = Some(cur.u8("energy per minute")?);
}
// 10. Heart Rate.
if present(flag::HEART_RATE) {
out.heart_rate_bpm = Some(cur.u8("heart rate")?);
}
// 11. Metabolic Equivalent — uint8, 0.1 resolution.
if present(flag::METABOLIC_EQUIVALENT) {
out.metabolic_equivalent = Some(cur.u8("metabolic equivalent")? as f32 / 10.0);
}
// 12. Elapsed Time.
if present(flag::ELAPSED_TIME) {
out.elapsed_time_s = Some(cur.u16("elapsed time")?);
}
// 13. Remaining Time.
if present(flag::REMAINING_TIME) {
out.remaining_time_s = Some(cur.u16("remaining time")?);
}
out.consumed = cur.offset;
Ok(out)
}
/// Format a byte slice as lowercase hex, for `NFR-8` style raw logging.
pub fn hex(data: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(data.len() * 2);
for b in data {
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::flag::*;
use super::*;
/// Build a packet from a flags value and a payload.
fn packet(flags: u16, payload: &[u8]) -> Vec<u8> {
let mut v = flags.to_le_bytes().to_vec();
v.extend_from_slice(payload);
v
}
#[test]
fn speed_present_when_bit0_is_clear() {
// flags = 0x0000: no bits set at all -> speed present, nothing else.
// 3000 * 0.01 = 30.00 km/h
let pkt = packet(0x0000, &3000u16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(30.0));
assert_eq!(d.consumed, 4);
assert_eq!(d.instant_power_w, None);
}
#[test]
fn speed_absent_when_bit0_is_set() {
// MORE_DATA set -> NO speed field. Power follows the flags directly.
let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &200i16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, None);
assert_eq!(d.instant_power_w, Some(200));
assert_eq!(d.consumed, 4);
}
/// The regression this whole module exists to prevent: if bit 0 were
/// treated as a normal present-when-set flag, the two power bytes would be
/// eaten by "speed" and power would decode as garbage (or fail).
#[test]
fn inverted_bit0_does_not_shift_later_fields() {
let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &250i16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_power_w, Some(250));
// And the mirrored case: bit 0 clear means speed IS there and power
// starts two bytes later.
let mut payload = 1234u16.to_le_bytes().to_vec(); // 12.34 km/h
payload.extend_from_slice(&250i16.to_le_bytes());
let pkt = packet(INSTANTANEOUS_POWER, &payload);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(12.34));
assert_eq!(d.instant_power_w, Some(250));
}
#[test]
fn typical_trainer_packet_speed_cadence_power() {
// A very common D100-class combination: speed (implicit), cadence,
// power. flags = cadence | power, bit 0 clear.
let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER;
let mut p = Vec::new();
p.extend_from_slice(&2550u16.to_le_bytes()); // 25.50 km/h
p.extend_from_slice(&180u16.to_le_bytes()); // 90.0 rpm (0.5 resolution)
p.extend_from_slice(&213i16.to_le_bytes()); // 213 W
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.instant_speed_kph, Some(25.5));
assert_eq!(d.instant_cadence_rpm, Some(90.0));
assert_eq!(d.instant_power_w, Some(213));
assert_eq!(d.consumed, 8);
assert_eq!(d.consumed, packet(flags, &p).len());
}
#[test]
fn all_fields_present() {
let flags = AVERAGE_SPEED
| INSTANTANEOUS_CADENCE
| AVERAGE_CADENCE
| TOTAL_DISTANCE
| RESISTANCE_LEVEL
| INSTANTANEOUS_POWER
| AVERAGE_POWER
| EXPENDED_ENERGY
| HEART_RATE
| METABOLIC_EQUIVALENT
| ELAPSED_TIME
| REMAINING_TIME; // bit 0 clear -> instantaneous speed also present
let mut p = Vec::new();
p.extend_from_slice(&3512u16.to_le_bytes()); // instant speed 35.12
p.extend_from_slice(&3000u16.to_le_bytes()); // average speed 30.00
p.extend_from_slice(&191u16.to_le_bytes()); // cadence 95.5
p.extend_from_slice(&180u16.to_le_bytes()); // avg cadence 90.0
p.extend_from_slice(&[0x40, 0x0d, 0x03]); // distance uint24 LE = 0x030d40 = 200000
p.extend_from_slice(&12i16.to_le_bytes()); // resistance 12
p.extend_from_slice(&245i16.to_le_bytes()); // power 245
p.extend_from_slice(&230i16.to_le_bytes()); // avg power 230
p.extend_from_slice(&150u16.to_le_bytes()); // total energy 150 kcal
p.extend_from_slice(&600u16.to_le_bytes()); // energy/hour
p.push(10); // energy/minute
p.push(142); // heart rate
p.push(85); // MET 8.5
p.extend_from_slice(&3600u16.to_le_bytes()); // elapsed 3600 s
p.extend_from_slice(&1800u16.to_le_bytes()); // remaining 1800 s
let pkt = packet(flags, &p);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(35.12));
assert_eq!(d.average_speed_kph, Some(30.0));
assert_eq!(d.instant_cadence_rpm, Some(95.5));
assert_eq!(d.average_cadence_rpm, Some(90.0));
assert_eq!(d.total_distance_m, Some(200_000));
assert_eq!(d.resistance_level, Some(12));
assert_eq!(d.instant_power_w, Some(245));
assert_eq!(d.average_power_w, Some(230));
assert_eq!(d.total_energy_kcal, Some(150));
assert_eq!(d.energy_per_hour_kcal, Some(600));
assert_eq!(d.energy_per_minute_kcal, Some(10));
assert_eq!(d.heart_rate_bpm, Some(142));
assert_eq!(d.metabolic_equivalent, Some(8.5));
assert_eq!(d.elapsed_time_s, Some(3600));
assert_eq!(d.remaining_time_s, Some(1800));
// Every byte consumed: proof the field order and widths line up.
assert_eq!(d.consumed, pkt.len());
}
#[test]
fn total_distance_is_uint24_little_endian() {
// 0xAABBCC as uint24 LE is bytes CC BB AA.
let pkt = packet(MORE_DATA | TOTAL_DISTANCE, &[0xcc, 0xbb, 0xaa]);
let d = decode(&pkt).unwrap();
assert_eq!(d.total_distance_m, Some(0x00AA_BBCC));
assert_eq!(d.consumed, 5);
}
#[test]
fn negative_power_and_resistance_are_signed() {
let flags = MORE_DATA | RESISTANCE_LEVEL | INSTANTANEOUS_POWER;
let mut p = Vec::new();
p.extend_from_slice(&(-5i16).to_le_bytes());
p.extend_from_slice(&(-30i16).to_le_bytes());
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.resistance_level, Some(-5));
assert_eq!(d.instant_power_w, Some(-30));
}
#[test]
fn energy_block_is_three_fields_under_one_flag() {
// If EXPENDED_ENERGY were treated as a single uint16 the heart rate
// would come out wrong. This pins all five bytes.
let flags = MORE_DATA | EXPENDED_ENERGY | HEART_RATE;
let mut p = Vec::new();
p.extend_from_slice(&321u16.to_le_bytes());
p.extend_from_slice(&654u16.to_le_bytes());
p.push(9);
p.push(155);
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.total_energy_kcal, Some(321));
assert_eq!(d.energy_per_hour_kcal, Some(654));
assert_eq!(d.energy_per_minute_kcal, Some(9));
assert_eq!(d.heart_rate_bpm, Some(155));
}
#[test]
fn flags_only_packet_with_more_data_set_is_valid_and_empty() {
let d = decode(&packet(MORE_DATA, &[])).unwrap();
assert_eq!(d, IndoorBikeData {
flags: MORE_DATA,
consumed: 2,
..Default::default()
});
}
#[test]
fn unknown_high_bits_are_ignored_not_fatal() {
// Bits 13-15 are RFU. A trainer setting them must not break decoding of
// the fields we do understand.
let flags = 0xE000 | MORE_DATA | INSTANTANEOUS_POWER;
let d = decode(&packet(flags, &100i16.to_le_bytes())).unwrap();
assert_eq!(d.instant_power_w, Some(100));
}
#[test]
fn trailing_unknown_bytes_are_reported_not_fatal() {
let mut pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &100i16.to_le_bytes());
pkt.extend_from_slice(&[0xde, 0xad]);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_power_w, Some(100));
assert_eq!(d.consumed, 4);
assert!(d.consumed < pkt.len());
}
#[test]
fn truncated_packet_is_an_error_not_a_panic() {
// Flags promise power but only one byte follows.
let err = decode(&packet(MORE_DATA | INSTANTANEOUS_POWER, &[0x01])).unwrap_err();
assert_eq!(
err,
DecodeError::Truncated {
field: "instantaneous power",
offset: 2,
need: 2,
len: 3,
}
);
}
#[test]
fn short_packets_are_errors() {
assert_eq!(decode(&[]).unwrap_err(), DecodeError::MissingFlags { len: 0 });
assert_eq!(
decode(&[0x00]).unwrap_err(),
DecodeError::MissingFlags { len: 1 }
);
}
#[test]
fn no_input_panics_across_every_flag_combination_and_length() {
// Exhaustive robustness sweep (NFR-4): every meaningful flags value
// against every payload length up to a full packet must either decode
// or return an error, never panic.
for flags in 0u16..=0x1FFF {
for len in 0..40usize {
let payload: Vec<u8> = (0..len).map(|i| i as u8).collect();
let _ = decode(&packet(flags, &payload));
}
}
}
#[test]
fn to_telemetry_maps_the_core_contract() {
let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER | HEART_RATE;
let mut p = Vec::new();
p.extend_from_slice(&2000u16.to_le_bytes());
p.extend_from_slice(&170u16.to_le_bytes());
p.extend_from_slice(&199i16.to_le_bytes());
p.push(130);
let t = decode(&packet(flags, &p)).unwrap().to_telemetry(1234);
assert_eq!(t.elapsed_ms, 1234);
assert_eq!(t.speed_kph, Some(20.0));
assert_eq!(t.cadence_rpm, Some(85.0));
assert_eq!(t.power_w, Some(199));
assert_eq!(t.heart_rate_bpm, Some(130));
assert_eq!(t.total_distance_m, None);
}
#[test]
fn hex_formats_lowercase_fixed_width() {
assert_eq!(hex(&[0x00, 0x0f, 0xff]), "000fff");
}
}
+69
View File
@@ -1 +1,70 @@
//! FTMS client and BLE transport. See REQUIREMENTS.md §5.15.2. //! FTMS client and BLE transport. See REQUIREMENTS.md §5.15.2.
//!
//! # Layout
//!
//! The crate is split so that everything protocol-shaped is a pure function
//! over bytes, and only [`client`] and [`scan`] touch a radio. That is what
//! lets the whole wire format be tested without a trainer on the desk:
//!
//! | Module | Contents | Needs hardware |
//! |--------|----------|----------------|
//! | [`uuids`] | FTMS assigned numbers | no |
//! | [`indoor_bike_data`] | `0x2AD2` decoder | no |
//! | [`control_point`] | `0x2AD9` encoders and response decoding | no |
//! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no |
//! | [`scan`] | discovery | yes |
//! | [`client`] | the connection actor | yes |
//!
//! # Usage
//!
//! ```no_run
//! use bikecontrol_ble::{FtmsClient, FtmsConfig, TrainerSelector};
//! use bikecontrol_core::types::ControlTarget;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let client = FtmsClient::connect(TrainerSelector::Any, FtmsConfig::default()).await?;
//!
//! let mut telemetry = client.telemetry();
//! tokio::spawn(async move {
//! while let Ok(sample) = telemetry.recv().await {
//! println!("{:?} W", sample.power_w);
//! }
//! });
//!
//! client.set_target(ControlTarget::Gradient { percent: 4.0 }).await?;
//!
//! // SAF-2: always leave the trainer at zero.
//! client.shutdown().await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Attribution
//!
//! The FTMS field ordering, scaling and control-point encodings are ported from
//! [`obostjancic/smart-trainer-control`](https://github.com/obostjancic/smart-trainer-control),
//! MIT licensed, Copyright (c) 2025 Ogi — a working Van Rysel D100 client
//! (REQUIREMENTS.md §3.2). Per-module attribution notes mark where.
pub mod capabilities;
pub mod client;
pub mod control_point;
pub mod error;
pub mod indoor_bike_data;
pub mod scan;
pub mod uuids;
pub use capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
UnsupportedTarget,
};
pub use client::{
safety_reset_commands, Backoff, ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent, Procedure,
};
pub use control_point::{ControlPointResponse, OpCode, ResultCode, SimulationParameters, StopOrPause};
pub use error::FtmsError;
pub use indoor_bike_data::{DecodeError, IndoorBikeData};
pub use scan::{
default_adapter, scan, scan_trainers, DiscoveredDevice, ScanKind, TrainerSelector,
};
pub use uuids::FITNESS_MACHINE_SERVICE;
+319
View File
@@ -0,0 +1,319 @@
//! BLE discovery (FR-1.1, FR-1.2).
use std::collections::HashMap;
use std::time::Duration;
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
use uuid::Uuid;
use crate::error::FtmsError;
use crate::uuids;
/// Zwift's custom service UUID, used to recognise Click pods during a scan
/// (FR-1.2). The Click *client* is Phase 3 and lives elsewhere; discovery only
/// needs the UUID so `probe scan` can label them.
pub const ZWIFT_SERVICE: Uuid = Uuid::from_fields(
0x0000_0001,
0x19CA,
0x4651,
&[0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1],
);
/// Zwift's Bluetooth SIG manufacturer ID (2378).
pub const ZWIFT_MANUFACTURER_ID: u16 = 0x094A;
/// A peripheral seen during a scan.
#[derive(Debug, Clone)]
pub struct DiscoveredDevice {
pub id: PeripheralId,
/// Canonical lowercase MAC-style address string.
pub address: String,
pub name: Option<String>,
pub rssi: Option<i16>,
pub tx_power: Option<i16>,
pub services: Vec<Uuid>,
pub manufacturer_data: HashMap<u16, Vec<u8>>,
pub service_data: HashMap<Uuid, Vec<u8>>,
}
impl DiscoveredDevice {
/// True when the peripheral advertises the FTMS service (FR-1.2).
///
/// Note that advertising is not mandatory: a trainer may expose FTMS
/// without listing it in its advertisement. `probe scan` therefore lists
/// everything, and connecting by address always works.
pub fn is_fitness_machine(&self) -> bool {
self.services.contains(&uuids::FITNESS_MACHINE_SERVICE)
}
/// True when the peripheral looks like a Zwift controller.
pub fn is_zwift_device(&self) -> bool {
self.services.contains(&ZWIFT_SERVICE)
|| self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID)
}
/// Best-effort human label.
pub fn label(&self) -> String {
match &self.name {
Some(n) if !n.is_empty() => n.clone(),
_ => "(no name)".to_string(),
}
}
}
/// Get the first Bluetooth adapter on the system.
pub async fn default_adapter() -> Result<Adapter, FtmsError> {
let manager = Manager::new().await?;
manager
.adapters()
.await?
.into_iter()
.next()
.ok_or(FtmsError::NoAdapter)
}
/// What to scan for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanKind {
/// Every peripheral the adapter reports.
All,
/// Only peripherals advertising the FTMS service.
FitnessMachines,
}
impl ScanKind {
fn filter(self) -> ScanFilter {
match self {
// An empty filter means "everything". Some backends require a
// filter for privacy reasons; on Linux/BlueZ an empty one is fine.
ScanKind::All => ScanFilter::default(),
ScanKind::FitnessMachines => ScanFilter {
services: vec![uuids::FITNESS_MACHINE_SERVICE],
},
}
}
}
/// Scan for `duration` and return everything seen.
///
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is
/// pedalled. An empty result means "nothing was advertising", not "no such
/// device exists"; FR-1.8 requires the UI to say so.
pub async fn scan(
adapter: &Adapter,
duration: Duration,
kind: ScanKind,
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
adapter.start_scan(kind.filter()).await?;
tokio::time::sleep(duration).await;
let peripherals = adapter.peripherals().await?;
// Stopping the scan is best effort; a failure here must not lose results.
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed");
}
let mut out = Vec::with_capacity(peripherals.len());
for p in peripherals {
if let Some(d) = describe(&p).await {
if kind == ScanKind::FitnessMachines && !d.is_fitness_machine() {
// Some backends ignore the service filter; enforce it here too.
continue;
}
out.push(d);
}
}
out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN)));
Ok(out)
}
/// Convenience wrapper: scan the default adapter for trainers.
pub async fn scan_trainers(duration: Duration) -> Result<Vec<DiscoveredDevice>, FtmsError> {
let adapter = default_adapter().await?;
scan(&adapter, duration, ScanKind::FitnessMachines).await
}
/// Snapshot a peripheral's advertisement data.
pub async fn describe(p: &Peripheral) -> Option<DiscoveredDevice> {
let props = p.properties().await.ok().flatten()?;
Some(DiscoveredDevice {
id: p.id(),
address: props.address.to_string().to_lowercase(),
name: props.local_name.clone(),
rssi: props.rssi,
tx_power: props.tx_power_level,
services: props.services.clone(),
manufacturer_data: props.manufacturer_data.clone(),
service_data: props.service_data.clone(),
})
}
/// How to pick a trainer out of a scan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrainerSelector {
/// The first peripheral advertising FTMS. Fine when only one trainer is in
/// the room; ambiguous otherwise.
Any,
/// Match on address, case-insensitively (`AA:BB:CC:DD:EE:FF`, or the
/// platform's opaque identifier on macOS).
Address(String),
/// Match when the advertised local name contains this, case-insensitively.
NameContains(String),
}
impl TrainerSelector {
/// Does this peripheral match the selector?
pub fn matches(&self, d: &DiscoveredDevice) -> bool {
self.matches_parts(
&d.address,
d.name.as_deref(),
d.is_fitness_machine(),
&format!("{:?}", d.id),
)
}
/// The matching rule, factored out so it can be unit-tested without a
/// `PeripheralId` (which only the platform backend can construct).
pub(crate) fn matches_parts(
&self,
address: &str,
name: Option<&str>,
is_fitness_machine: bool,
id_debug: &str,
) -> bool {
match self {
TrainerSelector::Any => is_fitness_machine,
TrainerSelector::Address(a) => {
address.eq_ignore_ascii_case(a)
|| id_debug.to_lowercase().contains(&a.to_lowercase())
}
TrainerSelector::NameContains(n) => name
.map(|name| name.to_lowercase().contains(&n.to_lowercase()))
.unwrap_or(false),
}
}
/// Human-readable description, for error messages.
pub fn describe(&self) -> String {
match self {
TrainerSelector::Any => "any FTMS trainer".to_string(),
TrainerSelector::Address(a) => format!("address {a}"),
TrainerSelector::NameContains(n) => format!("name containing {n:?}"),
}
}
/// A selector by address needs an unfiltered scan, because a peripheral is
/// not obliged to advertise the FTMS service.
fn scan_kind(&self) -> ScanKind {
match self {
TrainerSelector::Any => ScanKind::FitnessMachines,
_ => ScanKind::All,
}
}
}
/// Scan until a peripheral matching `selector` appears, or `timeout` elapses.
pub async fn find_peripheral(
adapter: &Adapter,
selector: &TrainerSelector,
timeout: Duration,
) -> Result<Peripheral, FtmsError> {
let kind = selector.scan_kind();
adapter.start_scan(kind.filter()).await?;
let deadline = tokio::time::Instant::now() + timeout;
let poll = Duration::from_millis(400);
let mut found: Option<Peripheral> = None;
'search: loop {
for p in adapter.peripherals().await?.into_iter() {
if let Some(d) = describe(&p).await {
if selector.matches(&d) {
tracing::info!(
address = %d.address,
name = d.label(),
rssi = ?d.rssi,
"matched trainer"
);
found = Some(p);
break 'search;
}
}
}
if tokio::time::Instant::now() >= deadline {
break 'search;
}
tokio::time::sleep(poll).await;
}
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed");
}
found.ok_or_else(|| FtmsError::NotFound(selector.describe()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zwift_service_uuid_matches_the_spec() {
assert_eq!(
ZWIFT_SERVICE.to_string(),
"00000001-19ca-4651-86e5-fa29dcdd09d1"
);
assert_eq!(ZWIFT_MANUFACTURER_ID, 2378);
}
#[test]
fn selector_any_requires_the_ftms_service() {
let s = TrainerSelector::Any;
assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), true, ""));
assert!(!s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), false, ""));
}
#[test]
fn selector_address_is_case_insensitive_and_ignores_advertised_services() {
let s = TrainerSelector::Address("AA:BB:CC:DD:EE:FF".into());
assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", None, false, ""));
assert!(!s.matches_parts("11:22:33:44:55:66", None, true, ""));
}
#[test]
fn selector_address_also_matches_an_opaque_platform_id() {
// macOS gives UUID-shaped PeripheralIds rather than MACs.
let s = TrainerSelector::Address("1E2F3A4B".into());
assert!(s.matches_parts("", None, false, "PeripheralId(1e2f3a4b-....)"));
}
#[test]
fn selector_name_is_a_case_insensitive_substring() {
let s = TrainerSelector::NameContains("d100".into());
assert!(s.matches_parts("", Some("VAN RYSEL D100 4321"), false, ""));
assert!(!s.matches_parts("", Some("KICKR CORE"), true, ""));
assert!(!s.matches_parts("", None, true, ""));
}
#[test]
fn selector_scan_kind_widens_for_address_and_name() {
assert_eq!(TrainerSelector::Any.scan_kind(), ScanKind::FitnessMachines);
assert_eq!(
TrainerSelector::Address("x".into()).scan_kind(),
ScanKind::All
);
assert_eq!(
TrainerSelector::NameContains("x".into()).scan_kind(),
ScanKind::All
);
}
#[test]
fn scan_filter_for_fitness_machines_carries_the_ftms_uuid() {
assert_eq!(
ScanKind::FitnessMachines.filter().services,
vec![uuids::FITNESS_MACHINE_SERVICE]
);
assert!(ScanKind::All.filter().services.is_empty());
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Bluetooth SIG assigned UUIDs for the Fitness Machine Service (FTMS).
//!
//! All of these are 16-bit assigned numbers expanded onto the Bluetooth Base
//! UUID `0000xxxx-0000-1000-8000-00805F9B34FB`.
use uuid::Uuid;
/// The tail of the Bluetooth Base UUID, `0000xxxx-0000-1000-8000-00805F9B34FB`.
const BASE_D2: u16 = 0x0000;
const BASE_D3: u16 = 0x1000;
const BASE_D4: [u8; 8] = [0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb];
/// Expand a 16-bit Bluetooth SIG assigned number onto the Bluetooth Base UUID.
pub const fn uuid16(assigned: u16) -> Uuid {
Uuid::from_fields(assigned as u32, BASE_D2, BASE_D3, &BASE_D4)
}
/// Fitness Machine Service — `0x1826`. Trainers are identified by advertising
/// this (FR-1.2).
pub const FITNESS_MACHINE_SERVICE: Uuid = uuid16(0x1826);
/// Fitness Machine Feature — `0x2ACC`, read.
pub const FITNESS_MACHINE_FEATURE: Uuid = uuid16(0x2ACC);
/// Indoor Bike Data — `0x2AD2`, notify.
pub const INDOOR_BIKE_DATA: Uuid = uuid16(0x2AD2);
/// Training Status — `0x2AD3`, read/notify.
pub const TRAINING_STATUS: Uuid = uuid16(0x2AD3);
/// Supported Speed Range — `0x2AD4`, read.
pub const SUPPORTED_SPEED_RANGE: Uuid = uuid16(0x2AD4);
/// Supported Inclination Range — `0x2AD5`, read.
pub const SUPPORTED_INCLINATION_RANGE: Uuid = uuid16(0x2AD5);
/// Supported Resistance Level Range — `0x2AD6`, read.
pub const SUPPORTED_RESISTANCE_LEVEL_RANGE: Uuid = uuid16(0x2AD6);
/// Supported Heart Rate Range — `0x2AD7`, read.
pub const SUPPORTED_HEART_RATE_RANGE: Uuid = uuid16(0x2AD7);
/// Supported Power Range — `0x2AD8`, read.
pub const SUPPORTED_POWER_RANGE: Uuid = uuid16(0x2AD8);
/// Fitness Machine Control Point — `0x2AD9`, write + indicate.
pub const FITNESS_MACHINE_CONTROL_POINT: Uuid = uuid16(0x2AD9);
/// Fitness Machine Status — `0x2ADA`, notify.
pub const FITNESS_MACHINE_STATUS: Uuid = uuid16(0x2ADA);
/// Device Information Service — `0x180A`. Useful for the probe.
pub const DEVICE_INFORMATION_SERVICE: Uuid = uuid16(0x180A);
/// Battery Service — `0x180F`.
pub const BATTERY_SERVICE: Uuid = uuid16(0x180F);
/// Human-readable name for a well-known UUID, for logging and the probe CLI.
/// Returns `None` for anything not recognised.
pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
let name = match short_id(uuid)? {
0x1826 => "Fitness Machine Service",
0x2ACC => "Fitness Machine Feature",
0x2AD2 => "Indoor Bike Data",
0x2AD3 => "Training Status",
0x2AD4 => "Supported Speed Range",
0x2AD5 => "Supported Inclination Range",
0x2AD6 => "Supported Resistance Level Range",
0x2AD7 => "Supported Heart Rate Range",
0x2AD8 => "Supported Power Range",
0x2AD9 => "Fitness Machine Control Point",
0x2ADA => "Fitness Machine Status",
0x180A => "Device Information",
0x180F => "Battery Service",
0x1800 => "Generic Access",
0x1801 => "Generic Attribute",
0x180D => "Heart Rate",
0x1818 => "Cycling Power",
0x1816 => "Cycling Speed and Cadence",
0x2A00 => "Device Name",
0x2A19 => "Battery Level",
0x2A24 => "Model Number String",
0x2A25 => "Serial Number String",
0x2A26 => "Firmware Revision String",
0x2A27 => "Hardware Revision String",
0x2A29 => "Manufacturer Name String",
_ => return None,
};
Some(name)
}
/// If `uuid` sits on the Bluetooth Base UUID, return its 16-bit assigned number.
pub fn short_id(uuid: Uuid) -> Option<u16> {
let (d1, d2, d3, d4) = uuid.as_fields();
if d2 == BASE_D2 && d3 == BASE_D3 && *d4 == BASE_D4 && d1 <= u16::MAX as u32 {
Some(d1 as u16)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ftms_service_uuid_is_correct() {
assert_eq!(
FITNESS_MACHINE_SERVICE.to_string(),
"00001826-0000-1000-8000-00805f9b34fb"
);
}
#[test]
fn characteristic_uuids_are_correct() {
assert_eq!(
INDOOR_BIKE_DATA.to_string(),
"00002ad2-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
FITNESS_MACHINE_CONTROL_POINT.to_string(),
"00002ad9-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
FITNESS_MACHINE_FEATURE.to_string(),
"00002acc-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
SUPPORTED_RESISTANCE_LEVEL_RANGE.to_string(),
"00002ad6-0000-1000-8000-00805f9b34fb"
);
}
#[test]
fn short_id_round_trips() {
assert_eq!(short_id(uuid16(0x2AD2)), Some(0x2AD2));
assert_eq!(short_id(FITNESS_MACHINE_SERVICE), Some(0x1826));
// A vendor UUID (the Zwift custom service) is not on the base UUID.
let zwift = Uuid::parse_str("00000001-19CA-4651-86E5-FA29DCDD09D1").unwrap();
assert_eq!(short_id(zwift), None);
}
#[test]
fn well_known_names_resolve() {
assert_eq!(
well_known_name(INDOOR_BIKE_DATA),
Some("Indoor Bike Data")
);
assert_eq!(well_known_name(uuid16(0x2A19)), Some("Battery Level"));
assert_eq!(well_known_name(uuid16(0xFF01)), None);
}
}
+854 -9
View File
@@ -6,7 +6,14 @@
//! Elevation must be smoothed before gradients are derived, and the result //! Elevation must be smoothed before gradients are derived, and the result
//! clamped (FR-5.3). //! clamped (FR-5.3).
use crate::profile::{Profile, TerrainPoint}; use crate::profile::{Block, Profile, TerrainPoint};
/// Mean Earth radius (IUGG), metres.
const EARTH_RADIUS_M: f64 = 6_371_008.8;
/// Upper bound on resampled points, so a 300 km track with a 10 cm spacing
/// cannot allocate gigabytes. Exceeding it widens the spacing instead.
const MAX_RESAMPLED_POINTS: usize = 200_000;
/// A single trackpoint read from a GPX file. /// A single trackpoint read from a GPX file.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@@ -54,27 +61,865 @@ pub enum GpxError {
/// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`, /// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`,
/// missing `<ele>` on some points, multiple segments, and namespaced documents. /// missing `<ele>` on some points, multiple segments, and namespaced documents.
pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> { pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> {
let _ = xml; let doc = roxmltree::Document::parse(xml).map_err(|e| GpxError::Malformed(e.to_string()))?;
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
// Match on the local name only: GPX 1.0 and 1.1 use different namespace
// URIs and plenty of files in the wild declare neither.
let mut coords: Vec<(f64, f64)> = Vec::new();
let mut elevations: Vec<Option<f32>> = Vec::new();
for node in doc.descendants() {
if !node.is_element() {
continue;
}
let name = node.tag_name().name();
if name != "trkpt" && name != "rtept" && name != "wpt" {
continue;
}
// A waypoint outside a track or route is a POI, not part of the line.
if name == "wpt" && !has_ancestor(node, &["trkseg", "trk", "rte"]) {
continue;
}
let (Some(lat), Some(lon)) = (
node.attribute("lat")
.and_then(|v| v.trim().parse::<f64>().ok()),
node.attribute("lon")
.and_then(|v| v.trim().parse::<f64>().ok()),
) else {
// Tolerate a junk point rather than losing the whole file.
continue;
};
if !lat.is_finite() || !lon.is_finite() {
continue;
}
let ele = node
.children()
.find(|c| c.is_element() && c.tag_name().name() == "ele")
.and_then(|c| c.text())
.and_then(|t| t.trim().parse::<f32>().ok())
.filter(|v| v.is_finite());
coords.push((lat, lon));
elevations.push(ele);
}
if coords.is_empty() {
return Ok(Vec::new());
}
if elevations.iter().all(Option::is_none) {
return Err(GpxError::NoElevation);
}
let filled = fill_missing_elevations(&elevations);
Ok(coords
.into_iter()
.zip(filled)
.map(|((lat_deg, lon_deg), elevation_m)| TrackPoint {
lat_deg,
lon_deg,
elevation_m,
})
.collect())
}
fn has_ancestor(node: roxmltree::Node<'_, '_>, names: &[&str]) -> bool {
node.ancestors()
.any(|a| a.is_element() && names.contains(&a.tag_name().name()))
}
/// Points without `<ele>` are bridged from their neighbours rather than
/// dropped — dropping them would corrupt the distance axis, and a hole in the
/// elevation series would read as a cliff once differentiated.
fn fill_missing_elevations(elevations: &[Option<f32>]) -> Vec<f32> {
let mut out = vec![0.0f32; elevations.len()];
let mut last_known: Option<(usize, f32)> = None;
for (i, known) in elevations.iter().enumerate() {
let Some(value) = *known else { continue };
match last_known {
// Linearly bridge the gap by index.
Some((prev_index, prev_value)) => {
let span = (i - prev_index) as f32;
for (offset, slot) in out[prev_index + 1..i].iter_mut().enumerate() {
let f = (offset + 1) as f32 / span;
*slot = prev_value + (value - prev_value) * f;
}
}
// Leading gap: hold the first known value backwards.
None => {
for slot in out.iter_mut().take(i) {
*slot = value;
}
}
}
out[i] = value;
last_known = Some((i, value));
}
// Trailing gap: hold the last known value forwards.
if let Some((last_index, last_value)) = last_known {
for slot in out.iter_mut().skip(last_index + 1) {
*slot = last_value;
}
}
out
} }
/// Great-circle distance between two points, in metres. /// Great-circle distance between two points, in metres.
pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 { pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
let _ = (a, b); let lat1 = a.lat_deg.to_radians();
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") let lat2 = b.lat_deg.to_radians();
let dlat = lat2 - lat1;
let dlon = (b.lon_deg - a.lon_deg).to_radians();
let h = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2);
let d = 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin();
if d.is_finite() {
d
} else {
0.0
}
} }
/// Turn track points into a smoothed, clamped gradient profile. /// Turn track points into a smoothed, clamped gradient profile.
///
/// The pipeline is deliberate and its order matters (FR-5.2):
///
/// 1. Accumulate ground distance with the haversine formula, discarding
/// repeated fixes so the distance axis is strictly increasing.
/// 2. Resample elevation onto an even `resample_m` grid. Uneven GPS spacing
/// otherwise weights a stationary cluster of fixes as heavily as a fast
/// descent.
/// 3. Smooth elevation with two cascaded centred moving averages `window_m`
/// wide, over a reflected extension so the window never truncates at the
/// ends. Consumer GPS elevation carries metres of noise; differentiating it
/// directly gives gradients swinging tens of percent between neighbours.
/// 4. Differentiate over the *same* window rather than between neighbours. A
/// neighbour difference re-amplifies whatever noise survived smoothing;
/// taking the rise across ±half a window makes the run large enough that
/// residual noise is a fraction of a percent.
/// 5. Clamp to the configured range (FR-5.3).
///
/// On the ±1.5 m fixture in `testdata/` this holds the largest gradient change
/// between adjacent 10 m samples under 1%, while recovering the route's real
/// 68% climb and 4.5% descent.
pub fn to_terrain( pub fn to_terrain(
points: &[TrackPoint], points: &[TrackPoint],
cfg: &SmoothingConfig, cfg: &SmoothingConfig,
) -> Result<Vec<TerrainPoint>, GpxError> { ) -> Result<Vec<TerrainPoint>, GpxError> {
let _ = (points, cfg); if points.len() < 2 {
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") return Err(GpxError::TooShort);
}
// 1. Cumulative ground distance, dropping non-advancing fixes.
let mut cum_m: Vec<f64> = Vec::with_capacity(points.len());
let mut raw_ele: Vec<f32> = Vec::with_capacity(points.len());
cum_m.push(0.0);
raw_ele.push(points[0].elevation_m);
let mut total = 0.0f64;
for pair in points.windows(2) {
let step = haversine_m(pair[0], pair[1]);
if !step.is_finite() || step <= 0.0 {
continue;
}
total += step;
cum_m.push(total);
raw_ele.push(pair[1].elevation_m);
}
if cum_m.len() < 2 || total <= 0.0 {
return Err(GpxError::TooShort);
}
// 2. Even resampling.
let mut spacing = if cfg.resample_m.is_finite() && cfg.resample_m > 0.0 {
cfg.resample_m
} else {
SmoothingConfig::default().resample_m
};
if total / spacing > MAX_RESAMPLED_POINTS as f64 {
spacing = total / MAX_RESAMPLED_POINTS as f64;
}
let count = (total / spacing).floor() as usize + 1;
if count < 3 {
return Err(GpxError::TooShort);
}
let mut grid_ele = Vec::with_capacity(count);
let mut cursor = 0usize;
for i in 0..count {
let x = i as f64 * spacing;
while cursor + 2 < cum_m.len() && cum_m[cursor + 1] < x {
cursor += 1;
}
let (x0, x1) = (cum_m[cursor], cum_m[cursor + 1]);
let (y0, y1) = (raw_ele[cursor], raw_ele[cursor + 1]);
let span = x1 - x0;
let f = if span > 0.0 {
((x - x0) / span).clamp(0.0, 1.0) as f32
} else {
0.0
};
grid_ele.push(y0 + (y1 - y0) * f);
}
// 3. Smooth, over a reflected extension of the series so that the window
// stays full width at the ends. Truncating the window instead leaves
// the first and last samples barely smoothed, and since step 4 reads
// exactly those samples the route would open and close with a gradient
// spike — the very thing this module exists to prevent.
//
// Two passes, not one. A single boxcar has a poor stopband: neighbouring
// windows share all but two samples, so the residual after one pass is
// strongly correlated and re-emerges as a step change once
// differentiated. Cascading two boxcars gives a triangular kernel,
// which cuts that step-to-step residual by roughly a factor of five
// while still reproducing a constant gradient exactly.
let window_m = if cfg.window_m.is_finite() && cfg.window_m > 0.0 {
cfg.window_m
} else {
SmoothingConfig::default().window_m
};
let half = (((window_m / spacing) * 0.5).round().max(1.0) as usize).min(count - 1);
// Two smoothing passes and the derivative each eat `half` at both ends.
let pad = 3 * half;
let padded = reflect_pad(&grid_ele, pad);
let smoothed = moving_average(&moving_average(&padded, half), half);
// 4. Differentiate over the smoothing window, then 5. clamp.
let (lo, hi) = gradient_bounds(cfg);
let run = 2.0 * half as f64 * spacing;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let centre = i + pad;
let gradient = if run > 0.0 {
100.0 * (smoothed[centre + half] - smoothed[centre - half]) as f64 / run
} else {
0.0
};
out.push(TerrainPoint {
distance_m: i as f64 * spacing,
gradient_pct: (gradient as f32).clamp(lo, hi),
elevation_m: smoothed[centre],
});
}
Ok(out)
}
/// Extend a series by `pad` samples at each end by reflecting *through* the
/// endpoint rather than about it: `x[-k] = 2·x[0] x[k]`.
///
/// A plain mirror would fold a climb back on itself and read as a summit at
/// the trailhead. Reflecting through the endpoint continues the local trend
/// instead, so a constant gradient stays constant right to the edge.
fn reflect_pad(src: &[f32], pad: usize) -> Vec<f32> {
let n = src.len();
debug_assert!(n > 0);
let last = n - 1;
let mut out = Vec::with_capacity(n + 2 * pad);
for k in (1..=pad).rev() {
out.push(2.0 * src[0] - src[k.min(last)]);
}
out.extend_from_slice(src);
for k in 1..=pad {
out.push(2.0 * src[last] - src[last.saturating_sub(k)]);
}
out
}
/// A config with the bounds the wrong way round should not produce an empty
/// clamp range and a stream of NaN.
fn gradient_bounds(cfg: &SmoothingConfig) -> (f32, f32) {
let defaults = SmoothingConfig::default();
let lo = if cfg.min_gradient_pct.is_finite() {
cfg.min_gradient_pct
} else {
defaults.min_gradient_pct
};
let hi = if cfg.max_gradient_pct.is_finite() {
cfg.max_gradient_pct
} else {
defaults.max_gradient_pct
};
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
/// Centred moving average over `2·half + 1` samples, with the window truncated
/// symmetrically at the ends so the series is not phase-shifted. Prefix sums
/// in f64 keep it O(n) without losing precision on long tracks.
fn moving_average(src: &[f32], half: usize) -> Vec<f32> {
let n = src.len();
let mut prefix = Vec::with_capacity(n + 1);
prefix.push(0.0f64);
for &v in src {
prefix.push(prefix[prefix.len() - 1] + v as f64);
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
// Shrink from both sides equally near an edge, so the window stays
// centred on `i`.
let reach = half.min(i).min(n - 1 - i);
let a = i - reach;
let b = i + reach;
let sum = prefix[b + 1] - prefix[a];
out.push((sum / (b - a + 1) as f64) as f32);
}
out
} }
/// Convenience: GPX document to a ready-to-ride single-block profile. /// Convenience: GPX document to a ready-to-ride single-block profile.
pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> { pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> {
let _ = (xml, name, cfg); let points = parse(xml)?;
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") let terrain = to_terrain(&points, cfg)?;
Ok(Profile {
name: name.to_string(),
description: Some(format!(
"Imported from GPX: {:.1} km",
terrain.last().map(|p| p.distance_m).unwrap_or(0.0) / 1000.0
)),
blocks: vec![Block::Terrain { points: terrain }],
// FR-5.6 leaves the choice to the rider; a real route finishes.
looping: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CLIMB: &str = include_str!("../../../testdata/sample-climb.gpx");
fn point(lat: f64, lon: f64, ele: f32) -> TrackPoint {
TrackPoint {
lat_deg: lat,
lon_deg: lon,
elevation_m: ele,
}
}
// ---- haversine -------------------------------------------------------
#[test]
fn haversine_matches_one_degree_of_latitude() {
// A degree of latitude on a sphere of radius R is π·R/180.
let d = haversine_m(point(0.0, 0.0, 0.0), point(1.0, 0.0, 0.0));
let expected = std::f64::consts::PI * EARTH_RADIUS_M / 180.0;
assert!((d - expected).abs() < 1.0, "{d} vs {expected}");
assert!((d - 111_194.9).abs() < 2.0);
}
#[test]
fn haversine_matches_a_known_city_pair() {
// Paris (Notre-Dame) to London (Charing Cross), ~343 km great circle.
let paris = point(48.8530, 2.3499, 0.0);
let london = point(51.5074, -0.1278, 0.0);
let d = haversine_m(paris, london) / 1000.0;
assert!((d - 343.0).abs() < 3.0, "{d} km");
}
#[test]
fn haversine_is_symmetric_and_zero_for_identical_points() {
let a = point(45.0, 6.0, 100.0);
let b = point(45.001, 6.001, 100.0);
assert_eq!(haversine_m(a, a), 0.0);
assert!((haversine_m(a, b) - haversine_m(b, a)).abs() < 1e-9);
}
#[test]
fn haversine_shrinks_with_latitude_for_a_fixed_longitude_step() {
let equator = haversine_m(point(0.0, 0.0, 0.0), point(0.0, 1.0, 0.0));
let high = haversine_m(point(60.0, 0.0, 0.0), point(60.0, 1.0, 0.0));
// cos(60°) = 0.5.
assert!((high / equator - 0.5).abs() < 1e-3);
}
// ---- parsing ---------------------------------------------------------
#[test]
fn parses_a_namespaced_track() {
let xml = r#"<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>t</name><trkseg>
<trkpt lat="45.0" lon="6.0"><ele>100.0</ele></trkpt>
<trkpt lat="45.001" lon="6.0"><ele>105.0</ele></trkpt>
</trkseg></trk>
</gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[0], point(45.0, 6.0, 100.0));
assert_eq!(points[1].elevation_m, 105.0);
}
#[test]
fn parses_a_prefixed_namespace() {
let xml = r#"<g:gpx xmlns:g="http://www.topografix.com/GPX/1/1">
<g:trk><g:trkseg>
<g:trkpt lat="1.0" lon="2.0"><g:ele>10</g:ele></g:trkpt>
<g:trkpt lat="1.001" lon="2.0"><g:ele>20</g:ele></g:trkpt>
</g:trkseg></g:trk></g:gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[1].elevation_m, 20.0);
}
#[test]
fn parses_multiple_segments_in_order() {
let xml = r#"<gpx><trk>
<trkseg>
<trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
<trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
</trkseg>
<trkseg>
<trkpt lat="45.002" lon="6.0"><ele>120</ele></trkpt>
</trkseg>
</trk></gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 3);
assert_eq!(points[2].elevation_m, 120.0);
}
#[test]
fn parses_a_route_rather_than_a_track() {
let xml = r#"<gpx><rte>
<rtept lat="45.0" lon="6.0"><ele>100</ele></rtept>
<rtept lat="45.001" lon="6.0"><ele>110</ele></rtept>
</rte></gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 2);
}
#[test]
fn standalone_waypoints_are_ignored() {
let xml = r#"<gpx>
<wpt lat="10.0" lon="10.0"><ele>999</ele><name>café</name></wpt>
<trk><trkseg>
<trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
<trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
</trkseg></trk>
</gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[0].lat_deg, 45.0);
}
#[test]
fn missing_elevation_is_bridged_from_neighbours() {
let xml = r#"<gpx><trk><trkseg>
<trkpt lat="45.000" lon="6.0"><ele>100</ele></trkpt>
<trkpt lat="45.001" lon="6.0"/>
<trkpt lat="45.002" lon="6.0"/>
<trkpt lat="45.003" lon="6.0"><ele>130</ele></trkpt>
</trkseg></trk></gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 4);
assert!((points[1].elevation_m - 110.0).abs() < 1e-4);
assert!((points[2].elevation_m - 120.0).abs() < 1e-4);
}
#[test]
fn leading_and_trailing_missing_elevation_are_held() {
let xml = r#"<gpx><trk><trkseg>
<trkpt lat="45.000" lon="6.0"/>
<trkpt lat="45.001" lon="6.0"><ele>100</ele></trkpt>
<trkpt lat="45.002" lon="6.0"><ele>200</ele></trkpt>
<trkpt lat="45.003" lon="6.0"/>
</trkseg></trk></gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points[0].elevation_m, 100.0);
assert_eq!(points[3].elevation_m, 200.0);
}
#[test]
fn a_track_with_no_elevation_at_all_is_an_error() {
let xml = r#"<gpx><trk><trkseg>
<trkpt lat="45.0" lon="6.0"/>
<trkpt lat="45.001" lon="6.0"/>
</trkseg></trk></gpx>"#;
assert!(matches!(parse(xml), Err(GpxError::NoElevation)));
}
#[test]
fn broken_xml_is_reported_not_panicked() {
assert!(matches!(parse("<gpx><trk>"), Err(GpxError::Malformed(_))));
assert!(matches!(parse(""), Err(GpxError::Malformed(_))));
}
#[test]
fn points_with_unparseable_coordinates_are_skipped() {
let xml = r#"<gpx><trk><trkseg>
<trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
<trkpt lat="oops" lon="6.0"><ele>105</ele></trkpt>
<trkpt lon="6.0"><ele>106</ele></trkpt>
<trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
</trkseg></trk></gpx>"#;
let points = parse(xml).unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[1].elevation_m, 110.0);
}
#[test]
fn an_empty_gpx_yields_no_points() {
assert!(parse("<gpx></gpx>").unwrap().is_empty());
}
// ---- smoothing -------------------------------------------------------
/// Build a track running due north with a prescribed elevation series,
/// spaced roughly `spacing_m` apart.
fn synthetic_track(elevations: &[f32], spacing_m: f64) -> Vec<TrackPoint> {
let dlat = spacing_m / (std::f64::consts::PI * EARTH_RADIUS_M / 180.0);
elevations
.iter()
.enumerate()
.map(|(i, &e)| point(45.0 + i as f64 * dlat, 6.0, e))
.collect()
}
/// Deterministic pseudo-noise; no rand dependency in the core crate.
fn noise(i: usize) -> f32 {
let x = (i as f32 * 12.9898).sin() * 43758.547;
(x - x.floor()) * 2.0 - 1.0
}
/// The largest gradient change between adjacent samples — the quantity a
/// rider feels as a lurch.
fn worst_gradient_step(terrain: &[TerrainPoint]) -> f32 {
terrain
.windows(2)
.map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs())
.fold(0.0f32, f32::max)
}
/// Mean gradient over a distance range, for asserting on route structure.
fn mean_gradient(terrain: &[TerrainPoint], from_m: f64, to_m: f64) -> f32 {
let values: Vec<f32> = terrain
.iter()
.filter(|p| p.distance_m >= from_m && p.distance_m <= to_m)
.map(|p| p.gradient_pct)
.collect();
assert!(!values.is_empty(), "no samples in {from_m}..{to_m} m");
values.iter().sum::<f32>() / values.len() as f32
}
#[test]
fn noisy_elevation_yields_smooth_bounded_gradients() {
// A true 5% climb, 2 km long, buried in ±3 m of GPS elevation noise —
// differentiating this raw would swing by ±60% between samples.
let spacing = 10.0;
let elevations: Vec<f32> = (0..200)
.map(|i| 1000.0 + i as f32 * spacing as f32 * 0.05 + noise(i) * 3.0)
.collect();
let track = synthetic_track(&elevations, spacing);
let cfg = SmoothingConfig::default();
let terrain = to_terrain(&track, &cfg).unwrap();
assert!(terrain.len() > 100);
for p in &terrain {
assert!(p.gradient_pct.is_finite());
assert!(
(cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct),
"gradient {} escaped the clamp",
p.gradient_pct
);
}
// Smooth: no violent sample-to-sample steps, anywhere including the
// ends, where a truncated window would otherwise leave a spike.
let worst_step = worst_gradient_step(&terrain);
// At 10 m spacing and 20 km/h that is well under 0.5 %/s of gradient
// change — below the trainer's own resolution, let alone the rider's.
assert!(
worst_step < 0.75,
"gradient jumped by {worst_step}% in one step"
);
// Accurate: the interior tracks the true 5%.
let interior = &terrain[20..terrain.len() - 20];
let mean: f32 =
interior.iter().map(|p| p.gradient_pct).sum::<f32>() / interior.len() as f32;
assert!(
(mean - 5.0).abs() < 0.5,
"mean gradient {mean}%, expected 5%"
);
for p in interior {
assert!(
(p.gradient_pct - 5.0).abs() < 2.0,
"noise survived smoothing: {}%",
p.gradient_pct
);
}
}
#[test]
fn naive_differentiation_would_have_failed_the_same_data() {
// Guards the test above from being vacuous: confirm the input really
// is too noisy to differentiate directly.
let spacing = 10.0f32;
let elevations: Vec<f32> = (0..200)
.map(|i| 1000.0 + i as f32 * spacing * 0.05 + noise(i) * 3.0)
.collect();
let worst = elevations
.windows(2)
.map(|w| ((w[1] - w[0]) / spacing * 100.0).abs())
.fold(0.0f32, f32::max);
assert!(
worst > 30.0,
"test data is not actually noisy (peak {worst}%)"
);
}
#[test]
fn a_flat_track_produces_zero_gradient() {
let track = synthetic_track(&[100.0; 100], 10.0);
let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
assert!(terrain.iter().all(|p| p.gradient_pct.abs() < 1e-3));
}
#[test]
fn a_clean_ramp_recovers_its_true_gradient() {
// 8% over 3 km, no noise.
let elevations: Vec<f32> = (0..300).map(|i| 500.0 + i as f32 * 10.0 * 0.08).collect();
let track = synthetic_track(&elevations, 10.0);
let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
let interior = &terrain[15..terrain.len() - 15];
for p in interior {
assert!((p.gradient_pct - 8.0).abs() < 0.2, "{}", p.gradient_pct);
}
}
#[test]
fn gradients_are_clamped_to_the_configured_range() {
// A 40% wall — far beyond anything safe to send to a trainer.
let elevations: Vec<f32> = (0..200).map(|i| i as f32 * 10.0 * 0.4).collect();
let track = synthetic_track(&elevations, 10.0);
let cfg = SmoothingConfig {
min_gradient_pct: -8.0,
max_gradient_pct: 12.0,
..Default::default()
};
let terrain = to_terrain(&track, &cfg).unwrap();
assert!(terrain.iter().all(|p| p.gradient_pct <= 12.0));
assert!(terrain.iter().all(|p| p.gradient_pct >= -8.0));
assert!(terrain.iter().any(|p| (p.gradient_pct - 12.0).abs() < 1e-4));
}
#[test]
fn descents_produce_negative_gradients() {
let elevations: Vec<f32> = (0..200).map(|i| 1000.0 - i as f32 * 10.0 * 0.06).collect();
let track = synthetic_track(&elevations, 10.0);
let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
let mid = terrain[terrain.len() / 2].gradient_pct;
assert!((mid + 6.0).abs() < 0.2, "{mid}");
}
#[test]
fn a_wider_window_gives_a_smoother_result() {
let elevations: Vec<f32> = (0..400)
.map(|i| 1000.0 + i as f32 * 0.3 + noise(i) * 4.0)
.collect();
let track = synthetic_track(&elevations, 10.0);
let roughness = |window_m: f64| {
let cfg = SmoothingConfig {
window_m,
..Default::default()
};
let terrain = to_terrain(&track, &cfg).unwrap();
terrain
.windows(2)
.map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs())
.sum::<f32>()
};
assert!(roughness(200.0) < roughness(30.0));
}
#[test]
fn distance_axis_is_evenly_spaced_and_monotone() {
let track = synthetic_track(&[100.0; 150], 7.0);
let cfg = SmoothingConfig {
resample_m: 25.0,
..Default::default()
};
let terrain = to_terrain(&track, &cfg).unwrap();
for (i, p) in terrain.iter().enumerate() {
assert!((p.distance_m - i as f64 * 25.0).abs() < 1e-6);
}
}
#[test]
fn stationary_and_duplicate_fixes_are_discarded() {
let mut track = synthetic_track(&[100.0, 105.0, 110.0, 115.0], 100.0);
// Insert repeats of the second fix, as a GPS does at a traffic light.
for _ in 0..20 {
track.insert(2, track[1]);
}
let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
assert!((terrain.last().unwrap().distance_m - 300.0).abs() < 15.0);
}
#[test]
fn short_or_degenerate_tracks_are_rejected() {
assert!(matches!(
to_terrain(&[], &SmoothingConfig::default()),
Err(GpxError::TooShort)
));
assert!(matches!(
to_terrain(&[point(45.0, 6.0, 100.0)], &SmoothingConfig::default()),
Err(GpxError::TooShort)
));
// Two identical points: no distance at all.
let same = [point(45.0, 6.0, 100.0), point(45.0, 6.0, 100.0)];
assert!(matches!(
to_terrain(&same, &SmoothingConfig::default()),
Err(GpxError::TooShort)
));
// Real but far shorter than one resample step.
let tiny = synthetic_track(&[100.0, 101.0], 2.0);
assert!(matches!(
to_terrain(&tiny, &SmoothingConfig::default()),
Err(GpxError::TooShort)
));
}
#[test]
fn a_degenerate_config_falls_back_rather_than_dividing_by_zero() {
let track = synthetic_track(&[100.0, 110.0, 120.0, 130.0, 140.0], 100.0);
let cfg = SmoothingConfig {
resample_m: 0.0,
window_m: -5.0,
min_gradient_pct: 15.0,
max_gradient_pct: -10.0,
};
let terrain = to_terrain(&track, &cfg).unwrap();
assert!(!terrain.is_empty());
assert!(terrain
.iter()
.all(|p| p.gradient_pct.is_finite() && (-10.0..=15.0).contains(&p.gradient_pct)));
}
// ---- end to end ------------------------------------------------------
#[test]
fn the_shipped_sample_climb_imports_cleanly() {
let points = parse(SAMPLE_CLIMB).unwrap();
assert!(points.len() > 100, "{} points", points.len());
let cfg = SmoothingConfig::default();
let profile = import(SAMPLE_CLIMB, "Sample climb", &cfg).unwrap();
assert_eq!(profile.name, "Sample climb");
assert!(!profile.looping);
assert_eq!(profile.blocks.len(), 1);
profile.validate().unwrap();
let Block::Terrain { points: terrain } = &profile.blocks[0] else {
panic!("expected a terrain block");
};
assert!(terrain.len() > 10);
for p in terrain {
assert!(p.gradient_pct.is_finite());
assert!((cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct));
}
// The fixture is noisy but is a genuine climb, so the mean must be up.
let mean: f32 = terrain.iter().map(|p| p.gradient_pct).sum::<f32>() / terrain.len() as f32;
assert!(mean > 0.0, "sample climb averaged {mean}%");
// And the profile it produces is rideable.
let extent = profile.total_extent();
assert!(extent.metres.unwrap_or(0.0) > 100.0);
assert!(profile
.sample(crate::profile::Position {
elapsed_s: 0.0,
distance_m: 50.0,
})
.is_some());
}
/// The fixture carries ±1.5 m of elevation noise on every point over a
/// route with known structure: ~500 m flat, ~1.8 km climbing at 68%
/// (sinusoidally varying), then ~700 m descending at about 4.5%. The
/// pipeline has to recover that structure, not the noise.
#[test]
fn the_shipped_sample_climb_recovers_its_real_structure() {
let cfg = SmoothingConfig::default();
let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap();
let total = terrain.last().unwrap().distance_m;
assert!((total - 3040.0).abs() < 100.0, "route measured {total} m");
// No lurches anywhere on the route, ends included.
let worst_step = worst_gradient_step(&terrain);
assert!(
worst_step < 1.0,
"gradient jumped by {worst_step}% in one step"
);
// Opening flat.
let flat = mean_gradient(&terrain, 0.0, 400.0);
assert!(flat.abs() < 1.0, "flat section read {flat}%");
// The climb, sampled clear of the transitions at either end.
let climb = mean_gradient(&terrain, 700.0, 2200.0);
assert!((3.0..8.0).contains(&climb), "climb averaged {climb}%");
for p in terrain
.iter()
.filter(|p| (700.0..=2200.0).contains(&p.distance_m))
{
assert!(
(2.0..10.0).contains(&p.gradient_pct),
"climb sample at {} m read {}%",
p.distance_m,
p.gradient_pct
);
}
// The closing descent.
let descent = mean_gradient(&terrain, 2500.0, 2900.0);
assert!(
(-6.0..-3.0).contains(&descent),
"descent averaged {descent}%"
);
// Net ascent, integrated from the smoothed gradient, matches the route.
let spacing = cfg.resample_m as f32;
let ascent: f32 = terrain
.iter()
.map(|p| (p.gradient_pct / 100.0 * spacing).max(0.0))
.sum();
assert!((60.0..110.0).contains(&ascent), "net ascent {ascent} m");
}
#[test]
fn the_shipped_sample_climb_survives_a_tight_smoothing_window() {
// Even at a third of the default window the result must stay usable:
// noisier, but still free of step changes a rider would feel.
let cfg = SmoothingConfig {
window_m: 30.0,
..Default::default()
};
let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap();
let worst_step = worst_gradient_step(&terrain);
assert!(
worst_step < 4.0,
"gradient jumped by {worst_step}% in one step"
);
assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
}
#[test]
fn import_propagates_parse_errors() {
assert!(matches!(
import("<gpx>", "n", &SmoothingConfig::default()),
Err(GpxError::Malformed(_))
));
assert!(matches!(
import("<gpx></gpx>", "n", &SmoothingConfig::default()),
Err(GpxError::TooShort)
));
}
} }
+429 -4
View File
@@ -23,6 +23,24 @@ pub const GRAVITY: f32 = 9.80665;
/// below which the rider is considered stopped. /// below which the rider is considered stopped.
pub const MIN_SPEED_MPS: f32 = 0.5; pub const MIN_SPEED_MPS: f32 = 0.5;
/// Absolute ceiling on virtual speed, ~144 km/h. Aerodynamic drag bounds the
/// model well below this for any plausible input; the cap exists so that
/// absurd configuration (CdA of zero, a 90% descent) still cannot run away.
pub const MAX_SPEED_MPS: f32 = 40.0;
/// Longest tick the integrator will honour. A caller that stalls for a minute
/// must not be allowed to teleport the rider down a mountain.
const MAX_DT_S: f32 = 10.0;
/// The integrator sub-divides the caller's `dt` to this resolution. Forward
/// Euler on `P/v` is stiff at low speed, so the result would otherwise depend
/// on how often the caller happens to tick; sub-stepping makes a 1 Hz tick and
/// a 4 Hz tick agree.
const SUBSTEP_S: f32 = 0.02;
/// Gradients beyond this are not physical roads and only appear as bad input.
const MAX_ABS_GRADIENT_PCT: f32 = 100.0;
/// Evolving physical state of the virtual rider. /// Evolving physical state of the virtual rider.
#[derive(Debug, Clone, Copy, PartialEq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct PhysicsState { pub struct PhysicsState {
@@ -41,8 +59,50 @@ impl PhysicsState {
/// rather than snapping to it — and must never produce negative speed, /// rather than snapping to it — and must never produce negative speed,
/// NaN, or unbounded values for any finite input. /// NaN, or unbounded values for any finite input.
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) { pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
let _ = (power_w, gradient_pct, cfg, dt); let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
todo!("implemented in crates/core/src/physics.rs — see AGENT task A") if dt <= 0.0 {
return;
}
let forces = Forces::new(power_w, gradient_pct, cfg);
// Recover from a poisoned state rather than propagating it: a single
// bad tick must not permanently wedge the ride.
if !self.speed_mps.is_finite() {
self.speed_mps = 0.0;
}
if !self.distance_m.is_finite() {
self.distance_m = 0.0;
}
if !self.elevation_gain_m.is_finite() {
self.elevation_gain_m = 0.0;
}
let steps = (dt / SUBSTEP_S).ceil().max(1.0);
let h = dt / steps;
let steps = steps as u32;
for _ in 0..steps {
let v0 = self.speed_mps.clamp(0.0, MAX_SPEED_MPS);
let v1 = (v0 + forces.acceleration(v0) * h).clamp(0.0, MAX_SPEED_MPS);
self.speed_mps = v1;
// Trapezoidal: with forward Euler on velocity this is the exact
// integral of the linear velocity ramp over the sub-step.
let ds = (0.5 * (v0 + v1) * h) as f64;
self.distance_m += ds;
// `ds` is measured along the road surface, so the vertical
// component is sin(θ). Only ascent counts (FR-7.6).
let climb = ds as f32 * forces.sin_theta;
if climb > 0.0 {
self.elevation_gain_m += climb;
}
}
if !self.speed_mps.is_finite() {
self.speed_mps = 0.0;
}
} }
pub fn speed_kph(&self) -> f32 { pub fn speed_kph(&self) -> f32 {
@@ -54,10 +114,375 @@ impl PhysicsState {
} }
} }
/// The speed-independent parts of the force balance, computed once per tick.
struct Forces {
/// `P × efficiency`; divided by speed to give propulsive force.
wheel_power_w: f32,
sin_theta: f32,
/// Gravity plus rolling resistance, newtons. Constant in speed.
resistive_n: f32,
/// `½ρ·CdA`; multiplied by v² to give drag.
drag_k: f32,
mass_kg: f32,
}
impl Forces {
fn new(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> Self {
// Braking is not modelled, so negative power is treated as coasting.
let power = sanitise(power_w, 0.0).max(0.0);
let gradient =
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
let theta = (gradient / 100.0).atan();
// A zero or negative mass would divide by zero; a config that broken
// should degrade rather than produce NaN.
let mass = sanitise(cfg.total_mass_kg(), 83.0).max(1.0);
let efficiency = sanitise(cfg.drivetrain_efficiency, 1.0).clamp(0.0, 1.0);
let crr = sanitise(cfg.crr, 0.0).max(0.0);
let cda = sanitise(cfg.cda, 0.0).max(0.0);
let rho = sanitise(cfg.air_density, 0.0).max(0.0);
Self {
wheel_power_w: power * efficiency,
sin_theta: theta.sin(),
resistive_n: mass * GRAVITY * (theta.sin() + crr * theta.cos()),
drag_k: 0.5 * rho * cda,
mass_kg: mass,
}
}
fn acceleration(&self, v: f32) -> f32 {
let propulsive = self.wheel_power_w / v.max(MIN_SPEED_MPS);
// Rolling resistance and gravity are folded together, so at a
// standstill on the flat the net is a small negative that the ≥0 clamp
// absorbs — the rider does not roll backwards.
let net = propulsive - self.resistive_n - self.drag_k * v * v;
let a = net / self.mass_kg;
if a.is_finite() {
a
} else {
0.0
}
}
}
fn sanitise(value: f32, fallback: f32) -> f32 {
if value.is_finite() {
value
} else {
fallback
}
}
/// Steady-state speed for a given power and gradient — the speed at which /// Steady-state speed for a given power and gradient — the speed at which
/// propulsive and resistive forces balance. Useful for tests and for sanity /// propulsive and resistive forces balance. Useful for tests and for sanity
/// checks on the resistance curve later. /// checks on the resistance curve later.
///
/// The balance is a cubic in `v` (`P·η = F_const·v + k·v³`) with no clean
/// closed form once the `max(v, v_min)` floor is included, so it is solved by
/// bisection. Net force is non-increasing in `v`, which makes the bracket
/// unambiguous.
pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 { pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 {
let _ = (power_w, gradient_pct, cfg); let forces = Forces::new(power_w, gradient_pct, cfg);
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
// Cannot get moving at all: the rider stalls on the climb.
if forces.acceleration(0.0) <= 0.0 {
return 0.0;
}
if forces.acceleration(MAX_SPEED_MPS) > 0.0 {
return MAX_SPEED_MPS;
}
let mut lo = 0.0f32;
let mut hi = MAX_SPEED_MPS;
// 60 halvings takes the bracket far below f32 resolution.
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
if mid <= lo || mid >= hi {
break;
}
if forces.acceleration(mid) > 0.0 {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> RiderConfig {
RiderConfig::default()
}
/// Run the integrator to steady state and return the state.
fn settle(power_w: f32, gradient_pct: f32, seconds: f32) -> PhysicsState {
let mut s = PhysicsState::default();
let cfg = cfg();
let dt = 0.25;
let ticks = (seconds / dt) as u32;
for _ in 0..ticks {
s.step(power_w, gradient_pct, &cfg, dt);
}
s
}
#[test]
fn equilibrium_is_a_fixed_point_of_the_integrator() {
for (power, gradient) in [(200.0, 0.0), (300.0, 5.0), (150.0, -2.0), (400.0, 8.0)] {
let target = equilibrium_speed_mps(power, gradient, &cfg());
let settled = settle(power, gradient, 900.0).speed_mps;
assert!(
(settled - target).abs() < 0.05,
"P={power} g={gradient}: integrator settled at {settled}, equilibrium says {target}"
);
}
}
#[test]
fn equilibrium_matches_hand_computed_flat_case() {
// 250 W on the flat with the default rider: solve P·η = F_roll·v + k·v³.
let c = cfg();
let v = equilibrium_speed_mps(250.0, 0.0, &c);
let m = c.total_mass_kg();
let f_roll = m * GRAVITY * c.crr;
let drag = 0.5 * c.air_density * c.cda;
let balance = 250.0 * c.drivetrain_efficiency - (f_roll * v + drag * v * v * v);
assert!(balance.abs() < 0.5, "residual force {balance} N at v={v}");
// Sanity: a 75 kg rider at 250 W on the flat sits around 40 km/h.
assert!((35.0..45.0).contains(&(v * 3.6)), "{} km/h", v * 3.6);
}
#[test]
fn speed_approaches_equilibrium_rather_than_snapping() {
let c = cfg();
let target = equilibrium_speed_mps(250.0, 0.0, &c);
let mut s = PhysicsState::default();
s.step(250.0, 0.0, &c, 1.0);
let after_one_second = s.speed_mps;
assert!(
after_one_second < target * 0.75,
"one second reached {after_one_second} of {target} — no inertia"
);
assert!(after_one_second > 0.0);
for _ in 0..600 {
s.step(250.0, 0.0, &c, 1.0);
}
assert!((s.speed_mps - target).abs() < 0.05);
}
#[test]
fn tick_rate_does_not_change_the_outcome() {
let c = cfg();
let mut coarse = PhysicsState::default();
let mut fine = PhysicsState::default();
for _ in 0..60 {
coarse.step(300.0, 3.0, &c, 1.0);
}
for _ in 0..600 {
fine.step(300.0, 3.0, &c, 0.1);
}
assert!((coarse.speed_mps - fine.speed_mps).abs() < 0.02);
assert!((coarse.distance_m - fine.distance_m).abs() < 1.0);
}
#[test]
fn zero_power_coasts_to_a_stop_on_the_flat() {
let c = cfg();
let mut s = PhysicsState {
speed_mps: 11.0,
..Default::default()
};
let start = s.speed_mps;
s.step(0.0, 0.0, &c, 1.0);
assert!(s.speed_mps < start, "coasting must decelerate");
for _ in 0..600 {
s.step(0.0, 0.0, &c, 1.0);
}
assert_eq!(s.speed_mps, 0.0, "should have come to rest");
assert!(!s.is_moving());
assert!(s.distance_m > 0.0 && s.distance_m < 2000.0);
}
#[test]
fn stationary_with_no_power_never_goes_backwards() {
let c = cfg();
let mut s = PhysicsState::default();
for _ in 0..100 {
s.step(0.0, 0.0, &c, 1.0);
assert_eq!(s.speed_mps, 0.0);
}
assert_eq!(s.distance_m, 0.0);
}
#[test]
fn steep_climb_stalls_but_stays_non_negative() {
let c = cfg();
let mut s = PhysicsState::default();
for _ in 0..300 {
s.step(60.0, 20.0, &c, 1.0);
assert!(s.speed_mps >= 0.0);
}
assert!(s.speed_mps < 1.0, "60 W up 20% should barely move");
assert_eq!(equilibrium_speed_mps(60.0, 20.0, &c), 0.0);
}
#[test]
fn steep_descent_accelerates_to_a_bounded_terminal_speed() {
let c = cfg();
let mut s = PhysicsState::default();
for _ in 0..600 {
s.step(0.0, -12.0, &c, 1.0);
}
let terminal = equilibrium_speed_mps(0.0, -12.0, &c);
assert!(terminal > 10.0, "should freewheel downhill, got {terminal}");
assert!(terminal < MAX_SPEED_MPS);
assert!((s.speed_mps - terminal).abs() < 0.1);
assert_eq!(s.elevation_gain_m, 0.0, "descending gains no elevation");
}
#[test]
fn more_power_always_means_more_speed() {
let c = cfg();
let mut previous = -1.0;
for power in [0.0, 50.0, 100.0, 200.0, 300.0, 500.0, 1000.0] {
let v = equilibrium_speed_mps(power, 0.0, &c);
assert!(
v > previous,
"{power} W gave {v} m/s, not more than {previous}"
);
previous = v;
}
}
#[test]
fn steeper_gradient_always_means_less_speed() {
let c = cfg();
let mut previous = f32::INFINITY;
for gradient in [-10.0, -5.0, 0.0, 2.0, 5.0, 10.0, 15.0] {
let v = equilibrium_speed_mps(300.0, gradient, &c);
assert!(
v < previous,
"{gradient}% gave {v} m/s, not less than {previous}"
);
previous = v;
}
}
#[test]
fn distance_and_elevation_accumulate_consistently() {
let s = settle(250.0, 5.0, 600.0);
assert!(s.distance_m > 0.0);
// 5% grade: vertical is sin(atan(0.05)) ≈ 0.0499 of distance travelled.
let expected = s.distance_m as f32 * (0.05f32.atan()).sin();
assert!(
(s.elevation_gain_m - expected).abs() < expected * 0.01,
"gain {} vs expected {expected}",
s.elevation_gain_m
);
}
#[test]
fn elevation_gain_counts_only_ascent() {
let c = cfg();
let mut s = PhysicsState::default();
for _ in 0..300 {
s.step(250.0, 5.0, &c, 1.0);
}
let after_climb = s.elevation_gain_m;
assert!(after_climb > 10.0);
for _ in 0..300 {
s.step(250.0, -5.0, &c, 1.0);
}
assert_eq!(
s.elevation_gain_m, after_climb,
"descent must not reduce gain"
);
}
#[test]
fn hostile_inputs_never_produce_nan_or_negatives() {
let mut c = cfg();
let hostile = [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-1.0e30,
1.0e30,
0.0,
-0.0,
];
for &power in &hostile {
for &gradient in &hostile {
for &dt in &hostile {
let mut s = PhysicsState::default();
s.step(power, gradient, &c, dt);
s.step(power, gradient, &c, 1.0);
assert!(
s.speed_mps.is_finite(),
"speed NaN for {power}/{gradient}/{dt}"
);
assert!(s.speed_mps >= 0.0, "negative speed {}", s.speed_mps);
assert!(s.speed_mps <= MAX_SPEED_MPS);
assert!(s.distance_m.is_finite() && s.distance_m >= 0.0);
assert!(s.elevation_gain_m.is_finite() && s.elevation_gain_m >= 0.0);
}
}
}
// A degenerate rider config must degrade, not explode.
c.rider_kg = 0.0;
c.bike_kg = 0.0;
c.cda = 0.0;
c.air_density = 0.0;
c.crr = f32::NAN;
let mut s = PhysicsState::default();
for _ in 0..100 {
s.step(500.0, -30.0, &c, 1.0);
}
assert!(s.speed_mps.is_finite() && (0.0..=MAX_SPEED_MPS).contains(&s.speed_mps));
assert!(equilibrium_speed_mps(500.0, -30.0, &c).is_finite());
}
#[test]
fn poisoned_state_is_recovered() {
let c = cfg();
let mut s = PhysicsState {
speed_mps: f32::NAN,
distance_m: f64::NAN,
elevation_gain_m: f32::NAN,
};
s.step(200.0, 0.0, &c, 1.0);
assert!(s.speed_mps.is_finite());
assert!(s.distance_m.is_finite());
assert!(s.elevation_gain_m.is_finite());
}
#[test]
fn zero_and_negative_dt_are_no_ops() {
let c = cfg();
let mut s = PhysicsState {
speed_mps: 8.0,
..Default::default()
};
let before = s;
s.step(300.0, 0.0, &c, 0.0);
s.step(300.0, 0.0, &c, -5.0);
assert_eq!(s, before);
}
#[test]
fn speed_kph_conversion() {
let s = PhysicsState {
speed_mps: 10.0,
..Default::default()
};
assert!((s.speed_kph() - 36.0).abs() < 1e-5);
}
} }
+1017 -10
View File
File diff suppressed because it is too large Load Diff
+752 -5
View File
@@ -32,6 +32,12 @@ pub enum RideStatus {
Finished, Finished,
} }
/// Smallest change worth spending a control-point write on. FR-2.8 caps writes
/// at 4 Hz; suppressing no-op targets keeps a 10 Hz tick loop comfortably
/// inside that without a timer, and avoids churning the trainer with values it
/// cannot resolve anyway.
const GRADIENT_EPSILON_PCT: f32 = 0.05;
pub struct RideSession { pub struct RideSession {
pub config: RiderConfig, pub config: RiderConfig,
pub limits: SafetyLimits, pub limits: SafetyLimits,
@@ -41,6 +47,10 @@ pub struct RideSession {
profile: Option<Profile>, profile: Option<Profile>,
/// Manual gradient trim applied on top of the profile's gradient. /// Manual gradient trim applied on top of the profile's gradient.
gradient_offset_pct: f32, gradient_offset_pct: f32,
/// Level held in [`ControlMode::Resistance`] (FR-4.3).
manual_resistance: i16,
/// Wattage held in [`ControlMode::Erg`] (FR-4.6).
erg_watts: u16,
elapsed_ms: u64, elapsed_ms: u64,
last_target: Option<ControlTarget>, last_target: Option<ControlTarget>,
} }
@@ -55,6 +65,8 @@ impl RideSession {
physics: PhysicsState::default(), physics: PhysicsState::default(),
profile: None, profile: None,
gradient_offset_pct: 0.0, gradient_offset_pct: 0.0,
manual_resistance: 0,
erg_watts: 150,
elapsed_ms: 0, elapsed_ms: 0,
last_target: None, last_target: None,
} }
@@ -93,6 +105,50 @@ impl RideSession {
self.gradient_offset_pct = 0.0; self.gradient_offset_pct = 0.0;
} }
pub fn gradient_offset_pct(&self) -> f32 {
self.gradient_offset_pct
}
/// Set the resistance level held in [`ControlMode::Resistance`] (FR-4.3).
///
/// Stored unclamped; `SafetyLimits` still has the final say at
/// transmission, so the rider's setting is never silently rewritten here.
pub fn set_resistance(&mut self, level: i16) {
self.manual_resistance = level;
}
pub fn nudge_resistance(&mut self, delta: i16) {
self.manual_resistance = self.manual_resistance.saturating_add(delta);
}
pub fn resistance_level(&self) -> i16 {
self.manual_resistance
}
/// Set the wattage held in [`ControlMode::Erg`] (FR-4.6).
pub fn set_erg_power(&mut self, watts: u16) {
self.erg_watts = watts;
}
pub fn nudge_erg_power(&mut self, delta: i16) {
self.erg_watts = self.erg_watts.saturating_add_signed(delta);
}
pub fn erg_power_w(&self) -> u16 {
self.erg_watts
}
/// Read-only view of the physics model, for diagnostics and recording.
pub fn physics(&self) -> &PhysicsState {
&self.physics
}
/// The target most recently sent to the trainer, post-clamp (SAF-1: this
/// is what should be held when input is lost).
pub fn last_target(&self) -> Option<ControlTarget> {
self.last_target
}
/// Advance the ride by one tick. /// Advance the ride by one tick.
/// ///
/// Feeds telemetry into the physics model, advances the profile, and /// Feeds telemetry into the physics model, advances the profile, and
@@ -100,18 +156,709 @@ impl RideSession {
/// when paused (no distance accrues) and when telemetry is missing power /// when paused (no distance accrues) and when telemetry is missing power
/// (treat as zero rather than panicking). /// (treat as zero rather than panicking).
pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> { pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> {
let _ = (telemetry, dt_s); let mut events = Vec::new();
todo!("implemented in crates/core/src/session.rs — see AGENT task A") let dt = if dt_s.is_finite() { dt_s.max(0.0) } else { 0.0 };
let running = self.status == RideStatus::Running;
if running {
self.elapsed_ms = self
.elapsed_ms
.saturating_add((dt as f64 * 1000.0).round() as u64);
}
// Resolve the target *before* stepping, so the physics see the same
// gradient the trainer is being asked for this tick.
let desired = self.desired_target();
let exhausted = self.profile.is_some() && desired.is_none();
if running {
// A trainer that reports no power is a trainer the rider is not
// pushing; nothing here may panic on a partial FTMS packet.
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
self.physics
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
}
// Only a running ride commands the trainer. When paused or finished the
// last target simply stands (SAF-1) rather than being re-sent or reset.
if running {
if let Some(target) = desired {
let clamped = self.limits.clamp(target);
if changed_meaningfully(self.last_target, clamped) {
self.last_target = Some(clamped);
events.push(SessionEvent::Command(clamped));
}
}
}
if exhausted && running {
self.status = RideStatus::Finished;
events.push(SessionEvent::ProfileFinished);
}
events.push(SessionEvent::Snapshot(self.snapshot(telemetry)));
events
} }
/// Build the snapshot the UI renders. /// Build the snapshot the UI renders.
pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot { pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot {
let _ = telemetry; RideSnapshot {
todo!("implemented in crates/core/src/session.rs — see AGENT task A") elapsed_ms: self.elapsed_ms,
telemetry,
virtual_speed_kph: self.physics.speed_kph(),
virtual_distance_m: self.physics.distance_m,
gradient_pct: self.simulated_gradient_pct(),
elevation_gain_m: self.physics.elevation_gain_m,
mode: self.mode,
target: self.last_target,
profile_progress: self.profile_progress(),
}
}
/// Fractional progress through the loaded profile (FR-9.7). `None` for a
/// looping profile, which never ends, or when nothing is loaded.
pub fn profile_progress(&self) -> Option<f32> {
let profile = self.profile.as_ref()?;
if profile.looping {
return None;
}
profile.total_extent().progress(self.position())
} }
/// The target that should be in force right now, before clamping. /// The target that should be in force right now, before clamping.
fn desired_target(&self) -> Option<ControlTarget> { fn desired_target(&self) -> Option<ControlTarget> {
todo!("implemented in crates/core/src/session.rs — see AGENT task A") match self.mode {
// No profile involved: the trim *is* the gradient.
ControlMode::ManualGrade => Some(ControlTarget::Gradient {
percent: self.gradient_offset_pct,
}),
ControlMode::Profile => {
let sampled = self.profile.as_ref()?.sample(self.position())?;
Some(match sampled {
// The D-pad trim rides on top of the route (FR-4.2 and
// FR-4.4 are simultaneously active, §5.4).
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
percent: percent + self.gradient_offset_pct,
},
other => other,
})
}
// These modes hold a value the rider set directly and ignore any
// loaded profile — selecting the mode *is* the statement that the
// rider is driving the trainer, not the route.
ControlMode::Resistance => Some(ControlTarget::Resistance {
level: self.manual_resistance,
}),
ControlMode::Erg => Some(ControlTarget::Power {
watts: self.erg_watts,
}),
}
}
/// The gradient the physics model should simulate this tick: the profile's
/// gradient, if it is driving one, plus the manual trim. A profile driving
/// power or resistance contributes no slope, so the rider is on the flat
/// plus whatever trim they have dialled in.
fn simulated_gradient_pct(&self) -> f32 {
let base = match self.mode {
ControlMode::Profile => match self
.profile
.as_ref()
.and_then(|p| p.sample(self.position()))
{
Some(ControlTarget::Gradient { percent }) => percent,
_ => 0.0,
},
_ => 0.0,
};
base + self.gradient_offset_pct
}
}
/// Whether a new target differs enough from the last one to be worth sending.
/// A change of channel always counts.
fn changed_meaningfully(previous: Option<ControlTarget>, next: ControlTarget) -> bool {
match (previous, next) {
(None, _) => true,
(Some(ControlTarget::Gradient { percent: a }), ControlTarget::Gradient { percent: b }) => {
(a - b).abs() >= GRADIENT_EPSILON_PCT
}
(Some(ControlTarget::Resistance { level: a }), ControlTarget::Resistance { level: b }) => {
a != b
}
(Some(ControlTarget::Power { watts: a }), ControlTarget::Power { watts: b }) => a != b,
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::{Block, Channel, Extent, Segment, Waveform};
fn session() -> RideSession {
RideSession::new(RiderConfig::default(), SafetyLimits::default())
}
fn powered(watts: i16) -> Telemetry {
Telemetry {
power_w: Some(watts),
..Default::default()
}
}
fn commands(events: &[SessionEvent]) -> Vec<ControlTarget> {
events
.iter()
.filter_map(|e| match e {
SessionEvent::Command(t) => Some(*t),
_ => None,
})
.collect()
}
fn snapshot_of(events: &[SessionEvent]) -> RideSnapshot {
events
.iter()
.find_map(|e| match e {
SessionEvent::Snapshot(s) => Some(*s),
_ => None,
})
.expect("every tick emits a snapshot")
}
fn gradient_of(target: ControlTarget) -> f32 {
match target {
ControlTarget::Gradient { percent } => percent,
other => panic!("expected a gradient target, got {other:?}"),
}
}
// ---- basic loop ------------------------------------------------------
#[test]
fn every_tick_emits_exactly_one_snapshot() {
let mut s = session();
s.start();
for _ in 0..10 {
let events = s.tick(powered(200), 1.0);
let snapshots = events
.iter()
.filter(|e| matches!(e, SessionEvent::Snapshot(_)))
.count();
assert_eq!(snapshots, 1);
}
}
#[test]
fn running_accrues_time_distance_and_speed() {
let mut s = session();
s.start();
for _ in 0..60 {
s.tick(powered(250), 1.0);
}
let snap = snapshot_of(&s.tick(powered(250), 1.0));
assert_eq!(snap.elapsed_ms, 61_000);
assert!(snap.virtual_distance_m > 300.0);
assert!(snap.virtual_speed_kph > 20.0);
}
// ---- pause -----------------------------------------------------------
#[test]
fn pausing_accrues_neither_time_nor_distance() {
let mut s = session();
s.start();
for _ in 0..30 {
s.tick(powered(250), 1.0);
}
let before = snapshot_of(&s.tick(powered(250), 1.0));
s.pause();
for _ in 0..100 {
let events = s.tick(powered(250), 1.0);
// Paused: nothing new is commanded, the last target stands (SAF-1).
assert!(commands(&events).is_empty());
}
let after = snapshot_of(&s.tick(powered(250), 1.0));
assert_eq!(after.virtual_distance_m, before.virtual_distance_m);
assert_eq!(after.elapsed_ms, before.elapsed_ms);
assert_eq!(after.elevation_gain_m, before.elevation_gain_m);
assert_eq!(after.target, before.target);
}
#[test]
fn an_idle_session_never_commands_the_trainer() {
let mut s = session();
for _ in 0..5 {
assert!(commands(&s.tick(powered(300), 1.0)).is_empty());
}
assert_eq!(s.last_target(), None);
assert_eq!(
snapshot_of(&s.tick(powered(300), 1.0)).virtual_distance_m,
0.0
);
}
#[test]
fn resuming_after_a_pause_continues_from_where_it_stopped() {
let mut s = session();
s.start();
for _ in 0..30 {
s.tick(powered(250), 1.0);
}
let mid = snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m;
s.pause();
s.tick(powered(250), 1.0);
s.start();
for _ in 0..10 {
s.tick(powered(250), 1.0);
}
assert!(snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m > mid);
}
// ---- missing / hostile telemetry ------------------------------------
#[test]
fn missing_power_is_treated_as_zero() {
let mut s = session();
s.start();
for _ in 0..30 {
s.tick(powered(300), 1.0);
}
let moving = snapshot_of(&s.tick(powered(300), 1.0)).virtual_speed_kph;
assert!(moving > 10.0);
// Empty packets — a real FTMS possibility, not a hypothetical.
for _ in 0..300 {
s.tick(Telemetry::default(), 1.0);
}
let snap = snapshot_of(&s.tick(Telemetry::default(), 1.0));
assert!(snap.virtual_speed_kph < moving);
assert_eq!(snap.virtual_speed_kph, 0.0, "should coast to a stop");
}
#[test]
fn negative_power_does_not_drive_the_rider_backwards() {
let mut s = session();
s.start();
for _ in 0..50 {
let snap = snapshot_of(&s.tick(powered(-500), 1.0));
assert!(snap.virtual_speed_kph >= 0.0);
assert_eq!(snap.virtual_distance_m, 0.0);
}
}
#[test]
fn hostile_dt_does_not_corrupt_the_ride() {
let mut s = session();
s.start();
for dt in [f32::NAN, f32::INFINITY, -1.0, 0.0, 1e20] {
let snap = snapshot_of(&s.tick(powered(200), dt));
assert!(snap.virtual_speed_kph.is_finite() && snap.virtual_speed_kph >= 0.0);
assert!(snap.virtual_distance_m.is_finite() && snap.virtual_distance_m >= 0.0);
}
}
// ---- gradient offset -------------------------------------------------
#[test]
fn manual_grade_commands_the_trim_directly() {
let mut s = session();
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
s.nudge_gradient(0.5);
s.nudge_gradient(0.5);
let events = s.tick(powered(0), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 1.0);
assert_eq!(snapshot_of(&events).gradient_pct, 1.0);
s.reset_gradient_offset();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
}
#[test]
fn the_trim_adds_on_top_of_the_profile_gradient() {
let mut s = session();
s.load_profile(Profile {
name: "flat-then-hill".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Gradient,
value: 4.0,
extent: Extent::Seconds(600.0),
}],
});
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.0);
s.nudge_gradient(-1.5);
let events = s.tick(powered(200), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 2.5);
// And the physics see the trimmed gradient too, not the raw profile.
assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
}
#[test]
fn the_trim_does_not_disturb_a_power_profile_target() {
let mut s = session();
s.load_profile(Profile {
name: "erg".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Power,
value: 220.0,
extent: Extent::Seconds(600.0),
}],
});
s.start();
s.nudge_gradient(3.0);
let events = s.tick(powered(220), 1.0);
assert_eq!(commands(&events)[0], ControlTarget::Power { watts: 220 });
// The trim still tilts the virtual road, which is what drives speed.
assert_eq!(snapshot_of(&events).gradient_pct, 3.0);
}
// ---- safety clamping -------------------------------------------------
#[test]
fn out_of_range_gradients_are_clamped_before_transmission() {
let mut s = session();
s.start();
s.nudge_gradient(90.0);
let target = commands(&s.tick(powered(0), 1.0))[0];
assert_eq!(gradient_of(target), s.limits.max_gradient_pct);
s.reset_gradient_offset();
s.nudge_gradient(-90.0);
// Two ticks: the first re-emits after the reset.
s.tick(powered(0), 1.0);
assert!(gradient_of(s.last_target().unwrap()) >= s.limits.min_gradient_pct);
assert_eq!(
gradient_of(s.last_target().unwrap()),
s.limits.min_gradient_pct
);
}
#[test]
fn an_absurd_profile_cannot_command_an_unsafe_target() {
// SAF-6: parameter errors must be caught by SAF-3, not by the profile.
let mut s = session();
s.load_profile(Profile {
name: "runaway".into(),
description: None,
looping: false,
blocks: vec![
Block::Constant {
channel: Channel::Power,
value: 5000.0,
extent: Extent::Seconds(10.0),
},
Block::Constant {
channel: Channel::Gradient,
value: -400.0,
extent: Extent::Seconds(10.0),
},
Block::Constant {
channel: Channel::Resistance,
value: 9000.0,
extent: Extent::Seconds(10.0),
},
],
});
s.start();
let mut seen = Vec::new();
for _ in 0..29 {
seen.extend(commands(&s.tick(powered(200), 1.0)));
}
assert!(!seen.is_empty());
for target in seen {
match target {
ControlTarget::Power { watts } => {
assert!((s.limits.min_power_w..=s.limits.max_power_w).contains(&watts))
}
ControlTarget::Gradient { percent } => assert!((s.limits.min_gradient_pct
..=s.limits.max_gradient_pct)
.contains(&percent)),
ControlTarget::Resistance { level } => {
assert!((s.limits.min_resistance..=s.limits.max_resistance).contains(&level))
}
}
}
}
#[test]
fn custom_limits_are_honoured() {
let mut s = RideSession::new(
RiderConfig::default(),
SafetyLimits {
min_gradient_pct: -2.0,
max_gradient_pct: 3.0,
..Default::default()
},
);
s.start();
s.nudge_gradient(10.0);
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 3.0);
}
// ---- rate limiting ---------------------------------------------------
#[test]
fn an_unchanged_target_is_not_resent() {
let mut s = session();
s.start();
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
for _ in 0..50 {
assert!(
commands(&s.tick(powered(200), 1.0)).is_empty(),
"a steady target must not be re-sent (FR-2.8)"
);
}
s.nudge_gradient(1.0);
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
}
#[test]
fn sub_threshold_gradient_drift_is_suppressed() {
let mut s = session();
s.start();
s.tick(powered(0), 1.0);
s.nudge_gradient(0.01);
assert!(commands(&s.tick(powered(0), 1.0)).is_empty());
for _ in 0..10 {
s.nudge_gradient(0.01);
}
assert_eq!(commands(&s.tick(powered(0), 1.0)).len(), 1);
}
#[test]
fn a_continuously_varying_profile_stays_well_inside_the_write_budget() {
// A 10 Hz tick loop over a gradient ramp must not produce 10 writes a
// second; FR-2.8 caps them at four.
let mut s = session();
s.load_profile(Profile {
name: "ramp".into(),
description: None,
looping: false,
blocks: vec![Block::Ramp {
channel: Channel::Gradient,
from: 0.0,
to: 6.0,
extent: Extent::Seconds(600.0),
}],
});
s.start();
let mut writes = 0;
for _ in 0..6000 {
writes += commands(&s.tick(powered(200), 0.1)).len();
}
// 6 % of gradient at a 0.05 % threshold is ~120 writes over 600 s.
assert!(writes <= 130, "{writes} writes in 600 s");
assert!(writes > 100);
}
// ---- profile lifecycle ----------------------------------------------
#[test]
fn a_non_looping_profile_finishes_once() {
let mut s = session();
s.load_profile(Profile {
name: "short".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Gradient,
value: 2.0,
extent: Extent::Seconds(5.0),
}],
});
s.start();
let mut finishes = 0;
for _ in 0..20 {
finishes += s
.tick(powered(200), 1.0)
.iter()
.filter(|e| matches!(e, SessionEvent::ProfileFinished))
.count();
}
assert_eq!(finishes, 1);
assert_eq!(s.status, RideStatus::Finished);
}
#[test]
fn a_looping_profile_never_finishes() {
let mut s = session();
s.load_profile(Profile {
name: "loop".into(),
description: None,
looping: true,
blocks: vec![Block::Segments {
segments: vec![
Segment {
distance_m: 400.0,
gradient_pct: 0.0,
},
Segment {
distance_m: 400.0,
gradient_pct: 5.0,
},
],
}],
});
s.start();
for _ in 0..1200 {
let events = s.tick(powered(250), 1.0);
assert!(!events
.iter()
.any(|e| matches!(e, SessionEvent::ProfileFinished)));
}
assert_eq!(s.status, RideStatus::Running);
assert!(s.physics().distance_m > 2000.0, "should have lapped");
assert_eq!(s.profile_progress(), None);
}
#[test]
fn progress_advances_from_zero_to_one() {
let mut s = session();
s.load_profile(Profile {
name: "p".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Gradient,
value: 0.0,
extent: Extent::Seconds(100.0),
}],
});
s.start();
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
Some(0.0)
);
for _ in 0..50 {
s.tick(powered(200), 1.0);
}
let mid = snapshot_of(&s.tick(powered(200), 0.0))
.profile_progress
.unwrap();
assert!((mid - 0.5).abs() < 0.02, "{mid}");
for _ in 0..60 {
s.tick(powered(200), 1.0);
}
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
Some(1.0)
);
}
#[test]
fn a_distance_profile_advances_only_as_the_rider_rides() {
let mut s = session();
s.load_profile(Profile {
name: "hill".into(),
description: None,
looping: false,
blocks: vec![Block::Segments {
segments: vec![
Segment {
distance_m: 200.0,
gradient_pct: 0.0,
},
Segment {
distance_m: 200.0,
gradient_pct: 8.0,
},
],
}],
});
s.start();
// No power, so no distance, so no progress no matter how long it runs.
for _ in 0..600 {
s.tick(Telemetry::default(), 1.0);
}
assert_eq!(s.profile_progress(), Some(0.0));
assert!(s.status == RideStatus::Running);
for _ in 0..600 {
s.tick(powered(250), 1.0);
}
assert_eq!(s.status, RideStatus::Finished);
}
#[test]
fn a_wave_profile_drives_the_power_channel() {
let mut s = session();
s.load_profile(Profile {
name: "over-unders".into(),
description: None,
looping: false,
blocks: vec![Block::Wave {
channel: Channel::Power,
shape: Waveform::Sine,
midpoint: 240.0,
amplitude: 40.0,
period: Extent::Seconds(120.0),
repeats: 2.0,
phase: 0.0,
}],
});
s.start();
let mut watts = Vec::new();
for _ in 0..240 {
for target in commands(&s.tick(powered(240), 1.0)) {
match target {
ControlTarget::Power { watts: w } => watts.push(w),
other => panic!("unexpected {other:?}"),
}
}
}
assert!(watts.contains(&280), "peak never reached: {watts:?}");
assert!(watts.contains(&200), "trough never reached");
assert!(watts.iter().all(|w| (200..=280).contains(w)));
}
#[test]
fn loading_a_profile_switches_into_profile_mode() {
let mut s = session();
assert_eq!(s.mode, ControlMode::ManualGrade);
s.load_profile(Profile {
name: "p".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Gradient,
value: 1.0,
extent: Extent::Seconds(10.0),
}],
});
assert_eq!(s.mode, ControlMode::Profile);
assert!(s.profile().is_some());
}
#[test]
fn a_gradient_profile_accumulates_elevation() {
let mut s = session();
s.load_profile(Profile {
name: "climb".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Gradient,
value: 6.0,
extent: Extent::Seconds(1200.0),
}],
});
s.start();
for _ in 0..600 {
s.tick(powered(250), 1.0);
}
let snap = snapshot_of(&s.tick(powered(250), 0.0));
let expected = snap.virtual_distance_m as f32 * (0.06f32.atan()).sin();
assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02);
assert!(snap.elevation_gain_m > 50.0);
} }
} }
+8
View File
@@ -8,3 +8,11 @@ license.workspace = true
bikecontrol-core = { workspace = true } bikecontrol-core = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
# Independent third-party FIT *decoder* (MIT). Test-only: we encode with our own
# writer and decode with someone else's parser, which is a far stronger check
# than round-tripping through our own code.
fitparser = "0.11"
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
//! The FIT CRC-16.
//!
//! The FIT specification defines a nibble-table CRC. It is bit-for-bit
//! CRC-16/ARC (reflected polynomial `0xA001`, init `0x0000`, no final XOR),
//! which gives us published test vectors to check against — see the tests.
//!
//! Two CRCs appear in every FIT file and both must be right or the file is
//! silently rejected on upload:
//!
//! * the *header CRC* — bytes 0..12 of a 14-byte header, stored at bytes 12..14;
//! * the *file CRC* — every byte from the start of the header through the end
//! of the data records, appended as the last two bytes of the file.
/// Nibble lookup table from the FIT SDK.
const CRC_TABLE: [u16; 16] = [
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401,
0x5000, 0x9C01, 0x8801, 0x4400,
];
/// Running FIT CRC-16 state.
///
/// Lets the encoder checksum bytes as they are produced rather than buffering
/// the whole file twice.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Crc16(u16);
impl Crc16 {
/// A fresh CRC with the FIT initial value (zero).
pub const fn new() -> Self {
Self(0)
}
/// Fold `data` into the running CRC.
pub fn update(&mut self, data: &[u8]) {
let mut crc = self.0;
for &byte in data {
// Low nibble, then high nibble.
let mut tmp = CRC_TABLE[(crc & 0xF) as usize];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ CRC_TABLE[(byte & 0xF) as usize];
tmp = CRC_TABLE[(crc & 0xF) as usize];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ CRC_TABLE[((byte >> 4) & 0xF) as usize];
}
self.0 = crc;
}
/// The current checksum.
pub const fn value(self) -> u16 {
self.0
}
}
/// One-shot FIT CRC-16 over `data`.
pub fn crc16(data: &[u8]) -> u16 {
let mut crc = Crc16::new();
crc.update(data);
crc.value()
}
#[cfg(test)]
mod tests {
use super::*;
/// The canonical CRC-16/ARC check value: `crc("123456789") == 0xBB3D`.
/// If this fails, every FIT file we produce is rejected.
#[test]
fn known_vector_check_string() {
assert_eq!(crc16(b"123456789"), 0xBB3D);
}
#[test]
fn known_vector_empty_and_zero() {
assert_eq!(crc16(b""), 0x0000);
// CRC-16/ARC of a single zero byte is 0.
assert_eq!(crc16(&[0x00]), 0x0000);
// Published CRC-16/ARC vectors.
assert_eq!(crc16(b"A"), 0x30C0);
assert_eq!(crc16(&[0x00, 0x00, 0x00, 0x00]), 0x0000);
}
#[test]
fn matches_reference_bitwise_implementation() {
// Independent, deliberately naive reflected-CRC implementation.
fn reference(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= b as u16;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
crc
}
// A deterministic pseudo-random corpus.
let mut data = Vec::new();
let mut x: u32 = 0x1234_5678;
for _ in 0..1000 {
x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
data.push((x >> 16) as u8);
assert_eq!(crc16(&data), reference(&data), "mismatch at len {}", data.len());
}
}
#[test]
fn incremental_equals_one_shot() {
let data: Vec<u8> = (0u8..=255).cycle().take(777).collect();
let mut running = Crc16::new();
for chunk in data.chunks(13) {
running.update(chunk);
}
assert_eq!(running.value(), crc16(&data));
}
}
+578
View File
@@ -0,0 +1,578 @@
//! The FIT binary container: file header, definition messages, data messages
//! and the trailing CRC.
//!
//! This is deliberately a small, literal implementation of the FIT protocol
//! rather than a wrapper around a generated SDK. The container is about two
//! hundred lines and every byte of it matters for whether an upload is
//! accepted, so it is worth being able to read all of it.
//!
//! # File layout
//!
//! ```text
//! +--------------------------------+
//! | header (14 bytes) | size, protocol, profile, data size,
//! | | ".FIT", header CRC
//! +--------------------------------+
//! | data records (data_size bytes) | definition + data messages
//! +--------------------------------+
//! | file CRC (2 bytes) | over header + data records
//! +--------------------------------+
//! ```
use crate::crc::Crc16;
/// Header length we emit. The 12-byte variant (no header CRC) is legal but the
/// 14-byte form is universally expected.
pub const HEADER_SIZE: u8 = 14;
/// Protocol version 2.0, encoded as `major << 4 | minor`.
pub const PROTOCOL_VERSION: u8 = 0x20;
/// Profile version, `major * 100 + minor`, from FIT SDK 21.
pub const PROFILE_VERSION: u16 = 21_205;
/// The `.FIT` data type signature at bytes 8..12 of the header.
pub const DATA_TYPE: &[u8; 4] = b".FIT";
/// FIT base type identifiers. The high bit marks an endian-sensitive type; the
/// low 5 bits are the type number.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum BaseType {
Enum = 0x00,
Sint8 = 0x01,
Uint8 = 0x02,
Sint16 = 0x83,
Uint16 = 0x84,
Sint32 = 0x85,
Uint32 = 0x86,
String = 0x07,
Float32 = 0x88,
Uint8z = 0x0A,
Uint16z = 0x8B,
Uint32z = 0x8C,
Byte = 0x0D,
}
/// One encoded field value, carrying its own base type and width.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Enum(u8),
Uint8(u8),
Uint8z(u8),
Sint8(i8),
Uint16(u16),
Uint16z(u16),
Sint16(i16),
Uint32(u32),
Uint32z(u32),
Sint32(i32),
Float32(f32),
/// Null-terminated UTF-8. The encoded size includes the terminator.
String(String),
}
impl Value {
/// The FIT base type of this value.
pub fn base_type(&self) -> BaseType {
match self {
Value::Enum(_) => BaseType::Enum,
Value::Uint8(_) => BaseType::Uint8,
Value::Uint8z(_) => BaseType::Uint8z,
Value::Sint8(_) => BaseType::Sint8,
Value::Uint16(_) => BaseType::Uint16,
Value::Uint16z(_) => BaseType::Uint16z,
Value::Sint16(_) => BaseType::Sint16,
Value::Uint32(_) => BaseType::Uint32,
Value::Uint32z(_) => BaseType::Uint32z,
Value::Sint32(_) => BaseType::Sint32,
Value::Float32(_) => BaseType::Float32,
Value::String(_) => BaseType::String,
}
}
/// Encoded width in bytes, as it appears in the definition message.
pub fn size(&self) -> u8 {
match self {
Value::Enum(_) | Value::Uint8(_) | Value::Uint8z(_) | Value::Sint8(_) => 1,
Value::Uint16(_) | Value::Uint16z(_) | Value::Sint16(_) => 2,
Value::Uint32(_) | Value::Uint32z(_) | Value::Sint32(_) | Value::Float32(_) => 4,
// UTF-8 bytes plus the null terminator; clamped so a pathological
// name cannot overflow the single-byte size field.
Value::String(s) => (s.len().min(u8::MAX as usize - 1) + 1) as u8,
}
}
/// Append this value to `out` in little-endian order.
fn write(&self, out: &mut Vec<u8>) {
match self {
Value::Enum(v) | Value::Uint8(v) | Value::Uint8z(v) => out.push(*v),
Value::Sint8(v) => out.push(*v as u8),
Value::Uint16(v) | Value::Uint16z(v) => out.extend_from_slice(&v.to_le_bytes()),
Value::Sint16(v) => out.extend_from_slice(&v.to_le_bytes()),
Value::Uint32(v) | Value::Uint32z(v) => out.extend_from_slice(&v.to_le_bytes()),
Value::Sint32(v) => out.extend_from_slice(&v.to_le_bytes()),
Value::Float32(v) => out.extend_from_slice(&v.to_le_bytes()),
Value::String(s) => {
let max = usize::from(self.size()) - 1;
let mut bytes = s.as_bytes();
if bytes.len() > max {
// Never split a UTF-8 sequence.
let mut end = max;
while end > 0 && (bytes[end] & 0xC0) == 0x80 {
end -= 1;
}
bytes = &bytes[..end];
}
out.extend_from_slice(bytes);
out.resize(out.len() + (max - bytes.len()) + 1, 0);
}
}
}
}
/// A message under construction: an ordered set of (field number, value) pairs.
///
/// Setting the same field twice replaces the value rather than emitting a
/// duplicate, which a definition message may not contain.
#[derive(Debug, Default, Clone)]
pub struct Message {
fields: Vec<(u8, Value)>,
}
impl Message {
/// An empty message.
pub fn new() -> Self {
Self::default()
}
/// Set a field.
pub fn set(&mut self, field: u8, value: Value) -> &mut Self {
match self.fields.iter_mut().find(|(n, _)| *n == field) {
Some(slot) => slot.1 = value,
None => self.fields.push((field, value)),
}
self
}
/// Set a field only when the value is present. Absent optional fields are
/// omitted from the definition entirely rather than written as the base
/// type's "invalid" sentinel, which keeps files small and stops decoders
/// from surfacing phantom all-invalid streams.
pub fn set_opt(&mut self, field: u8, value: Option<Value>) -> &mut Self {
if let Some(v) = value {
self.set(field, v);
}
self
}
/// The fields, in the order they will be written.
pub fn fields(&self) -> &[(u8, Value)] {
&self.fields
}
/// True when no field has been set.
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
/// The definition-message shape of this message: (field number, size, base
/// type) per field. Two messages sharing a shape can share a definition.
fn shape(&self) -> Vec<(u8, u8, u8)> {
self.fields
.iter()
.map(|(n, v)| (*n, v.size(), v.base_type() as u8))
.collect()
}
}
/// Accumulates data records and emits a complete FIT file.
///
/// Definitions are cached per local message type, so a definition is re-emitted
/// only when a message's shape changes — which is what lets a thousand `record`
/// messages share a single definition.
#[derive(Debug, Default)]
pub struct FitEncoder {
data: Vec<u8>,
/// Cached definition shape per local message type (0..16).
defs: [Option<(u16, Vec<(u8, u8, u8)>)>; 16],
message_count: usize,
}
impl FitEncoder {
/// A new, empty encoder.
pub fn new() -> Self {
Self::default()
}
/// Write `msg` as global message `global` using local message type `local`.
///
/// Emits a definition message first if the shape is not already cached for
/// this local type. `local` must be 0..=15; anything larger is masked, and
/// an empty message is skipped (a zero-field definition is legal but
/// pointless and confuses some parsers).
pub fn write_message(&mut self, local: u8, global: u16, msg: &Message) {
if msg.is_empty() {
return;
}
let local = local & 0x0F;
let shape = msg.shape();
let cached = self.defs[local as usize]
.as_ref()
.is_some_and(|(g, s)| *g == global && *s == shape);
if !cached {
self.write_definition(local, global, &shape);
self.defs[local as usize] = Some((global, shape));
}
// Data message header: bit 7 = 0 (normal), bit 6 = 0 (data),
// bits 0..4 = local message type.
self.data.push(local);
for (_, value) in msg.fields() {
value.write(&mut self.data);
}
self.message_count += 1;
}
fn write_definition(&mut self, local: u8, global: u16, shape: &[(u8, u8, u8)]) {
// Definition message header: bit 7 = 0 (normal), bit 6 = 1 (definition).
self.data.push(0x40 | local);
self.data.push(0); // reserved
self.data.push(0); // architecture: 0 = little endian
self.data.extend_from_slice(&global.to_le_bytes());
// A definition may describe at most 255 fields.
self.data.push(shape.len().min(u8::MAX as usize) as u8);
for &(num, size, base) in shape.iter().take(u8::MAX as usize) {
self.data.push(num);
self.data.push(size);
self.data.push(base);
}
}
/// Number of data messages written so far.
pub fn message_count(&self) -> usize {
self.message_count
}
/// Byte length of the data-records section written so far.
pub fn data_len(&self) -> usize {
self.data.len()
}
/// Finish the file: prepend the 14-byte header (with its own CRC) and
/// append the file CRC over header plus data.
pub fn finish(self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.data.len() + 16);
out.extend_from_slice(&file_header(self.data.len() as u32));
out.extend_from_slice(&self.data);
let mut crc = Crc16::new();
crc.update(&out);
out.extend_from_slice(&crc.value().to_le_bytes());
out
}
}
/// Build the 14-byte FIT file header for a given data-records length.
///
/// `data_size` counts *only* the data records — not the header and not the
/// trailing CRC. Getting that wrong is the second classic way to produce a file
/// that every uploader rejects.
pub fn file_header(data_size: u32) -> [u8; 14] {
let mut h = [0u8; 14];
h[0] = HEADER_SIZE;
h[1] = PROTOCOL_VERSION;
h[2..4].copy_from_slice(&PROFILE_VERSION.to_le_bytes());
h[4..8].copy_from_slice(&data_size.to_le_bytes());
h[8..12].copy_from_slice(DATA_TYPE);
let mut crc = Crc16::new();
crc.update(&h[0..12]);
h[12..14].copy_from_slice(&crc.value().to_le_bytes());
h
}
/// Structural check on an encoded FIT file: header self-consistency, declared
/// data size against actual length, and both CRCs.
///
/// Exposed because it is exactly the check an uploader performs before deciding
/// whether to look at the contents, and it is cheap enough to run on every file
/// we write.
pub fn verify(bytes: &[u8]) -> Result<(), VerifyError> {
if bytes.len() < 16 {
return Err(VerifyError::TooShort(bytes.len()));
}
let header_size = bytes[0] as usize;
if header_size != 12 && header_size != 14 {
return Err(VerifyError::BadHeaderSize(bytes[0]));
}
if &bytes[8..12] != DATA_TYPE {
return Err(VerifyError::BadSignature([
bytes[8], bytes[9], bytes[10], bytes[11],
]));
}
let data_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
let expected_len = header_size + data_size + 2;
if bytes.len() != expected_len {
return Err(VerifyError::DataSizeMismatch {
declared: data_size,
actual: bytes.len().saturating_sub(header_size + 2),
});
}
if header_size == 14 {
let stored = u16::from_le_bytes([bytes[12], bytes[13]]);
// A zero header CRC means "not present", which is legal.
if stored != 0 {
let computed = crate::crc::crc16(&bytes[0..12]);
if stored != computed {
return Err(VerifyError::HeaderCrc { stored, computed });
}
}
}
let stored = u16::from_le_bytes([bytes[expected_len - 2], bytes[expected_len - 1]]);
let computed = crate::crc::crc16(&bytes[..expected_len - 2]);
if stored != computed {
return Err(VerifyError::FileCrc { stored, computed });
}
Ok(())
}
/// Why [`verify`] rejected a file.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum VerifyError {
#[error("file is {0} bytes, too short to be a FIT file")]
TooShort(usize),
#[error("header size {0} is neither 12 nor 14")]
BadHeaderSize(u8),
#[error("data type signature is {0:?}, expected \".FIT\"")]
BadSignature([u8; 4]),
#[error("header declares {declared} data bytes but the file carries {actual}")]
DataSizeMismatch { declared: usize, actual: usize },
#[error("header CRC is {stored:#06x}, computed {computed:#06x}")]
HeaderCrc { stored: u16, computed: u16 },
#[error("file CRC is {stored:#06x}, computed {computed:#06x}")]
FileCrc { stored: u16, computed: u16 },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_layout_is_byte_exact() {
let h = file_header(0x1234);
assert_eq!(h[0], 14, "header size");
assert_eq!(h[1], 0x20, "protocol version 2.0");
assert_eq!(&h[2..4], &PROFILE_VERSION.to_le_bytes(), "profile version");
assert_eq!(&h[4..8], &[0x34, 0x12, 0x00, 0x00], "data size, little endian");
assert_eq!(&h[8..12], b".FIT", "data type signature");
let crc = u16::from_le_bytes([h[12], h[13]]);
assert_eq!(crc, crate::crc::crc16(&h[0..12]));
assert_ne!(crc, 0, "a real header CRC, not the 'absent' sentinel");
}
#[test]
fn empty_file_is_header_plus_crc() {
let bytes = FitEncoder::new().finish();
assert_eq!(bytes.len(), 16);
assert_eq!(&bytes[4..8], &[0, 0, 0, 0]);
assert!(verify(&bytes).is_ok());
}
#[test]
fn definition_and_data_bytes_are_exact() {
let mut enc = FitEncoder::new();
let mut msg = Message::new();
msg.set(0, Value::Enum(4));
msg.set(1, Value::Uint16(255));
enc.write_message(0, 0, &msg);
let bytes = enc.finish();
let data = &bytes[14..bytes.len() - 2];
#[rustfmt::skip]
let expected: &[u8] = &[
// definition message for global 0, local 0, two fields
0x40, // header: normal, definition, local 0
0x00, // reserved
0x00, // little endian
0x00, 0x00, // global message number 0 (file_id)
0x02, // two fields
0x00, 0x01, 0x00, // field 0, 1 byte, enum
0x01, 0x02, 0x84, // field 1, 2 bytes, uint16
// data message
0x00, // header: normal, data, local 0
0x04, // type = activity
0xFF, 0x00, // manufacturer = 255, little endian
];
assert_eq!(data, expected);
assert_eq!(
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize,
expected.len()
);
assert!(verify(&bytes).is_ok());
}
#[test]
fn definition_is_reused_for_identical_shapes() {
let mut enc = FitEncoder::new();
let mut msg = Message::new();
msg.set(253, Value::Uint32(1));
for i in 0..5u32 {
msg.set(253, Value::Uint32(i));
enc.write_message(3, mesg_record(), &msg);
}
// One 3+3+1-byte definition (6 header bytes + 3 per field) plus five
// 5-byte data messages.
assert_eq!(enc.data_len(), (6 + 3) + 5 * (1 + 4));
assert_eq!(enc.message_count(), 5);
}
#[test]
fn definition_is_re_emitted_when_the_shape_changes() {
let mut enc = FitEncoder::new();
let mut a = Message::new();
a.set(253, Value::Uint32(1));
enc.write_message(3, mesg_record(), &a);
let after_first = enc.data_len();
let mut b = Message::new();
b.set(253, Value::Uint32(2));
b.set(7, Value::Uint16(250));
enc.write_message(3, mesg_record(), &b);
// Second write costs a new 12-byte definition plus a 7-byte data message.
assert_eq!(enc.data_len() - after_first, (6 + 6) + (1 + 4 + 2));
}
fn mesg_record() -> u16 {
crate::profile::mesg::RECORD
}
#[test]
fn strings_are_null_terminated_and_sized_with_the_terminator() {
let v = Value::String("BikeControl".into());
assert_eq!(v.size(), 12);
let mut out = Vec::new();
v.write(&mut out);
assert_eq!(out, b"BikeControl\0");
}
#[test]
fn empty_string_is_a_single_null() {
let v = Value::String(String::new());
assert_eq!(v.size(), 1);
let mut out = Vec::new();
v.write(&mut out);
assert_eq!(out, b"\0");
}
#[test]
fn overlong_strings_are_truncated_on_a_char_boundary() {
let v = Value::String("é".repeat(200));
let size = usize::from(v.size());
let mut out = Vec::new();
v.write(&mut out);
assert_eq!(out.len(), size);
assert_eq!(*out.last().unwrap(), 0);
// Truncation must not leave a partial UTF-8 sequence.
assert!(std::str::from_utf8(&out[..out.len() - 1]).is_ok());
}
#[test]
fn setting_a_field_twice_replaces_rather_than_duplicates() {
let mut msg = Message::new();
msg.set(7, Value::Uint16(100));
msg.set(7, Value::Uint16(200));
assert_eq!(msg.fields().len(), 1);
assert_eq!(msg.fields()[0].1, Value::Uint16(200));
}
#[test]
fn set_opt_skips_none() {
let mut msg = Message::new();
msg.set_opt(3, None);
msg.set_opt(4, Some(Value::Uint8(90)));
assert_eq!(msg.fields().len(), 1);
assert_eq!(msg.fields()[0].0, 4);
}
#[test]
fn signed_values_use_twos_complement_little_endian() {
let mut out = Vec::new();
Value::Sint16(-100).write(&mut out);
assert_eq!(out, vec![0x9C, 0xFF]);
out.clear();
Value::Sint8(-1).write(&mut out);
assert_eq!(out, vec![0xFF]);
out.clear();
Value::Sint32(-2).write(&mut out);
assert_eq!(out, vec![0xFE, 0xFF, 0xFF, 0xFF]);
}
#[test]
fn verify_rejects_a_corrupted_file_crc() {
let mut bytes = FitEncoder::new().finish();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
assert!(matches!(verify(&bytes), Err(VerifyError::FileCrc { .. })));
}
#[test]
fn verify_rejects_a_corrupted_header_crc() {
let mut bytes = FitEncoder::new().finish();
bytes[12] ^= 0xFF;
assert!(matches!(verify(&bytes), Err(VerifyError::HeaderCrc { .. })));
}
#[test]
fn verify_rejects_a_wrong_data_size() {
let mut bytes = FitEncoder::new().finish();
bytes[4] = 99;
assert!(matches!(
verify(&bytes),
Err(VerifyError::DataSizeMismatch { .. })
));
}
#[test]
fn verify_rejects_a_bad_signature() {
let mut bytes = FitEncoder::new().finish();
bytes[8] = b'X';
assert!(matches!(verify(&bytes), Err(VerifyError::BadSignature(_))));
}
#[test]
fn verify_rejects_truncation() {
let bytes = FitEncoder::new().finish();
assert!(matches!(
verify(&bytes[..10]),
Err(VerifyError::TooShort(10))
));
}
#[test]
fn every_single_byte_corruption_is_caught() {
// The strongest statement we can make about the checksums without an
// uploader: no one-byte change to the file survives verification.
let mut enc = FitEncoder::new();
let mut msg = Message::new();
msg.set(253, Value::Uint32(1_000_000_000));
msg.set(7, Value::Uint16(250));
enc.write_message(3, crate::profile::mesg::RECORD, &msg);
let good = enc.finish();
for i in 0..good.len() {
let mut bad = good.clone();
bad[i] ^= 0x01;
assert!(
verify(&bad).is_err(),
"flipping a bit in byte {i} was not detected"
);
}
}
}
+158 -1
View File
@@ -1 +1,158 @@
//! FIT activity file encoder. See REQUIREMENTS.md §5.8. //! FIT activity file encoder (REQUIREMENTS.md §5.8, FR-8).
//!
//! Records a ride to a crash-safe journal and turns it into a FIT activity file
//! that Strava and Garmin Connect will accept.
//!
//! # Why this is hand-rolled
//!
//! RISK-3 in the requirements is accurate: the Rust ecosystem reads FIT far
//! better than it writes it. There *is* a capable encoder on crates.io
//! (`rustyfit`), but it was not the right dependency here:
//!
//! * The value it adds is the generated Garmin profile — several megabytes of
//! message definitions — of which an activity file needs seven messages. The
//! binary container underneath is about two hundred lines and is the part
//! that decides whether an upload is accepted.
//! * We have no Strava to test against, so correctness has to come from tests.
//! Encoding with someone's crate and round-tripping through the same crate's
//! decoder proves only self-consistency. Encoding with our own writer and
//! decoding with an *independent* parser — `fitparser`, a dev-dependency —
//! is a genuinely independent check, and it is the check this crate rests on.
//! * When an upload is rejected, the fix is at the byte level. Owning those
//! bytes is worth more here than saving a few hundred lines.
//!
//! The field numbers and enum values in [`profile`] were transcribed from the
//! Garmin FIT SDK profile and cross-checked against `rustyfit`'s generated
//! tables, so the SDK's knowledge is used — just not its code.
//!
//! # Shape of the crate
//!
//! ```text
//! RideSnapshot --> Recorder --> raw journal (JSON Lines, flushed per sample)
//! |
//! v
//! build_fit_from_log --> .fit
//! ```
//!
//! The FIT file cannot be written incrementally: its header carries a data size
//! and its last two bytes are a CRC over everything before them, so a
//! half-written FIT is a broken FIT. Crash safety therefore lives one level
//! down, in the journal — see [`rawlog`]. A ride that ends in a crash is
//! recovered by pointing [`build_fit_from_log`] at the journal, and the
//! resulting file is byte-identical to the one a clean shutdown would have
//! produced.
//!
//! # Example
//!
//! ```no_run
//! use bikecontrol_fit::{Recorder, RecorderOptions};
//! # use bikecontrol_core::RideSnapshot;
//! # fn demo(snapshots: &[RideSnapshot]) -> Result<(), bikecontrol_fit::FitError> {
//! let mut rec = Recorder::create("rides/2026-08-05.jsonl", RecorderOptions::default())?;
//! for snap in snapshots {
//! rec.record(snap)?;
//! }
//! let summary = rec.finish("rides/2026-08-05.fit")?;
//! # Ok(())
//! # }
//! ```
#![warn(missing_docs)]
use std::path::{Path, PathBuf};
pub mod builder;
pub mod crc;
pub mod encode;
pub mod profile;
pub mod rawlog;
pub mod recorder;
pub mod timestamp;
pub use builder::{encode_activity, FitSummary};
pub use crc::crc16;
pub use encode::{verify, VerifyError};
pub use rawlog::{parse_log, read_log, LogEntry, RawLog, Sample, SessionStart};
pub use recorder::{Recorder, RecorderOptions};
pub use timestamp::FIT_EPOCH_UNIX_SECS;
/// Anything that can go wrong recording or encoding a ride.
#[derive(Debug, thiserror::Error)]
pub enum FitError {
/// Reading or writing a file failed.
#[error("i/o error on {path}: {source}")]
Io {
/// The file involved.
path: PathBuf,
/// The underlying error.
#[source]
source: std::io::Error,
},
/// A journal line could not be serialised or deserialised.
#[error("journal encoding error: {0}")]
Json(#[from] serde_json::Error),
/// The journal has no `start` header, so elapsed times cannot be anchored
/// to the wall clock.
#[error("raw log has no session start entry")]
MissingSessionStart,
/// The journal contains no telemetry. An activity with no records is
/// rejected by every uploader, so it is refused here instead.
#[error("raw log contains no samples; nothing to encode")]
NoSamples,
/// A timestamp lies outside the FIT `date_time` range — before
/// 1989-12-31 UTC, or beyond 2158.
#[error("timestamp {unix_secs} is outside the FIT date_time range (1989-12-31 onwards)")]
TimestampOutOfRange {
/// The offending Unix timestamp, in seconds.
unix_secs: i64,
},
}
/// Build a FIT activity from a raw journal and write it to `fit_path`.
///
/// This is the crash-recovery entry point (FR-8.4): point it at a journal left
/// behind by a ride that ended badly and it produces the activity that ride
/// should have exported. It is also what [`Recorder::finish`] calls, so the two
/// paths cannot drift apart.
///
/// The encoded file is verified — header, declared data size, both CRCs —
/// before it is written, so a file that reaches disk is structurally sound.
pub fn build_fit_from_log(
log_path: impl AsRef<Path>,
fit_path: impl AsRef<Path>,
) -> Result<FitSummary, FitError> {
let log = read_log(log_path.as_ref())?;
let (bytes, summary) = encode_activity(&log)?;
debug_assert!(
verify(&bytes).is_ok(),
"encoder produced a structurally invalid FIT file: {:?}",
verify(&bytes)
);
let fit_path = fit_path.as_ref();
if let Some(parent) = fit_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| FitError::Io {
path: parent.to_path_buf(),
source,
})?;
}
}
std::fs::write(fit_path, &bytes).map_err(|source| FitError::Io {
path: fit_path.to_path_buf(),
source,
})?;
Ok(summary)
}
/// Build a FIT activity from a raw journal and return the bytes without
/// touching the filesystem.
pub fn fit_bytes_from_log(log_path: impl AsRef<Path>) -> Result<(Vec<u8>, FitSummary), FitError> {
let log = read_log(log_path.as_ref())?;
encode_activity(&log)
}
+184
View File
@@ -0,0 +1,184 @@
//! FIT global message numbers, field numbers and enum values.
//!
//! These are facts from the Garmin FIT SDK profile (`Profile.xlsx`, SDK 21.x),
//! transcribed for the handful of messages an activity file needs. They were
//! cross-checked against the generated profile in the `rustyfit` crate.
//!
//! **Watch the `lap` / `session` divergence.** The two messages do *not* share
//! field numbers: `session` inserts `total_fat_calories` at 13, which shifts
//! every summary field after it by one relative to `lap`. Writing lap field
//! numbers into a session message produces a file that parses but reports
//! nonsense (average power showing up as maximum heart rate, and so on).
/// Global message numbers.
pub mod mesg {
pub const FILE_ID: u16 = 0;
pub const SESSION: u16 = 18;
pub const LAP: u16 = 19;
pub const RECORD: u16 = 20;
pub const EVENT: u16 = 21;
pub const DEVICE_INFO: u16 = 23;
pub const ACTIVITY: u16 = 34;
}
/// `file_id` (global 0) field numbers.
pub mod file_id {
pub const TYPE: u8 = 0;
pub const MANUFACTURER: u8 = 1;
pub const PRODUCT: u8 = 2;
pub const SERIAL_NUMBER: u8 = 3;
pub const TIME_CREATED: u8 = 4;
pub const PRODUCT_NAME: u8 = 8;
}
/// `device_info` (global 23) field numbers.
pub mod device_info {
pub const DEVICE_INDEX: u8 = 0;
pub const MANUFACTURER: u8 = 2;
pub const PRODUCT: u8 = 4;
pub const SOFTWARE_VERSION: u8 = 5;
pub const SOURCE_TYPE: u8 = 25;
pub const PRODUCT_NAME: u8 = 27;
pub const TIMESTAMP: u8 = 253;
}
/// `event` (global 21) field numbers.
pub mod event {
pub const EVENT: u8 = 0;
pub const EVENT_TYPE: u8 = 1;
pub const DATA: u8 = 3;
pub const EVENT_GROUP: u8 = 4;
pub const TIMESTAMP: u8 = 253;
}
/// `record` (global 20) field numbers.
pub mod record {
pub const ALTITUDE: u8 = 2;
pub const HEART_RATE: u8 = 3;
pub const CADENCE: u8 = 4;
pub const DISTANCE: u8 = 5;
pub const SPEED: u8 = 6;
pub const POWER: u8 = 7;
pub const GRADE: u8 = 9;
pub const RESISTANCE: u8 = 10;
pub const CALORIES: u8 = 33;
pub const TIMESTAMP: u8 = 253;
}
/// `lap` (global 19) field numbers.
pub mod lap {
pub const EVENT: u8 = 0;
pub const EVENT_TYPE: u8 = 1;
pub const START_TIME: u8 = 2;
pub const TOTAL_ELAPSED_TIME: u8 = 7;
pub const TOTAL_TIMER_TIME: u8 = 8;
pub const TOTAL_DISTANCE: u8 = 9;
pub const TOTAL_CALORIES: u8 = 11;
pub const AVG_SPEED: u8 = 13;
pub const MAX_SPEED: u8 = 14;
pub const AVG_HEART_RATE: u8 = 15;
pub const MAX_HEART_RATE: u8 = 16;
pub const AVG_CADENCE: u8 = 17;
pub const MAX_CADENCE: u8 = 18;
pub const AVG_POWER: u8 = 19;
pub const MAX_POWER: u8 = 20;
pub const TOTAL_ASCENT: u8 = 21;
pub const TOTAL_DESCENT: u8 = 22;
pub const INTENSITY: u8 = 23;
pub const LAP_TRIGGER: u8 = 24;
pub const SPORT: u8 = 25;
pub const SUB_SPORT: u8 = 39;
pub const TOTAL_WORK: u8 = 41;
pub const AVG_GRADE: u8 = 45;
pub const TIMESTAMP: u8 = 253;
pub const MESSAGE_INDEX: u8 = 254;
}
/// `session` (global 18) field numbers. Note the offset relative to [`lap`].
pub mod session {
pub const EVENT: u8 = 0;
pub const EVENT_TYPE: u8 = 1;
pub const START_TIME: u8 = 2;
pub const SPORT: u8 = 5;
pub const SUB_SPORT: u8 = 6;
pub const TOTAL_ELAPSED_TIME: u8 = 7;
pub const TOTAL_TIMER_TIME: u8 = 8;
pub const TOTAL_DISTANCE: u8 = 9;
pub const TOTAL_CALORIES: u8 = 11;
pub const AVG_SPEED: u8 = 14;
pub const MAX_SPEED: u8 = 15;
pub const AVG_HEART_RATE: u8 = 16;
pub const MAX_HEART_RATE: u8 = 17;
pub const AVG_CADENCE: u8 = 18;
pub const MAX_CADENCE: u8 = 19;
pub const AVG_POWER: u8 = 20;
pub const MAX_POWER: u8 = 21;
pub const TOTAL_ASCENT: u8 = 22;
pub const TOTAL_DESCENT: u8 = 23;
pub const FIRST_LAP_INDEX: u8 = 25;
pub const NUM_LAPS: u8 = 26;
pub const TRIGGER: u8 = 28;
pub const TOTAL_WORK: u8 = 48;
pub const TIMESTAMP: u8 = 253;
pub const MESSAGE_INDEX: u8 = 254;
}
/// `activity` (global 34) field numbers.
pub mod activity {
pub const TOTAL_TIMER_TIME: u8 = 0;
pub const NUM_SESSIONS: u8 = 1;
pub const TYPE: u8 = 2;
pub const EVENT: u8 = 3;
pub const EVENT_TYPE: u8 = 4;
pub const LOCAL_TIMESTAMP: u8 = 5;
pub const TIMESTAMP: u8 = 253;
}
/// Enum values used by the messages above.
pub mod enums {
/// `file` — `file_id.type`.
pub const FILE_ACTIVITY: u8 = 4;
/// `manufacturer` — 255 is the SDK's "development" manufacturer, the
/// correct value for an application that is not a registered Garmin
/// partner. Both Strava and Garmin Connect accept it.
pub const MANUFACTURER_DEVELOPMENT: u16 = 255;
/// `sport`.
pub const SPORT_CYCLING: u8 = 2;
/// `sub_sport`. `VIRTUAL_ACTIVITY` is what makes Strava file the upload as
/// a *Virtual Ride* rather than an outdoor ride with no GPS.
pub const SUB_SPORT_INDOOR_CYCLING: u8 = 6;
pub const SUB_SPORT_VIRTUAL_ACTIVITY: u8 = 58;
/// `event`.
pub const EVENT_TIMER: u8 = 0;
pub const EVENT_LAP: u8 = 9;
pub const EVENT_SESSION: u8 = 8;
pub const EVENT_ACTIVITY: u8 = 26;
/// `event_type`.
pub const EVENT_TYPE_START: u8 = 0;
pub const EVENT_TYPE_STOP: u8 = 1;
pub const EVENT_TYPE_STOP_ALL: u8 = 4;
/// `lap_trigger`.
pub const LAP_TRIGGER_MANUAL: u8 = 0;
pub const LAP_TRIGGER_SESSION_END: u8 = 7;
/// `session_trigger`.
pub const SESSION_TRIGGER_ACTIVITY_END: u8 = 0;
/// `activity` — `activity.type`.
pub const ACTIVITY_MANUAL: u8 = 0;
/// `intensity`.
pub const INTENSITY_ACTIVE: u8 = 0;
/// `source_type`.
pub const SOURCE_TYPE_LOCAL: u8 = 5;
/// `device_index` — 0 is reserved for the device that created the file.
pub const DEVICE_INDEX_CREATOR: u8 = 0;
}
+554
View File
@@ -0,0 +1,554 @@
//! The raw ride log: an append-only, line-delimited JSON journal (FR-8.4,
//! FR-8.6).
//!
//! The FIT file cannot be written incrementally in any useful sense — the
//! header carries a data size and the file ends with a CRC over everything, so
//! a partially written FIT is simply a broken FIT. The session is therefore
//! made crash-safe a level below: every sample is appended to this journal as a
//! complete line and flushed, and the FIT is assembled from the journal at the
//! end of the ride. If the app dies mid-ride the journal survives and
//! [`crate::build_fit_from_log`] regenerates the activity.
//!
//! JSON Lines was chosen over a packed binary format for one reason: torn
//! writes are recoverable. A crash during the final `write` leaves a truncated
//! last line, which the reader drops; every earlier line is intact and
//! self-describing. A binary format with a length prefix would need its own
//! framing and resync logic to reach the same place.
use std::path::PathBuf;
use bikecontrol_core::{ControlMode, RideSnapshot};
use serde::{Deserialize, Serialize};
/// Version of the on-disk log format, written into the session header so a
/// future reader can tell what it is looking at.
pub const LOG_FORMAT_VERSION: u16 = 1;
/// One line of the journal.
///
/// The tag is short because these are written at 1 Hz for hours; the field
/// names cost real bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t")]
pub enum LogEntry {
/// Always the first line. Anchors elapsed time to the wall clock.
#[serde(rename = "start")]
Start(SessionStart),
/// A 1 Hz telemetry sample.
#[serde(rename = "s")]
Sample(Sample),
/// A stretch with no telemetry — a BLE dropout (FR-8.5). Recorded so the
/// hole in the record stream is explained rather than mysterious. The
/// timer keeps running across a gap: the rider was still pedalling, we just
/// stopped hearing about it.
#[serde(rename = "gap")]
Gap {
/// Elapsed time at which telemetry stopped, ms.
at_ms: u64,
/// Elapsed time at which it resumed, ms. `None` if the ride ended
/// during the dropout.
#[serde(default, skip_serializing_if = "Option::is_none")]
until_ms: Option<u64>,
/// Human-readable cause, e.g. the disconnect reason.
#[serde(default, skip_serializing_if = "String::is_empty")]
reason: String,
},
/// A lap marker (FR-8.7). Ends the lap in progress and starts a new one.
#[serde(rename = "lap")]
Lap {
at_ms: u64,
/// True if triggered by the controller rather than the UI.
#[serde(default, skip_serializing_if = "is_false")]
from_controller: bool,
},
/// The rider paused. Time between a pause and the next resume is excluded
/// from timer time but still counted in elapsed time.
#[serde(rename = "pause")]
Pause { at_ms: u64 },
/// The rider resumed.
#[serde(rename = "resume")]
Resume { at_ms: u64 },
/// Clean end of ride. Its absence is how a recovered log is recognised as
/// the product of a crash.
#[serde(rename = "end")]
End { at_ms: u64 },
}
/// Session metadata, written as the first line of the journal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionStart {
/// Wall-clock start of the ride, Unix milliseconds UTC. Every sample's
/// `elapsed_ms` is an offset from this.
pub start_unix_ms: i64,
/// The rider's UTC offset in seconds at the start of the ride, used for
/// `activity.local_timestamp`.
#[serde(default)]
pub utc_offset_secs: i32,
/// FIT `sub_sport`. Defaults to `virtual_activity`, which is what makes
/// Strava file the upload as a Virtual Ride.
#[serde(default = "default_sub_sport")]
pub sub_sport: u8,
/// Name written into `file_id.product_name`.
#[serde(default = "default_product_name")]
pub product_name: String,
/// Application version, scaled by 100 (1.20 is written as 120).
#[serde(default = "default_software_version")]
pub software_version: u16,
/// Device serial. Zero means "unset" (the FIT base type is `uint32z`).
#[serde(default)]
pub serial_number: u32,
/// Format version of this log.
#[serde(default)]
pub log_format: u16,
}
#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires this signature
fn is_false(b: &bool) -> bool {
!*b
}
fn default_sub_sport() -> u8 {
crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY
}
fn default_product_name() -> String {
"BikeControl".to_string()
}
fn default_software_version() -> u16 {
100
}
impl Default for SessionStart {
fn default() -> Self {
Self {
start_unix_ms: 0,
utc_offset_secs: 0,
sub_sport: default_sub_sport(),
product_name: default_product_name(),
software_version: default_software_version(),
serial_number: 0,
log_format: LOG_FORMAT_VERSION,
}
}
}
/// One recorded sample (FR-8.1).
///
/// This is deliberately not [`RideSnapshot`] itself: the snapshot is a UI
/// contract that will keep changing, whereas a journal on disk has to stay
/// readable by a later version of the app. Fields are optional and skipped when
/// absent so that a log of a ride without a heart-rate strap does not carry
/// thousands of nulls.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Sample {
/// Milliseconds since the start of the ride.
#[serde(rename = "e")]
pub elapsed_ms: u64,
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
pub power_w: Option<i16>,
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
pub cadence_rpm: Option<f32>,
/// Virtual speed from the physics engine, km/h.
#[serde(rename = "v", default)]
pub speed_kph: f32,
/// Virtual distance, metres.
#[serde(rename = "d", default)]
pub distance_m: f64,
/// Commanded gradient, percent.
#[serde(rename = "g", default)]
pub gradient_pct: f32,
/// Cumulative elevation gain, metres.
#[serde(rename = "eg", default)]
pub elevation_gain_m: f32,
/// Absolute altitude if a route supplies one; otherwise the encoder
/// integrates gradient over distance to synthesise a profile.
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub altitude_m: Option<f32>,
#[serde(rename = "h", default, skip_serializing_if = "Option::is_none")]
pub heart_rate_bpm: Option<u8>,
/// Trainer-reported cumulative energy, kcal.
#[serde(rename = "k", default, skip_serializing_if = "Option::is_none")]
pub energy_kcal: Option<u16>,
/// Trainer resistance level, if the trainer reports one.
#[serde(rename = "r", default, skip_serializing_if = "Option::is_none")]
pub resistance: Option<i16>,
/// Virtual gear (FR-8.1). No FIT record field carries this, so it lives in
/// the journal only.
#[serde(rename = "gear", default, skip_serializing_if = "Option::is_none")]
pub gear: Option<u8>,
/// Control mode in force at this sample (FR-8.1). Journal only.
#[serde(rename = "m", default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ControlMode>,
}
impl Sample {
/// Build a sample from a ride snapshot.
pub fn from_snapshot(snap: &RideSnapshot) -> Self {
Self {
elapsed_ms: snap.elapsed_ms,
power_w: snap.telemetry.power_w,
cadence_rpm: snap.telemetry.cadence_rpm,
speed_kph: snap.virtual_speed_kph,
distance_m: snap.virtual_distance_m,
gradient_pct: snap.gradient_pct,
elevation_gain_m: snap.elevation_gain_m,
altitude_m: None,
heart_rate_bpm: snap.telemetry.heart_rate_bpm,
energy_kcal: snap.telemetry.total_energy_kcal,
resistance: snap.telemetry.resistance_level,
gear: None,
mode: Some(snap.mode),
}
}
/// Attach a virtual gear number.
pub fn with_gear(mut self, gear: u8) -> Self {
self.gear = Some(gear);
self
}
/// Attach an absolute altitude, overriding the integrated profile.
pub fn with_altitude(mut self, altitude_m: f32) -> Self {
self.altitude_m = Some(altitude_m);
self
}
}
/// A parsed journal.
#[derive(Debug, Clone, PartialEq)]
pub struct RawLog {
/// Session metadata from the `start` line.
pub start: SessionStart,
/// Every entry after the header, in file order.
pub entries: Vec<LogEntry>,
/// Lines that failed to parse and were skipped. A count of 1 on the final
/// line is the normal signature of a crash mid-write; anything more
/// suggests real corruption.
pub skipped_lines: usize,
/// Whether the log ended with an `end` entry. False means the ride was
/// recovered from a crash.
pub clean_shutdown: bool,
/// Where the log came from, when it came from a file.
pub path: Option<PathBuf>,
}
impl RawLog {
/// Every sample, in order.
pub fn samples(&self) -> impl Iterator<Item = &Sample> {
self.entries.iter().filter_map(|e| match e {
LogEntry::Sample(s) => Some(s),
_ => None,
})
}
/// Elapsed times at which laps were marked.
pub fn lap_marks(&self) -> impl Iterator<Item = u64> + '_ {
self.entries.iter().filter_map(|e| match e {
LogEntry::Lap { at_ms, .. } => Some(*at_ms),
_ => None,
})
}
/// Recorded BLE dropouts as `(start_ms, end_ms)`. An unterminated gap is
/// closed at `fallback_end_ms`.
pub fn gaps(&self, fallback_end_ms: u64) -> Vec<(u64, u64)> {
self.entries
.iter()
.filter_map(|e| match e {
LogEntry::Gap { at_ms, until_ms, .. } => {
Some((*at_ms, until_ms.unwrap_or(fallback_end_ms).max(*at_ms)))
}
_ => None,
})
.collect()
}
/// Total time excluded from timer time by explicit pauses, in ms.
///
/// A pause with no matching resume is closed at `fallback_end_ms`.
/// Nested or repeated pauses are tolerated: only the outermost counts.
pub fn paused_ms(&self, fallback_end_ms: u64) -> u64 {
let mut total = 0u64;
let mut paused_at: Option<u64> = None;
for entry in &self.entries {
match entry {
LogEntry::Pause { at_ms } => {
if paused_at.is_none() {
paused_at = Some(*at_ms);
}
}
LogEntry::Resume { at_ms } => {
if let Some(start) = paused_at.take() {
total += at_ms.saturating_sub(start);
}
}
_ => {}
}
}
if let Some(start) = paused_at {
total += fallback_end_ms.saturating_sub(start);
}
total
}
}
/// Parse a journal from anything line-oriented.
///
/// Malformed lines are skipped rather than fatal — the entire point of this
/// format is that a half-written tail costs one sample, not the ride.
pub fn parse_log(text: &str) -> Result<RawLog, crate::FitError> {
let mut start: Option<SessionStart> = None;
let mut entries = Vec::new();
let mut skipped = 0usize;
let mut clean = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<LogEntry>(line) {
Ok(LogEntry::Start(s)) => {
if start.is_none() {
start = Some(s);
} else {
// A second header means two rides in one file; ignore it
// rather than silently merging them.
skipped += 1;
}
}
Ok(entry) => {
if matches!(entry, LogEntry::End { .. }) {
clean = true;
}
entries.push(entry);
}
Err(_) => skipped += 1,
}
}
let start = start.ok_or(crate::FitError::MissingSessionStart)?;
Ok(RawLog {
start,
entries,
skipped_lines: skipped,
clean_shutdown: clean,
path: None,
})
}
/// Read and parse a journal from disk.
pub fn read_log(path: impl Into<PathBuf>) -> Result<RawLog, crate::FitError> {
let path = path.into();
let text = std::fs::read_to_string(&path).map_err(|source| crate::FitError::Io {
path: path.clone(),
source,
})?;
let mut log = parse_log(&text)?;
log.path = Some(path);
Ok(log)
}
/// Serialise one entry as a journal line, terminator included.
pub fn entry_to_line(entry: &LogEntry) -> Result<String, crate::FitError> {
let mut s = serde_json::to_string(entry)?;
s.push('\n');
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn header_line() -> String {
entry_to_line(&LogEntry::Start(SessionStart {
start_unix_ms: 1_785_000_000_000,
..Default::default()
}))
.unwrap()
}
#[test]
fn entries_round_trip_through_json() {
let entries = vec![
LogEntry::Start(SessionStart::default()),
LogEntry::Sample(Sample {
elapsed_ms: 1000,
power_w: Some(250),
cadence_rpm: Some(88.5),
speed_kph: 32.4,
distance_m: 9.0,
gradient_pct: 2.5,
elevation_gain_m: 0.2,
heart_rate_bpm: Some(145),
..Default::default()
}),
LogEntry::Gap {
at_ms: 5000,
until_ms: Some(9000),
reason: "peripheral disconnected".into(),
},
LogEntry::Lap {
at_ms: 60_000,
from_controller: true,
},
LogEntry::Pause { at_ms: 70_000 },
LogEntry::Resume { at_ms: 80_000 },
LogEntry::End { at_ms: 90_000 },
];
for e in entries {
let line = entry_to_line(&e).unwrap();
assert!(line.ends_with('\n'));
assert!(!line[..line.len() - 1].contains('\n'), "one entry, one line");
let back: LogEntry = serde_json::from_str(&line).unwrap();
assert_eq!(back, e);
}
}
#[test]
fn a_truncated_final_line_costs_one_sample_not_the_ride() {
let mut text = header_line();
for i in 1..=5u64 {
text.push_str(
&entry_to_line(&LogEntry::Sample(Sample {
elapsed_ms: i * 1000,
power_w: Some(200),
..Default::default()
}))
.unwrap(),
);
}
// Simulate a crash part-way through writing the sixth line.
text.push_str("{\"t\":\"s\",\"e\":6000,\"p\":2");
let log = parse_log(&text).unwrap();
assert_eq!(log.samples().count(), 5);
assert_eq!(log.skipped_lines, 1);
assert!(!log.clean_shutdown, "no end entry means crash recovery");
}
#[test]
fn a_log_with_no_header_is_an_error() {
let text = entry_to_line(&LogEntry::Sample(Sample::default())).unwrap();
assert!(matches!(
parse_log(&text),
Err(crate::FitError::MissingSessionStart)
));
}
#[test]
fn clean_shutdown_is_detected() {
let text = header_line() + &entry_to_line(&LogEntry::End { at_ms: 10 }).unwrap();
assert!(parse_log(&text).unwrap().clean_shutdown);
}
#[test]
fn paused_time_sums_intervals() {
let text = header_line()
+ &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
+ &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap()
+ &entry_to_line(&LogEntry::Pause { at_ms: 20_000 }).unwrap()
+ &entry_to_line(&LogEntry::Resume { at_ms: 23_000 }).unwrap();
assert_eq!(parse_log(&text).unwrap().paused_ms(30_000), 8_000);
}
#[test]
fn an_unclosed_pause_runs_to_the_end_of_the_ride() {
let text = header_line() + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap();
assert_eq!(parse_log(&text).unwrap().paused_ms(25_000), 15_000);
}
#[test]
fn repeated_pauses_do_not_double_count() {
let text = header_line()
+ &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
+ &entry_to_line(&LogEntry::Pause { at_ms: 12_000 }).unwrap()
+ &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap();
assert_eq!(parse_log(&text).unwrap().paused_ms(20_000), 5_000);
}
#[test]
fn gaps_are_reported_with_unterminated_ones_closed() {
let text = header_line()
+ &entry_to_line(&LogEntry::Gap {
at_ms: 1000,
until_ms: Some(4000),
reason: String::new(),
})
.unwrap()
+ &entry_to_line(&LogEntry::Gap {
at_ms: 9000,
until_ms: None,
reason: "lost".into(),
})
.unwrap();
assert_eq!(parse_log(&text).unwrap().gaps(12_000), vec![
(1000, 4000),
(9000, 12_000)
]);
}
#[test]
fn blank_lines_and_whitespace_are_tolerated() {
let text = format!("\n{}\n\n \n", header_line().trim());
let log = parse_log(&text).unwrap();
assert_eq!(log.skipped_lines, 0);
assert_eq!(log.entries.len(), 0);
}
#[test]
fn a_second_header_is_skipped_not_merged() {
let text = header_line() + &header_line();
let log = parse_log(&text).unwrap();
assert_eq!(log.skipped_lines, 1);
}
#[test]
fn from_snapshot_carries_the_fields_the_contract_provides() {
use bikecontrol_core::{ControlMode, Telemetry};
let snap = RideSnapshot {
elapsed_ms: 12_000,
telemetry: Telemetry {
elapsed_ms: 12_000,
power_w: Some(233),
cadence_rpm: Some(91.0),
heart_rate_bpm: Some(150),
total_energy_kcal: Some(42),
resistance_level: Some(7),
..Default::default()
},
virtual_speed_kph: 31.5,
virtual_distance_m: 105.0,
gradient_pct: 3.5,
elevation_gain_m: 3.6,
mode: ControlMode::Profile,
target: None,
profile_progress: Some(0.1),
};
let s = Sample::from_snapshot(&snap);
assert_eq!(s.elapsed_ms, 12_000);
assert_eq!(s.power_w, Some(233));
assert_eq!(s.cadence_rpm, Some(91.0));
assert_eq!(s.heart_rate_bpm, Some(150));
assert_eq!(s.speed_kph, 31.5);
assert_eq!(s.distance_m, 105.0);
assert_eq!(s.gradient_pct, 3.5);
assert_eq!(s.energy_kcal, Some(42));
assert_eq!(s.resistance, Some(7));
assert_eq!(s.mode, Some(ControlMode::Profile));
// Speed comes from the physics engine, never from the trainer.
assert_eq!(s.speed_kph, snap.virtual_speed_kph);
}
#[test]
fn absent_optionals_do_not_appear_on_the_wire() {
let line = entry_to_line(&LogEntry::Sample(Sample {
elapsed_ms: 1000,
..Default::default()
}))
.unwrap();
assert!(!line.contains("null"), "no null padding: {line}");
assert!(!line.contains("\"h\""));
}
}
+634
View File
@@ -0,0 +1,634 @@
//! [`Recorder`] — the ride-time half of the crate.
//!
//! The recorder owns the raw journal. It is fed snapshots at whatever rate the
//! ride engine ticks, throttles them to 1 Hz (FR-8.1), and appends each one as
//! a complete line that is flushed immediately (FR-8.6). Nothing about the FIT
//! file is decided until [`Recorder::finish`].
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use bikecontrol_core::RideSnapshot;
use chrono::{DateTime, Local, Utc};
use crate::builder::{encode_activity, FitSummary};
use crate::rawlog::{entry_to_line, read_log, LogEntry, Sample, SessionStart, LOG_FORMAT_VERSION};
use crate::FitError;
/// Tuning for [`Recorder`].
#[derive(Debug, Clone, PartialEq)]
pub struct RecorderOptions {
/// Minimum spacing between recorded samples, ms. Snapshots arriving sooner
/// are dropped. The FIT `record` timestamp has one-second resolution, so
/// there is nothing to gain from a faster journal.
pub sample_interval_ms: u64,
/// Force the journal to stable storage every N samples. `None` relies on
/// the OS page cache, which is fast but loses the tail on a hard power cut.
/// The default trades roughly ten seconds of exposure for one `fsync` per
/// ten samples.
pub fsync_every: Option<usize>,
/// A silence longer than this is recorded as a BLE dropout (FR-8.5).
/// `None` disables automatic gap detection; gaps can still be marked
/// explicitly with [`Recorder::mark_gap`].
pub auto_gap_after_ms: Option<u64>,
/// FIT `sub_sport`. `virtual_activity` makes Strava file the ride as a
/// Virtual Ride; `indoor_cycling` is the alternative for a plain
/// trainer session with no simulated course.
pub sub_sport: u8,
/// Written to `file_id.product_name` and `device_info.product_name`.
pub product_name: String,
/// Application version scaled by 100 — 1.20 is `120`.
pub software_version: u16,
/// Device serial number. Zero means unset.
pub serial_number: u32,
}
impl Default for RecorderOptions {
fn default() -> Self {
Self {
sample_interval_ms: 1000,
fsync_every: Some(10),
auto_gap_after_ms: Some(5_000),
sub_sport: crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY,
product_name: "BikeControl".to_string(),
software_version: 100,
serial_number: 0,
}
}
}
/// Records a ride to a crash-safe journal and finalises it to a FIT activity.
///
/// ```no_run
/// # use bikecontrol_fit::{Recorder, RecorderOptions};
/// # use bikecontrol_core::RideSnapshot;
/// # fn demo(snapshots: Vec<RideSnapshot>) -> Result<(), Box<dyn std::error::Error>> {
/// let mut rec = Recorder::create("/tmp/ride.jsonl", RecorderOptions::default())?;
/// for snap in &snapshots {
/// rec.record(snap)?; // throttled to 1 Hz internally
/// }
/// rec.mark_lap(60_000, true)?; // controller pressed lap
/// let summary = rec.finish("/tmp/ride.fit")?;
/// println!("{} records, {:.1} km", summary.records, summary.total_distance_m / 1000.0);
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct Recorder {
file: File,
log_path: PathBuf,
opts: RecorderOptions,
start: SessionStart,
samples_written: usize,
since_sync: usize,
last_sample_ms: Option<u64>,
last_elapsed_ms: u64,
/// Elapsed time at which an open (unterminated) gap began.
open_gap_at: Option<u64>,
finished: bool,
}
impl Recorder {
/// Start recording, creating the journal at `log_path`.
///
/// The ride's wall-clock start is taken as "now", and the local UTC offset
/// is captured with it so the activity shows the right time of day.
pub fn create(log_path: impl AsRef<Path>, opts: RecorderOptions) -> Result<Self, FitError> {
Self::create_at(log_path, opts, Utc::now(), local_utc_offset_secs())
}
/// Start recording with an explicit wall-clock start and UTC offset.
/// Used by tests, and by anything that needs a reproducible file.
pub fn create_at(
log_path: impl AsRef<Path>,
opts: RecorderOptions,
started_at: DateTime<Utc>,
utc_offset_secs: i32,
) -> Result<Self, FitError> {
let log_path = log_path.as_ref().to_path_buf();
if let Some(parent) = log_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| FitError::Io {
path: parent.to_path_buf(),
source,
})?;
}
}
// Truncate rather than append: a journal holds exactly one ride, and
// silently concatenating two would produce a nonsense activity.
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)
.map_err(|source| FitError::Io {
path: log_path.clone(),
source,
})?;
let start = SessionStart {
start_unix_ms: started_at.timestamp_millis(),
utc_offset_secs,
sub_sport: opts.sub_sport,
product_name: opts.product_name.clone(),
software_version: opts.software_version,
serial_number: opts.serial_number,
log_format: LOG_FORMAT_VERSION,
};
let mut rec = Self {
file,
log_path,
opts,
start: start.clone(),
samples_written: 0,
since_sync: 0,
last_sample_ms: None,
last_elapsed_ms: 0,
open_gap_at: None,
finished: false,
};
rec.append(&LogEntry::Start(start))?;
rec.sync()?;
Ok(rec)
}
/// Record a snapshot, subject to the 1 Hz throttle.
///
/// Returns `true` if the sample was written, `false` if it was throttled
/// away. Safe to call on every engine tick.
pub fn record(&mut self, snapshot: &RideSnapshot) -> Result<bool, FitError> {
self.record_sample(Sample::from_snapshot(snapshot))
}
/// Record a fully-formed sample, subject to the same throttle. Use this
/// when there is more to record than the snapshot carries — a virtual gear,
/// or an absolute altitude from a loaded route.
pub fn record_sample(&mut self, sample: Sample) -> Result<bool, FitError> {
let t = sample.elapsed_ms;
self.last_elapsed_ms = self.last_elapsed_ms.max(t);
if let Some(prev) = self.last_sample_ms {
if t < prev.saturating_add(self.opts.sample_interval_ms) {
return Ok(false);
}
// Telemetry has been silent long enough to call it a dropout.
if let Some(threshold) = self.opts.auto_gap_after_ms {
if t.saturating_sub(prev) >= threshold && self.open_gap_at.is_none() {
self.append(&LogEntry::Gap {
at_ms: prev,
until_ms: Some(t),
reason: "no telemetry".to_string(),
})?;
}
}
}
// An explicitly opened gap closes as soon as telemetry returns.
if let Some(at_ms) = self.open_gap_at.take() {
self.append(&LogEntry::Gap {
at_ms,
until_ms: Some(t),
reason: "telemetry resumed".to_string(),
})?;
}
self.last_sample_ms = Some(t);
self.append(&LogEntry::Sample(sample))?;
self.samples_written += 1;
self.since_sync += 1;
if self
.opts
.fsync_every
.is_some_and(|n| n > 0 && self.since_sync >= n)
{
self.sync()?;
}
Ok(true)
}
/// Mark the start of a BLE dropout (FR-8.5). Recording continues; the gap
/// is closed automatically by the next sample, or at the end of the ride.
///
/// Calling this is optional — `auto_gap_after_ms` catches dropouts on its
/// own — but a caller that *knows* the peripheral disconnected can record a
/// reason and the exact moment.
pub fn mark_gap(&mut self, at_ms: u64, reason: impl Into<String>) -> Result<(), FitError> {
if self.open_gap_at.is_some() {
return Ok(()); // already inside a dropout
}
self.open_gap_at = Some(at_ms);
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
// Written now, unterminated, so it survives a crash during the dropout.
self.append(&LogEntry::Gap {
at_ms,
until_ms: None,
reason: reason.into(),
})?;
self.sync()
}
/// Mark a lap boundary (FR-8.7). `from_controller` distinguishes a Click
/// button press from an on-screen tap.
pub fn mark_lap(&mut self, at_ms: u64, from_controller: bool) -> Result<(), FitError> {
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
self.append(&LogEntry::Lap {
at_ms,
from_controller,
})?;
self.sync()
}
/// Pause the ride timer. Time until [`Recorder::resume`] counts towards
/// elapsed time but not timer time.
pub fn pause(&mut self, at_ms: u64) -> Result<(), FitError> {
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
self.append(&LogEntry::Pause { at_ms })?;
self.sync()
}
/// Resume the ride timer.
pub fn resume(&mut self, at_ms: u64) -> Result<(), FitError> {
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
self.append(&LogEntry::Resume { at_ms })?;
self.sync()
}
/// Close the journal and write the FIT activity to `fit_path`.
///
/// The FIT is built from the journal on disk, by the same code path crash
/// recovery uses — so the file a rider gets after a clean ride and the file
/// they get after a crash are produced identically.
pub fn finish(mut self, fit_path: impl AsRef<Path>) -> Result<FitSummary, FitError> {
let end_ms = self.last_elapsed_ms;
// Close any dropout that was still open when the ride ended.
if let Some(at_ms) = self.open_gap_at.take() {
self.append(&LogEntry::Gap {
at_ms,
until_ms: Some(end_ms),
reason: "ride ended during dropout".to_string(),
})?;
}
self.append(&LogEntry::End { at_ms: end_ms })?;
self.sync()?;
self.finished = true;
let log_path = self.log_path.clone();
drop(self);
crate::build_fit_from_log(&log_path, fit_path)
}
/// Close the journal without producing a FIT file. The journal remains on
/// disk and can be turned into an activity later.
pub fn abandon(mut self) -> PathBuf {
self.finished = true;
self.log_path.clone()
}
/// Where the journal is being written.
pub fn log_path(&self) -> &Path {
&self.log_path
}
/// How many samples have been committed to the journal.
pub fn samples_written(&self) -> usize {
self.samples_written
}
/// The session header written at the top of the journal.
pub fn session_start(&self) -> &SessionStart {
&self.start
}
/// Build a FIT from the journal *as it currently stands*, without ending
/// the ride. Useful for a mid-ride preview or export, and the cheapest way
/// to convince yourself the recording is sound before the ride ends.
pub fn snapshot_fit(&mut self) -> Result<(Vec<u8>, FitSummary), FitError> {
self.sync()?;
let log = read_log(&self.log_path)?;
encode_activity(&log)
}
fn append(&mut self, entry: &LogEntry) -> Result<(), FitError> {
let line = entry_to_line(entry)?;
// One `write_all` per entry: a torn write can only ever damage the
// final line, which the reader drops.
self.file
.write_all(line.as_bytes())
.map_err(|source| FitError::Io {
path: self.log_path.clone(),
source,
})
}
fn sync(&mut self) -> Result<(), FitError> {
self.since_sync = 0;
self.file.flush().map_err(|source| FitError::Io {
path: self.log_path.clone(),
source,
})?;
self.file.sync_data().map_err(|source| FitError::Io {
path: self.log_path.clone(),
source,
})
}
}
impl Drop for Recorder {
fn drop(&mut self) {
if !self.finished {
// Best effort: get whatever is buffered onto disk. A ride
// interrupted by a panic is still recoverable from the journal.
let _ = self.file.flush();
let _ = self.file.sync_data();
}
}
}
/// The machine's current UTC offset in seconds.
fn local_utc_offset_secs() -> i32 {
Local::now().offset().local_minus_utc()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rawlog::parse_log;
use bikecontrol_core::{ControlMode, Telemetry};
use chrono::TimeZone;
fn tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bikecontrol-fit-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn started_at() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap()
}
fn snapshot(elapsed_ms: u64) -> RideSnapshot {
RideSnapshot {
elapsed_ms,
telemetry: Telemetry {
elapsed_ms,
power_w: Some(210),
cadence_rpm: Some(88.0),
..Default::default()
},
virtual_speed_kph: 32.4,
virtual_distance_m: elapsed_ms as f64 * 0.009,
gradient_pct: 1.5,
elevation_gain_m: elapsed_ms as f32 * 0.000_135,
mode: ControlMode::ManualGrade,
target: None,
profile_progress: None,
}
}
fn recorder(name: &str, opts: RecorderOptions) -> (Recorder, PathBuf) {
let dir = tmpdir(name);
let log = dir.join("ride.jsonl");
let rec = Recorder::create_at(&log, opts, started_at(), 7200).unwrap();
(rec, dir)
}
#[test]
fn samples_are_throttled_to_one_hertz() {
let (mut rec, dir) = recorder("throttle", RecorderOptions::default());
// 10 Hz input for 3 seconds.
let mut accepted = 0;
for i in 0..30u64 {
if rec.record(&snapshot(i * 100)).unwrap() {
accepted += 1;
}
}
assert_eq!(accepted, 3, "0 ms, 1000 ms, 2000 ms");
assert_eq!(rec.samples_written(), 3);
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_throttle_can_be_turned_off() {
let (mut rec, dir) = recorder("nothrottle", RecorderOptions {
sample_interval_ms: 0,
auto_gap_after_ms: None,
..Default::default()
});
for i in 0..10u64 {
assert!(rec.record(&snapshot(i * 100)).unwrap());
}
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn every_sample_is_on_disk_before_the_call_returns() {
// The crash-safety claim, tested directly: read the journal back with
// the recorder still open and still holding the file.
let (mut rec, dir) = recorder("durable", RecorderOptions::default());
for i in 0..5u64 {
rec.record(&snapshot(i * 1000)).unwrap();
let text = std::fs::read_to_string(rec.log_path()).unwrap();
let log = parse_log(&text).unwrap();
assert_eq!(
log.samples().count(),
(i + 1) as usize,
"sample {i} was not durable when record() returned"
);
}
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_abandoned_journal_still_makes_a_fit() {
// Simulates a crash: the process dies, nothing calls finish(), and the
// journal is later handed to build_fit_from_log.
let (mut rec, dir) = recorder("crash", RecorderOptions::default());
for i in 0..20u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
let log_path = rec.abandon();
let fit_path = dir.join("recovered.fit");
let summary = crate::build_fit_from_log(&log_path, &fit_path).unwrap();
assert_eq!(summary.records, 20);
assert!(summary.recovered_from_crash);
assert!(crate::encode::verify(&std::fs::read(&fit_path).unwrap()).is_ok());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn finish_writes_a_verifiable_fit_and_a_clean_journal() {
let (mut rec, dir) = recorder("finish", RecorderOptions::default());
for i in 0..30u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
rec.mark_lap(10_000, true).unwrap();
let log_path = rec.log_path().to_path_buf();
let fit_path = dir.join("ride.fit");
let summary = rec.finish(&fit_path).unwrap();
assert_eq!(summary.records, 30);
assert_eq!(summary.laps, 2);
assert!(!summary.recovered_from_crash);
assert_eq!(summary.skipped_log_lines, 0);
let bytes = std::fs::read(&fit_path).unwrap();
assert_eq!(bytes.len(), summary.bytes);
assert!(crate::encode::verify(&bytes).is_ok());
// The journal is still there and still describes the same ride.
let log = crate::read_log(&log_path).unwrap();
assert!(log.clean_shutdown);
assert_eq!(log.samples().count(), 30);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_recovered_file_is_identical_to_the_clean_one() {
// The strongest form of FR-8.4: recovery is not a degraded path, it is
// the same path.
let (mut rec, dir) = recorder("identical", RecorderOptions::default());
for i in 0..15u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
let log_path = rec.log_path().to_path_buf();
let clean = dir.join("clean.fit");
rec.finish(&clean).unwrap();
let rebuilt = dir.join("rebuilt.fit");
crate::build_fit_from_log(&log_path, &rebuilt).unwrap();
assert_eq!(
std::fs::read(&clean).unwrap(),
std::fs::read(&rebuilt).unwrap()
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_dropout_is_detected_automatically() {
let (mut rec, dir) = recorder("autogap", RecorderOptions::default());
rec.record(&snapshot(0)).unwrap();
rec.record(&snapshot(1000)).unwrap();
// Ten seconds of silence, then telemetry returns.
rec.record(&snapshot(11_000)).unwrap();
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
assert_eq!(log.gaps(11_000), vec![(1000, 11_000)]);
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_explicit_gap_is_closed_when_telemetry_returns() {
let (mut rec, dir) = recorder("explicitgap", RecorderOptions {
auto_gap_after_ms: None,
..Default::default()
});
rec.record(&snapshot(0)).unwrap();
rec.mark_gap(1000, "peripheral disconnected").unwrap();
// A second mark while already in a dropout is a no-op.
rec.mark_gap(2000, "still gone").unwrap();
rec.record(&snapshot(9000)).unwrap();
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
let gaps = log.gaps(9000);
// The unterminated marker written at 1000 ms, plus its closure.
assert!(gaps.contains(&(1000, 9000)));
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_dropout_open_at_the_end_of_the_ride_is_closed_by_finish() {
let (mut rec, dir) = recorder("opengap", RecorderOptions {
auto_gap_after_ms: None,
..Default::default()
});
rec.record(&snapshot(0)).unwrap();
rec.record(&snapshot(5000)).unwrap();
rec.mark_gap(6000, "trainer lost").unwrap();
let fit = dir.join("ride.fit");
let summary = rec.finish(&fit).unwrap();
assert!(summary.gaps >= 1);
assert!(crate::encode::verify(&std::fs::read(&fit).unwrap()).is_ok());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn pause_and_resume_are_journalled() {
let (mut rec, dir) = recorder("pause", RecorderOptions::default());
for i in 0..5u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
rec.pause(5000).unwrap();
rec.resume(20_000).unwrap();
for i in 20..25u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
let fit = dir.join("ride.fit");
let summary = rec.finish(&fit).unwrap();
assert_eq!(summary.total_elapsed_s, 24.0);
assert_eq!(summary.total_timer_s, 9.0, "24 s elapsed less 15 s paused");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_mid_ride_snapshot_is_a_valid_fit() {
let (mut rec, dir) = recorder("midride", RecorderOptions::default());
for i in 0..8u64 {
rec.record(&snapshot(i * 1000)).unwrap();
}
let (bytes, summary) = rec.snapshot_fit().unwrap();
assert!(crate::encode::verify(&bytes).is_ok());
assert_eq!(summary.records, 8);
assert!(summary.recovered_from_crash, "no end marker yet");
// Recording continues afterwards.
assert!(rec.record(&snapshot(8000)).unwrap());
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn creating_a_recorder_makes_missing_directories() {
let dir = tmpdir("mkdir").join("a").join("b");
let log = dir.join("ride.jsonl");
let rec = Recorder::create_at(&log, RecorderOptions::default(), started_at(), 0).unwrap();
assert!(log.exists());
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(tmpdir("mkdir"));
}
#[test]
fn the_session_header_captures_the_start_and_offset() {
let (rec, dir) = recorder("header", RecorderOptions::default());
let start = rec.session_start();
assert_eq!(start.start_unix_ms, started_at().timestamp_millis());
assert_eq!(start.utc_offset_secs, 7200);
assert_eq!(start.log_format, LOG_FORMAT_VERSION);
assert_eq!(
start.sub_sport,
crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY
);
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_gear_can_be_recorded_alongside_the_snapshot() {
let (mut rec, dir) = recorder("gear", RecorderOptions::default());
rec.record_sample(Sample::from_snapshot(&snapshot(0)).with_gear(11))
.unwrap();
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
let s = log.samples().next().unwrap();
assert_eq!(s.gear, Some(11));
assert_eq!(s.mode, Some(ControlMode::ManualGrade));
let _ = rec.abandon();
let _ = std::fs::remove_dir_all(dir);
}
}
+150
View File
@@ -0,0 +1,150 @@
//! FIT `date_time` conversion.
//!
//! FIT counts seconds since **1989-12-31 00:00:00 UTC**, not the Unix epoch.
//! Feeding a Unix timestamp straight into a FIT file lands the activity in
//! 1989, which is one of the more common ways a hand-rolled encoder produces a
//! file that parses fine and is still useless.
use chrono::{DateTime, TimeZone, Utc};
use crate::FitError;
/// The FIT epoch expressed as a Unix timestamp: 1989-12-31T00:00:00Z.
pub const FIT_EPOCH_UNIX_SECS: i64 = 631_065_600;
/// The `date_time` invalid value. Also the boundary below which a raw value is
/// interpreted as a system time rather than a UTC timestamp.
pub const DATE_TIME_INVALID: u32 = 0xFFFF_FFFF;
/// `date_time` values below this are "system time" (seconds since power-on),
/// not wall-clock. We never emit one, but the check keeps us honest.
pub const DATE_TIME_MIN: u32 = 0x1000_0000;
/// Convert a UTC instant to a FIT `date_time`.
///
/// Fails for instants before the FIT epoch or beyond the `u32` range.
pub fn to_fit(dt: DateTime<Utc>) -> Result<u32, FitError> {
from_unix_secs(dt.timestamp())
}
/// Convert Unix seconds to a FIT `date_time`.
pub fn from_unix_secs(unix_secs: i64) -> Result<u32, FitError> {
let secs = unix_secs - FIT_EPOCH_UNIX_SECS;
if secs < 0 {
return Err(FitError::TimestampOutOfRange { unix_secs });
}
u32::try_from(secs).map_err(|_| FitError::TimestampOutOfRange { unix_secs })
}
/// Convert Unix milliseconds to a FIT `date_time`, rounding to the nearest
/// second. Sub-second resolution has no representation in `date_time`.
pub fn from_unix_millis(unix_millis: i64) -> Result<u32, FitError> {
from_unix_secs(unix_millis.div_euclid(1000) + i64::from(unix_millis.rem_euclid(1000) >= 500))
}
/// Convert a FIT `date_time` back to a UTC instant. The inverse of [`to_fit`].
pub fn to_utc(fit: u32) -> DateTime<Utc> {
Utc.timestamp_opt(i64::from(fit) + FIT_EPOCH_UNIX_SECS, 0)
.single()
.expect("every u32 offset from the FIT epoch is a representable instant")
}
/// Build a `local_timestamp`: the same instant expressed in the rider's local
/// time zone, still counted from the FIT epoch. Garmin Connect uses this to
/// show the ride at the time of day it actually happened.
pub fn to_local(fit_utc: u32, utc_offset_secs: i32) -> u32 {
fit_utc.saturating_add_signed(utc_offset_secs)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn epoch_constant_is_1989_12_31_utc() {
let epoch = Utc.with_ymd_and_hms(1989, 12, 31, 0, 0, 0).unwrap();
assert_eq!(epoch.timestamp(), FIT_EPOCH_UNIX_SECS);
assert_eq!(to_fit(epoch).unwrap(), 0);
}
#[test]
fn is_not_the_unix_epoch() {
// The whole point: a Unix timestamp is ~631 million seconds larger
// than the FIT value for the same instant.
let dt = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap();
let fit = to_fit(dt).unwrap();
assert_eq!(i64::from(fit), dt.timestamp() - FIT_EPOCH_UNIX_SECS);
assert_ne!(i64::from(fit), dt.timestamp());
}
#[test]
fn known_values() {
// 1990-01-01T00:00:00Z is exactly one day after the FIT epoch.
assert_eq!(
to_fit(Utc.with_ymd_and_hms(1990, 1, 1, 0, 0, 0).unwrap()).unwrap(),
86_400
);
// 2020-01-01T00:00:00Z, computed independently:
// (2020-01-01 unix 1577836800) - 631065600 = 946771200.
assert_eq!(
to_fit(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()).unwrap(),
946_771_200
);
}
#[test]
fn round_trips_over_a_wide_range() {
for &unix in &[
FIT_EPOCH_UNIX_SECS,
FIT_EPOCH_UNIX_SECS + 1,
946_684_800, // 2000-01-01
1_600_000_000, // 2020-09
1_785_000_000, // 2026-07
4_000_000_000, // 2096
] {
let fit = from_unix_secs(unix).unwrap();
assert_eq!(to_utc(fit).timestamp(), unix, "round trip failed for {unix}");
}
}
#[test]
fn round_trips_through_datetime() {
let dt = Utc.with_ymd_and_hms(2026, 8, 5, 9, 41, 17).unwrap();
assert_eq!(to_utc(to_fit(dt).unwrap()), dt);
}
#[test]
fn a_modern_timestamp_is_above_the_system_time_boundary() {
// Decoders treat date_time < 0x10000000 as system (uptime) time. Any
// ride recorded this decade must be well above it.
let fit = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 0, 0, 0).unwrap()).unwrap();
assert!(fit > DATE_TIME_MIN);
assert!(fit < DATE_TIME_INVALID);
}
#[test]
fn rejects_pre_epoch_and_reports_the_offending_value() {
let err = to_fit(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()).unwrap_err();
assert!(matches!(err, FitError::TimestampOutOfRange { unix_secs: 0 }));
assert!(from_unix_secs(FIT_EPOCH_UNIX_SECS - 1).is_err());
}
#[test]
fn millis_round_to_nearest_second() {
let base = FIT_EPOCH_UNIX_SECS * 1000;
assert_eq!(from_unix_millis(base).unwrap(), 0);
assert_eq!(from_unix_millis(base + 499).unwrap(), 0);
assert_eq!(from_unix_millis(base + 500).unwrap(), 1);
assert_eq!(from_unix_millis(base + 1499).unwrap(), 1);
assert_eq!(from_unix_millis(base + 1500).unwrap(), 2);
}
#[test]
fn local_timestamp_applies_the_offset() {
let utc = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()).unwrap();
assert_eq!(to_local(utc, 7200), utc + 7200); // CEST
assert_eq!(to_local(utc, -18000), utc - 18000); // EST
assert_eq!(to_local(utc, 0), utc);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! Hand-rolled argument parsing.
//!
//! Deliberately dependency-free: the probe is a Phase 0 diagnostic tool that
//! has to build and run on whatever machine is next to the trainer, and it does
//! not need an argument parser to do four subcommands.
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use bikecontrol_core::types::ControlTarget;
pub const USAGE: &str = "\
probe Van Rysel D100 / FTMS protocol discovery (REQUIREMENTS.md Phase 0)
USAGE:
probe <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
scan List BLE peripherals: name, address, RSSI, advertised services
inspect <ADDR> Connect and dump every service, characteristic and capability
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
TARGET (for `set`):
gradient=<PCT> SetTargetInclination (0x03), e.g. gradient=4.5
sim=<PCT> SetIndoorBikeSimulation (0x11) this is what answers A-1
resistance=<LEVEL> SetTargetResistanceLevel (0x04), e.g. resistance=30
power=<WATTS> SetTargetPower (0x05), e.g. power=200
OPTIONS:
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
scan 6, monitor 30, set 15)
--all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name
-v, --verbose debug-level logging, including every raw BLE frame (NFR-8)
-h, --help this text
ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF).
SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the
trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C.
";
#[derive(Debug, PartialEq)]
pub enum Command {
Help,
Scan {
duration: Duration,
all: bool,
},
Inspect {
device: Device,
},
Monitor {
device: Device,
duration: Duration,
},
Set {
device: Device,
target: ControlTarget,
/// True for `sim=`, which forces op code `0x11`.
simulation: bool,
hold: Duration,
},
}
/// How the user identified the trainer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Device {
Address(String),
Name(String),
}
#[derive(Debug, PartialEq)]
pub struct Args {
pub command: Command,
pub verbose: bool,
}
pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut args: Vec<String> = argv.into_iter().collect();
let mut verbose = false;
let mut secs: Option<u64> = None;
let mut all = false;
let mut name: Option<String> = None;
let mut help = false;
let mut positional: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = std::mem::take(&mut args[i]);
match arg.as_str() {
"-h" | "--help" | "help" => help = true,
"-v" | "--verbose" => verbose = true,
"--all" => all = true,
"--secs" | "--seconds" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow!("--secs needs a value"))?
.clone();
secs = Some(
v.parse()
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
);
}
"--name" => {
i += 1;
name = Some(
args.get(i)
.ok_or_else(|| anyhow!("--name needs a value"))?
.clone(),
);
}
other if other.starts_with('-') => bail!("unknown option {other:?}"),
other => positional.push(other.to_string()),
}
i += 1;
}
if help || positional.is_empty() {
return Ok(Args {
command: Command::Help,
verbose,
});
}
let device = |positional: &[String], index: usize| -> Result<Device> {
if let Some(n) = &name {
return Ok(Device::Name(n.clone()));
}
positional
.get(index)
.map(|a| Device::Address(a.clone()))
.ok_or_else(|| anyhow!("this subcommand needs an address (or --name <SUBSTR>)"))
};
let command = match positional[0].as_str() {
"scan" => Command::Scan {
duration: Duration::from_secs(secs.unwrap_or(6)),
all,
},
"inspect" => Command::Inspect {
device: device(&positional, 1)?,
},
"monitor" => Command::Monitor {
device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(30)),
},
"set" => {
// With --name the address slot is absent, so the target may be at
// index 1 or 2.
let target_arg = if name.is_some() && positional.len() == 2 {
positional[1].clone()
} else {
positional
.get(2)
.cloned()
.ok_or_else(|| anyhow!("`set` needs a target, e.g. gradient=4.5"))?
};
let (target, simulation) = parse_target(&target_arg)?;
Command::Set {
device: device(&positional, 1)?,
target,
simulation,
hold: Duration::from_secs(secs.unwrap_or(15)),
}
}
other => bail!("unknown subcommand {other:?} — run `probe --help`"),
};
Ok(Args { command, verbose })
}
/// Parse `gradient=4.5`, `resistance=30`, `power=200` or `sim=4.5`.
///
/// Returns the target and whether simulation mode (`0x11`) was requested.
pub fn parse_target(s: &str) -> Result<(ControlTarget, bool)> {
let (key, value) = s
.split_once('=')
.or_else(|| s.split_once(':'))
.ok_or_else(|| anyhow!("target must look like `gradient=4.5`, got {s:?}"))?;
let key = key.trim().to_lowercase();
let value = value.trim();
match key.as_str() {
"gradient" | "grade" | "incline" | "inclination" => {
let pct: f32 = value
.parse()
.map_err(|_| anyhow!("gradient must be a number of percent, got {value:?}"))?;
Ok((ControlTarget::Gradient { percent: pct }, false))
}
"sim" | "simulation" | "simgrade" => {
let pct: f32 = value
.parse()
.map_err(|_| anyhow!("sim grade must be a number of percent, got {value:?}"))?;
Ok((ControlTarget::Gradient { percent: pct }, true))
}
"resistance" | "res" | "level" => {
let level: i16 = value
.parse()
.map_err(|_| anyhow!("resistance must be a whole number, got {value:?}"))?;
Ok((ControlTarget::Resistance { level }, false))
}
"power" | "watts" | "erg" => {
let watts: u16 = value
.parse()
.map_err(|_| anyhow!("power must be a whole number of watts, got {value:?}"))?;
Ok((ControlTarget::Power { watts }, false))
}
other => bail!("unknown target channel {other:?} — use gradient, sim, resistance or power"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Result<Args> {
parse(v.iter().map(|s| s.to_string()))
}
#[test]
fn no_arguments_prints_help() {
assert_eq!(args(&[]).unwrap().command, Command::Help);
assert_eq!(args(&["--help"]).unwrap().command, Command::Help);
assert_eq!(args(&["scan", "-h"]).unwrap().command, Command::Help);
}
#[test]
fn scan_defaults_and_flags() {
assert_eq!(
args(&["scan"]).unwrap().command,
Command::Scan {
duration: Duration::from_secs(6),
all: false
}
);
assert_eq!(
args(&["scan", "--all", "--secs", "12"]).unwrap().command,
Command::Scan {
duration: Duration::from_secs(12),
all: true
}
);
}
#[test]
fn verbose_is_recognised_anywhere() {
assert!(args(&["-v", "scan"]).unwrap().verbose);
assert!(args(&["scan", "--verbose"]).unwrap().verbose);
assert!(!args(&["scan"]).unwrap().verbose);
}
#[test]
fn inspect_and_monitor_take_an_address() {
assert_eq!(
args(&["inspect", "AA:BB:CC:DD:EE:FF"]).unwrap().command,
Command::Inspect {
device: Device::Address("AA:BB:CC:DD:EE:FF".into())
}
);
assert_eq!(
args(&["monitor", "AA:BB:CC:DD:EE:FF", "--secs", "5"])
.unwrap()
.command,
Command::Monitor {
device: Device::Address("AA:BB:CC:DD:EE:FF".into()),
duration: Duration::from_secs(5)
}
);
}
#[test]
fn name_substitutes_for_an_address() {
assert_eq!(
args(&["inspect", "--name", "D100"]).unwrap().command,
Command::Inspect {
device: Device::Name("D100".into())
}
);
assert_eq!(
args(&["set", "--name", "D100", "power=150"]).unwrap().command,
Command::Set {
device: Device::Name("D100".into()),
target: ControlTarget::Power { watts: 150 },
simulation: false,
hold: Duration::from_secs(15),
}
);
}
#[test]
fn set_parses_every_channel() {
let cmd = args(&["set", "aa:bb", "gradient=4.5"]).unwrap().command;
assert_eq!(
cmd,
Command::Set {
device: Device::Address("aa:bb".into()),
target: ControlTarget::Gradient { percent: 4.5 },
simulation: false,
hold: Duration::from_secs(15),
}
);
let cmd = args(&["set", "aa:bb", "sim=-3.0", "--secs", "4"])
.unwrap()
.command;
assert_eq!(
cmd,
Command::Set {
device: Device::Address("aa:bb".into()),
target: ControlTarget::Gradient { percent: -3.0 },
simulation: true,
hold: Duration::from_secs(4),
}
);
}
#[test]
fn target_parsing_covers_aliases_and_signs() {
assert_eq!(
parse_target("grade=-7.5").unwrap(),
(ControlTarget::Gradient { percent: -7.5 }, false)
);
assert_eq!(
parse_target("res=30").unwrap(),
(ControlTarget::Resistance { level: 30 }, false)
);
assert_eq!(
parse_target("watts=250").unwrap(),
(ControlTarget::Power { watts: 250 }, false)
);
assert_eq!(
parse_target("SIM=6").unwrap(),
(ControlTarget::Gradient { percent: 6.0 }, true)
);
// Colon works too, for shells that dislike `=`.
assert_eq!(
parse_target("power:100").unwrap(),
(ControlTarget::Power { watts: 100 }, false)
);
}
#[test]
fn target_parsing_rejects_nonsense() {
assert!(parse_target("gradient").is_err());
assert!(parse_target("gradient=uphill").is_err());
assert!(parse_target("torque=5").is_err());
assert!(parse_target("power=-50").is_err(), "power is unsigned");
assert!(parse_target("resistance=1.5").is_err(), "resistance is integral");
}
#[test]
fn missing_and_unknown_arguments_are_errors() {
assert!(args(&["inspect"]).is_err());
assert!(args(&["set", "aa:bb"]).is_err());
assert!(args(&["scan", "--secs"]).is_err());
assert!(args(&["scan", "--secs", "soon"]).is_err());
assert!(args(&["frobnicate"]).is_err());
assert!(args(&["scan", "--wat"]).is_err());
}
}
+745
View File
@@ -0,0 +1,745 @@
//! The four probe subcommands.
//!
//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug`
//! directly, so they never take FTMS control and can be run safely while
//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which
//! means it exercises the same rate limiting, clamping, acknowledgement
//! handling and SAF-2 shutdown that the app will use.
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use bikecontrol_ble::capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
};
use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent};
use bikecontrol_ble::control_point::ResultCode;
use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
use bikecontrol_ble::{uuids, FtmsError};
use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
use btleplug::platform::Peripheral;
use futures::StreamExt;
use uuid::Uuid;
use crate::cli::Device;
impl Device {
fn selector(&self) -> TrainerSelector {
match self {
Device::Address(a) => TrainerSelector::Address(a.clone()),
Device::Name(n) => TrainerSelector::NameContains(n.clone()),
}
}
}
// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------
/// FR-1.1: list peripherals with name, address, RSSI and advertised services.
pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let kind = if all {
ScanKind::All
} else {
ScanKind::FitnessMachines
};
println!(
"Scanning for {} s ({})...",
duration.as_secs(),
if all {
"everything"
} else {
"fitness machines only — pass --all to see every peripheral"
}
);
let devices = scan::scan(&adapter, duration, kind).await?;
if devices.is_empty() {
println!("\nNothing found.");
println!(
"The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\
and scan again. A Zwift Click wakes on a button press."
);
return Ok(());
}
println!("\n{} device(s):\n", devices.len());
for d in &devices {
print_device(d);
}
Ok(())
}
fn print_device(d: &DiscoveredDevice) {
let kind = if d.is_fitness_machine() {
" [FTMS trainer]"
} else if d.is_zwift_device() {
" [Zwift device]"
} else {
""
};
println!("{} {}{}", d.address, d.label(), kind);
println!(
" rssi: {} tx power: {}",
d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()),
d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into())
);
if d.services.is_empty() {
println!(" services: (none advertised)");
} else {
println!(" services:");
for s in &d.services {
println!(" {}{}", s, named(*s));
}
}
for (id, data) in &d.manufacturer_data {
println!(" manufacturer 0x{id:04x} ({id}): {}", hex(data));
}
for (uuid, data) in &d.service_data {
println!(" service data {uuid}: {}", hex(data));
}
println!();
}
fn named(uuid: Uuid) -> String {
uuids::well_known_name(uuid)
.map(|n| format!(" ({n})"))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------
/// TASK-1: enumerate everything, and decode the capability characteristics.
pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
if let Some(d) = scan::describe(&peripheral).await {
println!("Connected to {} ({})\n", d.address, d.label());
}
println!("=== Services and characteristics ===\n");
let mut has_ftms = false;
for service in peripheral.services() {
if service.uuid == uuids::FITNESS_MACHINE_SERVICE {
has_ftms = true;
}
println!(
"service {}{}{}",
service.uuid,
named(service.uuid),
if service.primary { " [primary]" } else { "" }
);
for ch in &service.characteristics {
println!(
" char {}{}\n properties: {}",
ch.uuid,
named(ch.uuid),
properties(ch.properties)
);
// Reading is safe: every readable characteristic here is
// informational, and it is exactly what Phase 0 needs to see.
if ch.properties.contains(CharPropFlags::READ) {
match peripheral.read(ch).await {
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
Err(e) => println!(" value: <unreadable: {e}>"),
}
}
}
println!();
}
if !has_ftms {
println!(
"!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\
!! It is not an FTMS trainer, or it needs waking.\n"
);
}
println!("=== Fitness Machine Feature (0x2ACC) ===\n");
match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await {
Some(raw) => match FitnessMachineFeature::decode(&raw) {
Ok(f) => print_feature(&raw, f),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Resistance Level Range (0x2AD6) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await {
Some(raw) => match ResistanceLevelRange::decode(&raw) {
Ok(r) => {
let (lo, hi, inc) = r.scaled();
println!(" raw bytes: {}", hex(&raw));
println!(" minimum: {}", r.min);
println!(" maximum: {}", r.max);
println!(" increment: {}", r.increment);
println!(
" if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}"
);
println!(
"\n NOTE: resistance level is a trainer-specific unit. Whether the D100\n\
means raw integers or tenths is TASK-1/TASK-3 compare these numbers\n\
with what `set resistance=<N>` actually does, and with the resistance\n\
level reported back in Indoor Bike Data.\n"
);
}
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Power Range (0x2AD8) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await {
Some(raw) => match PowerRange::decode(&raw) {
Ok(p) => println!(
" raw bytes: {}\n {} .. {} W, step {} W\n",
hex(&raw),
p.min_w,
p.max_w,
p.increment_w
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Inclination Range (0x2AD5) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await {
Some(raw) => match InclinationRange::decode(&raw) {
Ok(i) => println!(
" raw bytes: {}\n {} .. {} %, step {} %\n",
hex(&raw),
i.min_percent(),
i.max_percent(),
i.increment_percent()
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
disconnect(&peripheral).await;
Ok(())
}
fn print_feature(raw: &[u8], f: FitnessMachineFeature) {
println!(" raw bytes: {}", hex(raw));
println!(" machine field: 0x{:08x}", f.machine);
println!(" target field: 0x{:08x}\n", f.target);
println!(" Measures:");
let m = f.machine_feature_names();
if m.is_empty() {
println!(" (none)");
}
for name in m {
println!(" - {name}");
}
println!("\n Accepts as targets:");
let t = f.target_feature_names();
if t.is_empty() {
println!(" (none)");
}
for name in t {
println!(" - {name}");
}
println!("\n Answers to the questions Phase 0 is asking:");
println!(
" SetTargetInclination (0x03): {}",
yes_no(f.supports_inclination_target())
);
println!(
" SetTargetResistanceLevel (0x04): {}",
yes_no(f.supports_resistance_target())
);
println!(
" SetTargetPower (0x05): {}",
yes_no(f.supports_power_target())
);
println!(
" SetIndoorBikeSimulationParameters (0x11): {} <-- A-1",
yes_no(f.supports_simulation())
);
println!(
"\n The 0x11 bit is only what the trainer *claims*. Confirm it with\n\
`probe set <addr> sim=4.0`, which writes the op code regardless.\n"
);
}
fn yes_no(b: bool) -> &'static str {
if b {
"advertised"
} else {
"NOT advertised"
}
}
// ---------------------------------------------------------------------------
// monitor
// ---------------------------------------------------------------------------
/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious.
pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| {
anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)")
})?;
let mut notifications = peripheral.notifications().await?;
peripheral.subscribe(&bike_data).await?;
println!(
"Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\
Pedal the trainer most trainers send nothing at all when stationary.\n\
Press Ctrl-C to stop early.\n",
duration.as_secs()
);
let start = Instant::now();
let mut count: u64 = 0;
let mut failures: u64 = 0;
let deadline = tokio::time::sleep(duration);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
n = notifications.next() => {
let Some(n) = n else {
println!("\nNotification stream ended (the trainer disconnected).");
break;
};
if n.uuid != uuids::INDOOR_BIKE_DATA {
continue;
}
count += 1;
let t = start.elapsed().as_secs_f32();
println!("[{t:7.2}s] #{count} raw 2ad2: {}", hex(&n.value));
match indoor_bike_data::decode(&n.value) {
Ok(d) => print_decoded(&d, n.value.len()),
Err(e) => {
failures += 1;
println!(" DECODE FAILED: {e}");
}
}
println!();
}
}
}
println!(
"\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).",
start.elapsed().as_secs_f32(),
count as f32 / start.elapsed().as_secs_f32().max(0.001)
);
if count > 0 && failures == 0 {
println!("Decoder agrees with the trainer on every packet.");
}
let _ = peripheral.unsubscribe(&bike_data).await;
disconnect(&peripheral).await;
Ok(())
}
fn print_decoded(d: &IndoorBikeData, len: usize) {
println!(
" flags: 0x{:04x} ({})",
d.flags,
flag_names(d.flags)
);
let row = |label: &str, value: Option<String>| {
if let Some(v) = value {
println!(" {label:<10} {v}");
}
};
row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h")));
row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h")));
row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("distance:", d.total_distance_m.map(|v| format!("{v} m")));
row("resist:", d.resistance_level.map(|v| v.to_string()));
row("power:", d.instant_power_w.map(|v| format!("{v} W")));
row("avg power:", d.average_power_w.map(|v| format!("{v} W")));
row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal")));
row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string()));
row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string()));
row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm")));
row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}")));
row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s")));
row("remaining:", d.remaining_time_s.map(|v| format!("{v} s")));
if d.consumed != len {
println!(
" !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for",
d.consumed,
len - d.consumed
);
}
}
fn flag_names(flags: u16) -> String {
use indoor_bike_data::flag as f;
let mut names = Vec::new();
// Bit 0 is inverted: speed is present when it is CLEAR.
if flags & f::MORE_DATA == 0 {
names.push("InstantaneousSpeed(bit0 clear)");
} else {
names.push("MoreData(bit0 set: no speed)");
}
for (bit, name) in [
(f::AVERAGE_SPEED, "AvgSpeed"),
(f::INSTANTANEOUS_CADENCE, "Cadence"),
(f::AVERAGE_CADENCE, "AvgCadence"),
(f::TOTAL_DISTANCE, "TotalDistance"),
(f::RESISTANCE_LEVEL, "Resistance"),
(f::INSTANTANEOUS_POWER, "Power"),
(f::AVERAGE_POWER, "AvgPower"),
(f::EXPENDED_ENERGY, "Energy"),
(f::HEART_RATE, "HeartRate"),
(f::METABOLIC_EQUIVALENT, "MET"),
(f::ELAPSED_TIME, "ElapsedTime"),
(f::REMAINING_TIME, "RemainingTime"),
] {
if flags & bit != 0 {
names.push(name);
}
}
if flags & 0xE000 != 0 {
names.push("<reserved bits set>");
}
names.join(" | ")
}
// ---------------------------------------------------------------------------
// set
// ---------------------------------------------------------------------------
/// TASK-2, and the experiment that answers A-1.
pub async fn set(
device: &Device,
target: ControlTarget,
simulation: bool,
hold: Duration,
scan_timeout: Duration,
) -> Result<()> {
let config = FtmsConfig {
use_simulation_mode: simulation,
scan_timeout,
// Discovery: write the op code even when the feature bit is clear, so
// the trainer's own response settles the question rather than our
// reading of its advertisement.
ignore_advertised_features: true,
..FtmsConfig::default()
};
println!("Connecting and requesting FTMS control...");
let client = FtmsClient::connect(device.selector(), config).await?;
println!(
"Control acquired on {} ({}).\n",
client.address(),
client.name().unwrap_or("no name")
);
let caps = client.capabilities();
if let Some(f) = caps.feature {
println!("Trainer advertises target support: {:?}\n", f.target_feature_names());
}
let mut events = client.events();
let mut telemetry = client.telemetry();
let (op, note) = describe_write(&target, simulation);
println!("Writing {op} ({note})...");
let outcome = client.set_target(target).await;
report_outcome(&outcome, simulation);
// Drain the indication that came back, so the raw result code is visible
// even when the write succeeded.
while let Ok(event) = events.try_recv() {
if let FtmsEvent::ControlResponse { op, result } = event {
println!(
" indication: op {:?}, result {} (0x{:02x})",
op,
result,
result.as_u8()
);
}
}
if outcome.is_ok() {
println!(
"\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\
(TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\
Ctrl-C to stop early.\n",
hold.as_secs()
);
let deadline = tokio::time::sleep(hold);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
sample = telemetry.recv() => {
if let Ok(s) = sample {
println!(
" {:6.1}s power {:>5} cadence {:>6} speed {:>7} resistance {:>5}",
s.elapsed_ms as f32 / 1000.0,
s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()),
s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()),
s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()),
s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()),
);
}
}
}
}
}
println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)...");
client.shutdown().await?;
println!("Done.");
Ok(())
}
fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) {
match target {
ControlTarget::Gradient { percent } if simulation => (
"SetIndoorBikeSimulationParameters (0x11)",
format!("grade {percent} %"),
),
ControlTarget::Gradient { percent } => (
"SetTargetInclination (0x03)",
format!("inclination {percent} %"),
),
ControlTarget::Resistance { level } => (
"SetTargetResistanceLevel (0x04)",
format!("level {level}"),
),
ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")),
}
}
fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool) {
match outcome {
Ok(ControlOutcome::Acknowledged { sent }) => {
println!(" ACCEPTED. Trainer acknowledged with Success.");
println!(" value actually transmitted (post-clamp): {sent:?}");
if simulation {
println!(
"\n >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\
>>> FR-2.3 may use 0x11 for gradient."
);
}
}
Ok(ControlOutcome::Superseded) => {
println!(" superseded before transmission (should not happen for a single write)");
}
Err(FtmsError::Rejected { op, result }) => {
println!(" REJECTED. Trainer answered {op} with: {result}");
if simulation && *result == ResultCode::OpCodeNotSupported {
println!(
"\n >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\
>>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\
>>> exactly as the MIT reference implementation does. Low impact \n\
>>> the app owns the physics (FR-7.1)."
);
}
if *result == ResultCode::ControlNotPermitted {
println!(
" (RequestControl succeeded but the trainer withdrew control — another\n\
app may be connected. Only one BLE host may hold the trainer, per A-3.)"
);
}
}
Err(FtmsError::Unacknowledged { op, timeout_ms }) => {
println!(" NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms.");
println!(" This is the silent-failure mode FR-2.7 exists to catch.");
}
Err(FtmsError::Unsupported(e)) => {
println!(" BLOCKED BEFORE TRANSMISSION: {e}");
}
Err(e) => println!(" FAILED: {e}"),
}
}
// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------
async fn connect(device: &Device, scan_timeout: Duration) -> Result<Peripheral> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let selector = device.selector();
println!("Looking for {}...", selector.describe());
let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout)
.await
.with_context(|| {
format!(
"could not find {}. The trainer may be asleep — pedal it and try again (A-4)",
selector.describe()
)
})?;
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await.context("connect failed")?;
}
peripheral
.discover_services()
.await
.context("service discovery failed")?;
Ok(peripheral)
}
async fn disconnect(peripheral: &Peripheral) {
if let Err(e) = peripheral.disconnect().await {
tracing::debug!(error = %e, "disconnect failed");
}
}
fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option<Characteristic> {
peripheral.characteristics().into_iter().find(|c| c.uuid == uuid)
}
async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option<Vec<u8>> {
let ch = find_characteristic(peripheral, uuid)?;
peripheral.read(&ch).await.ok()
}
/// Render a characteristic's bytes as text when they look like a string —
/// Device Information holds model and firmware numbers this way.
fn as_text(v: &[u8]) -> String {
if !v.is_empty()
&& v.iter()
.all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r')
{
format!("\"{}\"", String::from_utf8_lossy(v).trim())
} else {
String::new()
}
}
fn properties(p: CharPropFlags) -> String {
let mut out = Vec::new();
for (flag, name) in [
(CharPropFlags::BROADCAST, "broadcast"),
(CharPropFlags::READ, "read"),
(CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"),
(CharPropFlags::WRITE, "write"),
(CharPropFlags::NOTIFY, "notify"),
(CharPropFlags::INDICATE, "indicate"),
(
CharPropFlags::AUTHENTICATED_SIGNED_WRITES,
"authenticated-signed-writes",
),
(CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"),
] {
if p.contains(flag) {
out.push(name);
}
}
if out.is_empty() {
"(none)".to_string()
} else {
out.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_ble::indoor_bike_data::flag;
#[test]
fn flag_names_call_out_the_inverted_bit_zero() {
// Bit 0 clear means speed IS present.
assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)"));
// Bit 0 set means it is not.
assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)"));
}
#[test]
fn flag_names_list_every_present_field() {
let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER);
assert!(names.contains("Cadence"));
assert!(names.contains("Power"));
assert!(!names.contains("HeartRate"));
}
#[test]
fn flag_names_flag_reserved_bits() {
assert!(flag_names(0x8000).contains("<reserved bits set>"));
assert!(!flag_names(0x0001).contains("<reserved bits set>"));
}
#[test]
fn device_selector_mapping() {
assert_eq!(
Device::Address("AA:BB".into()).selector(),
TrainerSelector::Address("AA:BB".into())
);
assert_eq!(
Device::Name("D100".into()).selector(),
TrainerSelector::NameContains("D100".into())
);
}
#[test]
fn describe_write_names_the_op_code() {
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0,
"SetTargetInclination (0x03)"
);
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0,
"SetIndoorBikeSimulationParameters (0x11)"
);
assert_eq!(
describe_write(&ControlTarget::Resistance { level: 10 }, false).0,
"SetTargetResistanceLevel (0x04)"
);
assert_eq!(
describe_write(&ControlTarget::Power { watts: 100 }, false).0,
"SetTargetPower (0x05)"
);
}
#[test]
fn as_text_only_renders_printable_payloads() {
assert_eq!(as_text(b"D100"), "\"D100\"");
assert_eq!(as_text(&[0x00, 0x01, 0xff]), "");
assert_eq!(as_text(&[]), "");
}
#[test]
fn properties_are_listed_in_order() {
assert_eq!(
properties(CharPropFlags::READ | CharPropFlags::INDICATE),
"read, indicate"
);
assert_eq!(properties(CharPropFlags::empty()), "(none)");
}
}
+68 -1
View File
@@ -1 +1,68 @@
fn main() { println!("probe: not yet implemented"); } //! `probe` — BLE protocol discovery against real hardware.
//!
//! This is the Phase 0 tool from REQUIREMENTS.md §9: TASK-1 (enumerate the
//! D100's services, dump its capability characteristics, log decoded Indoor
//! Bike Data, resolve whether op code `0x11` works) and TASK-2 (write a control
//! command and confirm a physical resistance change).
//!
//! It is intentionally separate from the app: it prints raw bytes next to
//! decoded values (NFR-8) so that a decoder bug shows up as a disagreement on
//! screen rather than as a strange number in a chart.
mod cli;
mod commands;
use std::time::Duration;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
/// How long to look for the device before giving up.
const SCAN_TIMEOUT: Duration = Duration::from_secs(20);
#[tokio::main]
async fn main() -> Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {
Ok(args) => args,
Err(e) => {
eprintln!("error: {e}\n");
eprint!("{}", cli::USAGE);
std::process::exit(2);
}
};
init_logging(args.verbose);
match args.command {
cli::Command::Help => {
print!("{}", cli::USAGE);
Ok(())
}
cli::Command::Scan { duration, all } => commands::scan_cmd(duration, all).await,
cli::Command::Inspect { device } => commands::inspect(&device, SCAN_TIMEOUT).await,
cli::Command::Monitor { device, duration } => {
commands::monitor(&device, duration, SCAN_TIMEOUT).await
}
cli::Command::Set {
device,
target,
simulation,
hold,
} => commands::set(&device, target, simulation, hold, SCAN_TIMEOUT).await,
}
}
fn init_logging(verbose: bool) {
// `-v` turns on the raw-frame logging required by NFR-8. RUST_LOG still
// wins, so `RUST_LOG=trace` gets every notification.
let default = if verbose {
"bikecontrol_ble=debug,probe=debug,info"
} else {
"warn"
};
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default.into()))
.with_target(false)
.without_time()
.init();
}
+1
View File
@@ -24,6 +24,7 @@ tauri-plugin-dialog = "2"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml_ng = { workspace = true } serde_yaml_ng = { workspace = true }
roxmltree = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+67
View File
@@ -0,0 +1,67 @@
//! The seam between the Tauri shell and whatever is actually riding.
//!
//! Today that is [`crate::mock::MockBackend`], a synthetic rider. Tomorrow it
//! is `bikecontrol_core::RideSession` fed by FTMS telemetry from
//! `bikecontrol_ble`. Both are the same shape: rider intent in, a
//! `RideSnapshot` out, and a `ControlTarget` to push to the trainer.
//!
//! Nothing above this trait knows which one is running (§4.3 — the control loop
//! lives in Rust; the frontend only ever sees snapshots).
use std::sync::Arc;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits,
};
use crate::events::RideStatus;
/// Rider intent, owned by the Tauri layer and read by the backend each tick.
#[derive(Debug, Clone)]
pub struct RideInputs {
pub status: RideStatus,
pub mode: ControlMode,
/// Base gradient in `ManualGrade` mode.
pub manual_gradient_pct: f32,
/// Trim applied on top of whatever the base gradient is (FR-4.2).
pub gradient_offset_pct: f32,
pub resistance_level: i16,
pub power_target_w: u16,
pub profile: Option<Arc<Profile>>,
pub rider: RiderConfig,
pub limits: SafetyLimits,
}
impl Default for RideInputs {
fn default() -> Self {
Self {
status: RideStatus::Idle,
mode: ControlMode::ManualGrade,
manual_gradient_pct: 0.0,
gradient_offset_pct: 0.0,
resistance_level: 20,
power_target_w: 200,
profile: None,
rider: RiderConfig::default(),
limits: SafetyLimits::default(),
}
}
}
/// What a tick produced.
pub struct Tick {
pub snapshot: RideSnapshot,
/// Post-clamp target to transmit (SAF-3). `None` when the ride is not
/// running, so a paused ride never pushes a new load.
pub command: Option<ControlTarget>,
}
pub trait RideBackend: Send + 'static {
/// Advance the ride by `dt_s`.
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick;
/// Return to a fresh ride: zero elapsed, distance and speed.
fn reset(&mut self);
/// Identifier surfaced to the UI so it is obvious when the data is fake.
fn source(&self) -> &'static str;
}
+423
View File
@@ -0,0 +1,423 @@
//! Every intent the rider can express, as a Tauri command.
//!
//! Commands are *intents*, not state changes the frontend has already made:
//! they mutate Rust-side state and the resulting truth comes back on the event
//! channel. The UI never assumes a command took effect (§4.3).
use bikecontrol_core::gpx::{self, SmoothingConfig};
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
use tauri::{AppHandle, State};
use crate::devices::DeviceInfo;
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
use crate::profile_view::{self, ProfileView};
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};
type Cmd<T> = Result<T, String>;
// ---------------------------------------------------------------------------
// Ride state
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn ride_state(state: State<'_, AppState>) -> RideState {
state.lock().ride_state()
}
#[tauri::command]
pub fn start_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
{
let mut inner = state.lock();
if inner.inputs.status == RideStatus::Finished || inner.inputs.status == RideStatus::Idle {
inner.reset_ride();
}
inner.inputs.status = RideStatus::Running;
}
ack(&app, "start", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Paused;
ack(&app, "pause", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Running;
ack(&app, "resume", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// Pause or resume, whichever is the opposite of now. This is the one bound to
/// the space bar and to Click face button B.
#[tauri::command]
pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
let status = {
let mut inner = state.lock();
inner.inputs.status = match inner.inputs.status {
RideStatus::Running => RideStatus::Paused,
_ => RideStatus::Running,
};
inner.inputs.status
};
ack(&app, "toggle-pause", Some(format!("{status:?}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
/// before the session closes.
#[tauri::command]
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Finished;
crate::state::release_trainer(&app);
ack(&app, "stop", None);
emit_ride_state(&app);
notify(&app, Notice::info("Ride ended — trainer released to 0%"));
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().reset_ride();
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
// ---------------------------------------------------------------------------
// Control modes and targets (§5.4)
// ---------------------------------------------------------------------------
/// Mode cycle order, matching the on-screen control and Click face button A.
const MODE_CYCLE: [ControlMode; 4] = [
ControlMode::ManualGrade,
ControlMode::Profile,
ControlMode::Resistance,
ControlMode::Erg,
];
#[tauri::command]
pub fn set_control_mode(
app: AppHandle,
state: State<'_, AppState>,
mode: ControlMode,
) -> Cmd<RideState> {
{
let mut inner = state.lock();
if mode == ControlMode::Profile && inner.inputs.profile.is_none() {
return Err("No profile loaded — load a GPX or YAML profile first".into());
}
inner.inputs.mode = mode;
}
ack(&app, "mode", Some(format!("{mode:?}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
let mode = {
let mut inner = state.lock();
let has_profile = inner.inputs.profile.is_some();
let current = inner.inputs.mode;
let start = MODE_CYCLE.iter().position(|m| *m == current).unwrap_or(0);
let mut chosen = current;
for step in 1..=MODE_CYCLE.len() {
let candidate = MODE_CYCLE[(start + step) % MODE_CYCLE.len()];
if candidate == ControlMode::Profile && !has_profile {
continue;
}
chosen = candidate;
break;
}
inner.inputs.mode = chosen;
chosen
};
ack(&app, "mode", Some(format!("{mode:?}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// FR-4.2 / SAF-5 — one configured increment per event, never more.
#[tauri::command]
pub fn nudge_gradient(
app: AppHandle,
state: State<'_, AppState>,
delta_pct: f32,
) -> Cmd<RideState> {
let step = delta_pct.clamp(-2.0, 2.0);
{
let mut inner = state.lock();
match inner.inputs.mode {
ControlMode::ManualGrade => inner.inputs.manual_gradient_pct += step,
// In profile mode the nudge trims on top of the profile's gradient.
_ => inner.inputs.gradient_offset_pct += step,
}
let limits = inner.inputs.limits;
inner.inputs.manual_gradient_pct = inner
.inputs
.manual_gradient_pct
.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
inner.inputs.gradient_offset_pct = inner.inputs.gradient_offset_pct.clamp(-10.0, 10.0);
}
ack(&app, "gradient", Some(format!("{step:+.1}%")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn set_gradient(app: AppHandle, state: State<'_, AppState>, percent: f32) -> Cmd<RideState> {
{
let mut inner = state.lock();
let limits = inner.inputs.limits;
inner.inputs.manual_gradient_pct =
percent.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
}
ack(&app, "gradient", Some(format!("{percent:.1}%")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn reset_gradient(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
{
let mut inner = state.lock();
inner.inputs.gradient_offset_pct = 0.0;
inner.inputs.manual_gradient_pct = 0.0;
}
ack(&app, "gradient-reset", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn set_target_resistance(
app: AppHandle,
state: State<'_, AppState>,
level: i16,
) -> Cmd<RideState> {
{
let mut inner = state.lock();
let limits = inner.inputs.limits;
inner.inputs.resistance_level = level.clamp(limits.min_resistance, limits.max_resistance);
}
ack(&app, "resistance", Some(format!("{level}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16) -> Cmd<RideState> {
{
let mut inner = state.lock();
let limits = inner.inputs.limits;
inner.inputs.power_target_w = watts.clamp(limits.min_power_w, limits.max_power_w);
}
ack(&app, "power", Some(format!("{watts} W")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[tauri::command]
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
let lap = state.lock().mark_lap();
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
ack(&app, "lap", Some(format!("Lap {}", lap.index)));
emit_ride_state(&app);
Ok(lap)
}
// ---------------------------------------------------------------------------
// Rider and safety configuration
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn rider_config(state: State<'_, AppState>) -> RiderConfig {
state.lock().inputs.rider
}
#[tauri::command]
pub fn set_rider_config(
app: AppHandle,
state: State<'_, AppState>,
config: RiderConfig,
) -> Cmd<RiderConfig> {
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
return Err("Rider and bike mass must be positive and realistic".into());
}
state.lock().inputs.rider = config;
emit_ride_state(&app);
Ok(config)
}
#[tauri::command]
pub fn safety_limits(state: State<'_, AppState>) -> SafetyLimits {
state.lock().inputs.limits
}
#[tauri::command]
pub fn set_safety_limits(
app: AppHandle,
state: State<'_, AppState>,
limits: SafetyLimits,
) -> Cmd<SafetyLimits> {
if limits.min_gradient_pct >= limits.max_gradient_pct {
return Err("Gradient limits are inverted".into());
}
state.lock().inputs.limits = limits;
emit_ride_state(&app);
Ok(limits)
}
// ---------------------------------------------------------------------------
// Profiles (§5.5, §5.6)
// ---------------------------------------------------------------------------
fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String> {
if is_gpx {
// FR-5.2/5.3: core smooths the elevation before differentiating and
// clamps the result. The defaults are the spec's defaults.
gpx::import(text, name, &SmoothingConfig::default()).map_err(|e| e.to_string())
} else {
Profile::from_yaml(text).map_err(|e| e.to_string())
}
}
/// Load a profile from a path on disk. GPX is detected by extension, everything
/// else is treated as the YAML profile format.
#[tauri::command]
pub fn load_profile_from_path(
app: AppHandle,
state: State<'_, AppState>,
path: String,
) -> Cmd<ProfileView> {
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
let stem = std::path::Path::new(&path)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Profile".into());
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
let profile = parse_profile(&text, &stem, is_gpx)?;
let (view, geom) = profile_view::build(&profile, path);
state.lock().set_profile(profile, view.clone(), geom);
emit_ride_state(&app);
notify(&app, Notice::info(format!("Loaded profile “{}", view.name)));
Ok(view)
}
/// Load from text the frontend already has — used by the drop target, the
/// built-in samples and the profile editor.
#[tauri::command]
pub fn load_profile_from_text(
app: AppHandle,
state: State<'_, AppState>,
name: String,
text: String,
is_gpx: bool,
) -> Cmd<ProfileView> {
let profile = parse_profile(&text, &name, is_gpx)?;
let (view, geom) = profile_view::build(&profile, name);
state.lock().set_profile(profile, view.clone(), geom);
emit_ride_state(&app);
notify(&app, Notice::info(format!("Loaded profile “{}", view.name)));
Ok(view)
}
/// Parse and preview without loading — the editor calls this on every keystroke
/// so errors surface as you type rather than when you press Ride.
#[tauri::command]
pub fn preview_profile_yaml(yaml: String) -> Cmd<ProfileView> {
let profile = Profile::from_yaml(&yaml).map_err(|e| e.to_string())?;
Ok(profile_view::build(&profile, "editor").0)
}
#[tauri::command]
pub fn clear_profile(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().clear_profile();
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SampleProfile {
pub name: String,
pub summary: String,
/// YAML profile source, or GPX XML when `is_gpx`.
pub text: String,
pub is_gpx: bool,
}
/// Profiles shipped with the app, so there is always something to ride.
#[tauri::command]
pub fn sample_profiles() -> Vec<SampleProfile> {
crate::samples::all()
}
// ---------------------------------------------------------------------------
// Devices (FR-1, FR-9.19.3)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
let inner = state.lock();
DeviceList { scanning: inner.devices.scanning, devices: inner.devices.list() }
}
#[tauri::command]
pub fn start_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.lock().devices.start_scan();
emit_devices(&app);
Ok(())
}
#[tauri::command]
pub fn stop_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.lock().devices.stop_scan();
emit_devices(&app);
Ok(())
}
#[tauri::command]
pub fn connect_device(
app: AppHandle,
state: State<'_, AppState>,
device_id: String,
) -> Cmd<DeviceInfo> {
let info = state.lock().devices.connect(&device_id)?;
emit_devices(&app);
Ok(info)
}
#[tauri::command]
pub fn disconnect_device(
app: AppHandle,
state: State<'_, AppState>,
device_id: String,
) -> Cmd<DeviceInfo> {
let info = state.lock().devices.disconnect(&device_id)?;
emit_devices(&app);
notify(&app, Notice::info(format!("Disconnected {}", info.name)));
Ok(info)
}
#[tauri::command]
pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: String) -> Cmd<()> {
state.lock().devices.forget(&device_id)?;
emit_devices(&app);
Ok(())
}
/// True once a trainer has FTMS control. The ride screen uses this to warn that
/// it is showing simulated data (FR-9.3).
#[tauri::command]
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
state.lock().devices.trainer_controllable()
}
+291
View File
@@ -0,0 +1,291 @@
//! Derived ride figures: ETA, distance remaining, rolling averages.
//!
//! All of this is computed in Rust, not the frontend (§4.3). `RideSnapshot` is
//! a frozen contract in `crates/core` and does not carry any of it, so it rides
//! alongside the snapshot in a [`RideFrame`].
//!
//! **ETA (FR-9.15).** The rule that matters: never derive it from
//! instantaneous speed. Trainer speed swings several km/h between samples and
//! an ETA computed from it flickers uselessly. Three cases:
//!
//! * **Time-based profile** — remaining time is *known*. No estimation.
//! * **Distance-based profile** — remaining distance over a 45-second rolling
//! mean speed. When the rider stops, the last good ETA is *held* rather than
//! diverging to infinity, and flagged as held so the UI can dim it.
//! * **Looping profile** — no finish exists. Report lap position instead of a
//! number that would be a lie.
use std::collections::VecDeque;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::RideSnapshot;
use serde::Serialize;
use crate::profile_view::{self, ProfileGeometry, XUnit};
/// Rolling mean window for the speed that feeds ETA. Long enough to survive a
/// soft-pedal over a rise, short enough to react to a real change of pace.
const SPEED_WINDOW_S: f64 = 45.0;
/// Rolling mean window for the displayed power (FR-9.11).
pub const POWER_WINDOW_S: f64 = 10.0;
/// Window for the normalised-power rolling mean (§12 glossary).
const NP_WINDOW_S: f64 = 30.0;
/// Below this the rider is not really moving; hold the last ETA.
const MIN_ETA_SPEED_KPH: f32 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EtaKind {
/// Time-based profile: the remaining time is exact, not estimated.
Exact,
/// Distance-based: remaining distance over smoothed speed.
Estimated,
/// Rider has stopped; showing the last good estimate.
Held,
/// Looping profile — there is no finish.
Looping,
/// No profile, or one with no finite extent.
Unavailable,
}
/// Everything the ride screen shows that is not in the frozen `RideSnapshot`.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Derived {
// --- route (the primary readouts) ---------------------------------------
pub eta_kind: EtaKind,
/// Seconds to the finish. `None` when unavailable or looping.
pub time_remaining_s: Option<f64>,
pub distance_total_m: Option<f64>,
pub distance_remaining_m: Option<f64>,
/// Current altitude on the route, metres.
pub elevation_m: Option<f32>,
pub ascent_remaining_m: Option<f32>,
/// Position on the profile's own axis (seconds or metres).
pub position_x: f64,
pub axis_unit: XUnit,
pub axis_total: f64,
/// Which lap of a looping profile, 1-based.
pub loop_index: Option<u32>,
// --- motion --------------------------------------------------------------
/// 45-second rolling mean. This is what drives ETA; it is also the honest
/// number to show a rider, because instantaneous speed is noise.
pub smoothed_speed_kph: f32,
// --- effort (secondary) ---------------------------------------------------
/// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
pub rolling_power_w: f32,
pub rolling_power_window_s: f64,
pub avg_power_w: f32,
pub max_power_w: i16,
pub normalised_power_w: Option<f32>,
pub avg_cadence_rpm: f32,
pub energy_kj: f32,
}
/// Rolling windows. One instance lives in the app state for the whole ride.
pub struct Deriver {
speed: VecDeque<(f64, f32)>,
power: VecDeque<(f64, f32)>,
np: VecDeque<(f64, f32)>,
np_fourth_sum: f64,
np_n: u64,
power_sum: f64,
power_n: u64,
cadence_sum: f64,
cadence_n: u64,
max_power_w: i16,
energy_kj: f32,
last_elapsed_s: f64,
/// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
last_eta_s: Option<f64>,
}
impl Default for Deriver {
fn default() -> Self {
Self {
speed: VecDeque::new(),
power: VecDeque::new(),
np: VecDeque::new(),
np_fourth_sum: 0.0,
np_n: 0,
power_sum: 0.0,
power_n: 0,
cadence_sum: 0.0,
cadence_n: 0,
max_power_w: 0,
energy_kj: 0.0,
last_elapsed_s: 0.0,
last_eta_s: None,
}
}
}
fn push_window(window: &mut VecDeque<(f64, f32)>, t: f64, v: f32, span: f64) {
window.push_back((t, v));
while let Some((t0, _)) = window.front() {
if t - t0 > span {
window.pop_front();
} else {
break;
}
}
}
fn mean(window: &VecDeque<(f64, f32)>) -> f32 {
if window.is_empty() {
return 0.0;
}
window.iter().map(|(_, v)| *v as f64).sum::<f64>() as f32 / window.len() as f32
}
impl Deriver {
pub fn reset(&mut self) {
*self = Self::default();
}
/// Fold one snapshot in and produce the derived figures.
pub fn update(
&mut self,
snapshot: &RideSnapshot,
running: bool,
profile: Option<&Profile>,
geom: Option<&ProfileGeometry>,
) -> Derived {
let t = snapshot.elapsed_ms as f64 / 1000.0;
let dt = (t - self.last_elapsed_s).max(0.0);
self.last_elapsed_s = t;
let power = snapshot.telemetry.power_w.unwrap_or(0) as f32;
let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0);
push_window(&mut self.speed, t, snapshot.virtual_speed_kph, SPEED_WINDOW_S);
push_window(&mut self.power, t, power, POWER_WINDOW_S);
push_window(&mut self.np, t, power, NP_WINDOW_S);
if running {
self.power_sum += power as f64;
self.power_n += 1;
self.max_power_w = self.max_power_w.max(power as i16);
if cadence > 1.0 {
self.cadence_sum += cadence as f64;
self.cadence_n += 1;
}
self.energy_kj += power * dt as f32 / 1000.0;
// Normalised power: 30 s rolling mean, raised to the fourth,
// averaged, fourth root.
let rolling = mean(&self.np) as f64;
self.np_fourth_sum += rolling.powi(4);
self.np_n += 1;
}
let smoothed_speed_kph = mean(&self.speed);
// ---- route position and ETA ----------------------------------------
let mut eta_kind = EtaKind::Unavailable;
let mut time_remaining_s = None;
let mut distance_total_m = None;
let mut distance_remaining_m = None;
let mut elevation_m = None;
let mut ascent_remaining_m = None;
let mut loop_index = None;
let mut position_x = 0.0;
let mut axis_unit = XUnit::Seconds;
let mut axis_total = 0.0;
if let (Some(profile), Some(geom)) = (profile, geom) {
let elapsed_s = t;
let distance_m = snapshot.virtual_distance_m;
position_x = profile_view::position_x(geom, elapsed_s, distance_m);
axis_unit = geom.x_unit;
axis_total = geom.total_x;
elevation_m = geom.elevation_at(position_x);
ascent_remaining_m = geom.ascent_remaining(position_x);
distance_total_m = geom.total_metres;
if profile.looping {
eta_kind = EtaKind::Looping;
if geom.total_x > 0.0 {
let laps = match geom.x_unit {
XUnit::Metres => distance_m / geom.total_x,
XUnit::Seconds => elapsed_s / geom.total_x,
};
loop_index = Some(laps.floor() as u32 + 1);
}
if let Some(total) = geom.total_metres {
distance_remaining_m = Some((total - position_x).max(0.0));
}
} else {
match (geom.total_seconds, geom.total_metres) {
// Time-based: remaining time is known exactly.
(Some(total_s), None) => {
eta_kind = EtaKind::Exact;
time_remaining_s = Some((total_s - elapsed_s).max(0.0));
}
// Distance-based (or mixed): estimate from smoothed speed.
(_, Some(total_m)) => {
let remaining = (total_m - distance_m).max(0.0);
distance_remaining_m = Some(remaining);
if smoothed_speed_kph >= MIN_ETA_SPEED_KPH {
let eta = remaining / (smoothed_speed_kph as f64 * 1000.0 / 3600.0);
self.last_eta_s = Some(eta);
eta_kind = EtaKind::Estimated;
time_remaining_s = Some(eta);
} else {
eta_kind = EtaKind::Held;
time_remaining_s = self.last_eta_s;
}
// A mixed profile also has a hard time limit; take
// whichever finishes first.
if let Some(total_s) = geom.total_seconds {
let by_time = (total_s - elapsed_s).max(0.0);
time_remaining_s =
Some(time_remaining_s.map_or(by_time, |e: f64| e.min(by_time)));
}
}
(None, None) => {}
}
}
}
Derived {
eta_kind,
time_remaining_s,
distance_total_m,
distance_remaining_m,
elevation_m,
ascent_remaining_m,
position_x,
axis_unit,
axis_total,
loop_index,
smoothed_speed_kph,
rolling_power_w: mean(&self.power),
rolling_power_window_s: POWER_WINDOW_S,
avg_power_w: if self.power_n == 0 {
0.0
} else {
(self.power_sum / self.power_n as f64) as f32
},
max_power_w: self.max_power_w,
normalised_power_w: (self.np_n > 30)
.then(|| (self.np_fourth_sum / self.np_n as f64).powf(0.25) as f32),
avg_cadence_rpm: if self.cadence_n == 0 {
0.0
} else {
(self.cadence_sum / self.cadence_n as f64) as f32
},
energy_kj: self.energy_kj,
}
}
}
/// What lands on the `ride://snapshot` channel: the frozen core snapshot plus
/// the derived view data that cannot live in it.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideFrame {
pub snapshot: RideSnapshot,
pub derived: Derived,
}
+333
View File
@@ -0,0 +1,333 @@
//! Device discovery and connection state (FR-1, FR-9.19.3).
//!
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
//! connection state machines that take realistic time to settle. It exists so
//! the connection screen can be built and judged today.
//!
//! The important behaviour it models — and the reason it is not just a static
//! list — is that **BLE connection and FTMS control acquisition are separate
//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and
//! it can sit at `Connected` indefinitely if the control point is refused.
//!
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
//! renders does not change.
use std::collections::HashSet;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
/// What we think a peripheral is, from its advertised services and
/// manufacturer data (FR-1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DeviceKind {
/// Advertises FTMS (`0x1826`).
Trainer,
/// Zwift custom service, manufacturer type byte identifying the left pod.
ClickLeft,
ClickRight,
HeartRate,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
pub id: String,
pub name: String,
pub address: String,
/// dBm. Roughly 40 (touching) to 95 (barely there).
pub rssi: i16,
pub kind: DeviceKind,
pub state: ConnectionState,
/// FTMS control point acquired (FR-2.1). **Connected ≠ controllable**
/// (FR-9.3) — this is deliberately a separate field, not a state.
pub control_acquired: bool,
pub services: Vec<String>,
/// Previously paired, so it would auto-connect on launch (FR-1.5).
pub remembered: bool,
pub battery_pct: Option<u8>,
/// Zwift unlock validity for Click pods (FR-3.9). `None` for other kinds.
pub unlock_expires_in_s: Option<u64>,
/// Human-readable failure, shown verbatim in the UI (FR-9.2).
pub error: Option<String>,
}
/// Outcome of one registry tick.
pub struct PollResult {
pub changed: bool,
/// Devices whose connection state settled this tick.
pub transitions: Vec<DeviceInfo>,
}
/// A scripted peripheral in the mock environment.
struct Simulated {
info: DeviceInfo,
/// Ticks after scan start before it shows up. Models A-4: the trainer only
/// advertises once you pedal, the Click once you press a button.
appears_after: u32,
/// Ticks remaining in the current transition, and where it lands.
pending: Option<(u32, ConnectionState, bool)>,
visible: bool,
}
pub struct DeviceRegistry {
devices: Vec<Simulated>,
forgotten: HashSet<String>,
pub scanning: bool,
ticks: u32,
rng: u64,
}
/// How long each mock transition takes, in registry ticks (2 Hz).
const CONNECT_TICKS: u32 = 3;
const CONTROL_TICKS: u32 = 3;
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
devices: catalogue(),
forgotten: HashSet::new(),
scanning: false,
ticks: 0,
rng: 0xDEAD_BEEF_CAFE_F00D,
}
}
fn rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
pub fn start_scan(&mut self) {
self.scanning = true;
self.ticks = 0;
for d in &mut self.devices {
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
d.info.state = ConnectionState::Scanning;
}
}
}
pub fn stop_scan(&mut self) {
self.scanning = false;
for d in &mut self.devices {
if d.info.state == ConnectionState::Scanning {
d.info.state = ConnectionState::Idle;
}
}
}
/// Advance the mock. Reports whether the list changed at all, and which
/// devices crossed a connection-state boundary this tick.
pub fn poll(&mut self) -> PollResult {
let mut changed = false;
let mut transitions = Vec::new();
if self.scanning {
self.ticks += 1;
for i in 0..self.devices.len() {
let appears = self.devices[i].appears_after;
if !self.devices[i].visible && self.ticks >= appears {
self.devices[i].visible = true;
changed = true;
}
if self.devices[i].visible {
let jitter = (self.rand() * 6.0) as i16 - 3;
let base = self.devices[i].info.rssi;
let next = (base + jitter).clamp(-95, -38);
if next != base {
self.devices[i].info.rssi = next;
changed = true;
}
}
}
}
for d in &mut self.devices {
if let Some((remaining, target, control)) = d.pending.take() {
if remaining <= 1 {
d.info.state = target.clone();
d.info.control_acquired = control;
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
// Connected, now go after the FTMS control point.
d.pending =
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
}
transitions.push(d.info.clone());
changed = true;
} else {
d.pending = Some((remaining - 1, target, control));
}
}
}
PollResult { changed, transitions }
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.devices
.iter()
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
.map(|d| d.info.clone())
.collect()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.info.state == ConnectionState::Controlling {
return Err(format!("{} is already connected", device.info.name));
}
device.info.error = None;
device.info.state = ConnectionState::Connecting;
device.info.remembered = true;
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
Ok(device.info.clone())
}
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.control_acquired = false;
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
Ok(device.info.clone())
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.remembered = false;
device.info.control_acquired = false;
device.info.state = ConnectionState::Idle;
device.visible = false;
self.forgotten.insert(id.to_string());
Ok(())
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
self.devices
.iter()
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
}
}
fn device(
id: &str,
name: &str,
address: &str,
rssi: i16,
kind: DeviceKind,
services: &[&str],
appears_after: u32,
) -> Simulated {
Simulated {
info: DeviceInfo {
id: id.into(),
name: name.into(),
address: address.into(),
rssi,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: services.iter().map(|s| s.to_string()).collect(),
remembered: false,
battery_pct: match kind {
DeviceKind::ClickLeft => Some(78),
DeviceKind::ClickRight => Some(64),
DeviceKind::HeartRate => Some(91),
_ => None,
},
unlock_expires_in_s: match kind {
DeviceKind::ClickLeft => Some(0),
DeviceKind::ClickRight => Some(41_400),
_ => None,
},
error: None,
},
appears_after,
pending: None,
visible: false,
}
}
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
/// takes ~2 s to appear and the pods ~46 s — long enough that the "wake it by
/// pedalling" prompt (FR-1.8) is actually visible.
fn catalogue() -> Vec<Simulated> {
vec![
device(
"d100-1",
"Van Rysel D100",
"E4:2B:11:9A:03:7C",
-54,
DeviceKind::Trainer,
&["0x1826 Fitness Machine", "0x180A Device Information"],
4,
),
device(
"click-l",
"Zwift Click (left)",
"C0:1A:77:12:4E:01",
-63,
DeviceKind::ClickLeft,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
9,
),
device(
"click-r",
"Zwift Click (right)",
"C0:1A:77:12:4E:02",
-61,
DeviceKind::ClickRight,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
11,
),
device(
"hrm-1",
"Wahoo TICKR",
"D9:44:0B:31:88:2A",
-71,
DeviceKind::HeartRate,
&["0x180D Heart Rate"],
14,
),
device(
"unknown-1",
"(unnamed peripheral)",
"7F:22:C4:08:19:E3",
-88,
DeviceKind::Unknown,
&[],
17,
),
]
}
+119
View File
@@ -0,0 +1,119 @@
//! Event channel from Rust to the webview.
//!
//! The frontend is a *view* (§4.3): it never computes ride state, it renders
//! what arrives here. Every event name is declared once, in this module, and
//! mirrored in `ui/src/lib/events.ts`.
use bikecontrol_core::types::{ConnectionState, ControlMode, ControlTarget};
use serde::Serialize;
use crate::devices::DeviceInfo;
use crate::profile_view::ProfileView;
/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`].
pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
/// Low-frequency ride state: status, mode, targets, laps, loaded profile.
pub const RIDE_STATE: &str = "ride://state";
/// A lap marker was inserted (FR-3.19 / FR-8.7).
pub const RIDE_LAP: &str = "ride://lap";
/// The full device list changed (FR-9.1).
pub const DEVICES_UPDATED: &str = "devices://updated";
/// One device changed connection or control state (FR-1.7, FR-9.3).
pub const DEVICE_CONNECTION: &str = "devices://connection";
/// User-facing message: confirmation, warning or error (FR-9.2).
pub const APP_NOTICE: &str = "app://notice";
/// Acknowledgement that an input registered, so the UI can flash (FR-9.9).
pub const INPUT_ACK: &str = "app://input-ack";
/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but
/// serialisable across the IPC boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RideStatus {
Idle,
Running,
Paused,
Finished,
}
/// Everything the ride screen needs that is *not* in a `RideSnapshot`.
/// Emitted on change, not on a timer.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideState {
pub status: RideStatus,
pub mode: ControlMode,
pub target: Option<ControlTarget>,
/// Manual gradient trim on top of the profile's base gradient (FR-4.2).
pub gradient_offset_pct: f32,
pub manual_gradient_pct: f32,
pub resistance_level: i16,
pub power_target_w: u16,
pub lap: u32,
pub laps: Vec<LapSummary>,
pub profile: Option<ProfileView>,
/// Which backend is driving the ride — `"mock"` until `crates/ble` lands.
pub source: &'static str,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LapSummary {
pub index: u32,
pub elapsed_ms: u64,
pub distance_m: f64,
pub avg_power_w: f32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionEvent {
pub device_id: String,
pub state: ConnectionState,
/// FTMS control point acquired. Connected is *not* controllable (FR-9.3).
pub control_acquired: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NoticeLevel {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Notice {
pub level: NoticeLevel,
pub message: String,
}
impl Notice {
pub fn info(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Info, message: message.into() }
}
pub fn warn(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Warn, message: message.into() }
}
pub fn error(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Error, message: message.into() }
}
}
/// Confirms an intent was accepted, so the UI can flash the control (FR-9.9).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InputAck {
pub action: String,
pub detail: Option<String>,
}
/// The full device list.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceList {
pub scanning: bool,
pub devices: Vec<DeviceInfo>,
}
+93
View File
@@ -0,0 +1,93 @@
//! BikeControl desktop shell.
//!
//! This crate is *only* wiring: it owns the ride loop, exposes intents as Tauri
//! commands, and pushes state to the webview as events. The ride logic proper
//! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` — the
//! webview reaches neither directly (§4.3).
pub mod backend;
pub mod commands;
pub mod derive;
pub mod devices;
pub mod events;
pub mod mock;
pub mod profile_view;
pub mod samples;
pub mod session_backend;
pub mod state;
use tauri::{Manager, RunEvent, WindowEvent};
use crate::state::AppState;
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into()),
)
.init();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
// ride lifecycle
commands::ride_state,
commands::start_ride,
commands::pause_ride,
commands::resume_ride,
commands::toggle_pause,
commands::stop_ride,
commands::reset_ride,
// control modes and targets
commands::set_control_mode,
commands::cycle_control_mode,
commands::nudge_gradient,
commands::set_gradient,
commands::reset_gradient,
commands::set_target_resistance,
commands::set_target_power,
commands::mark_lap,
// configuration
commands::rider_config,
commands::set_rider_config,
commands::safety_limits,
commands::set_safety_limits,
// profiles
commands::load_profile_from_path,
commands::load_profile_from_text,
commands::preview_profile_yaml,
commands::clear_profile,
commands::sample_profiles,
// devices
commands::device_list,
commands::start_scan,
commands::stop_scan,
commands::connect_device,
commands::disconnect_device,
commands::forget_device,
commands::trainer_controllable,
])
.setup(|app| {
let handle = app.handle().clone();
// NFR-7: scanning starts immediately, not on a user click.
handle.state::<AppState>().lock().devices.start_scan();
state::spawn_ride_loop(handle.clone());
state::spawn_device_loop(handle.clone());
state::emit_devices(&handle);
state::emit_ride_state(&handle);
Ok(())
})
.build(tauri::generate_context!())
.expect("failed to start BikeControl")
.run(|app, event| {
// SAF-2 — on any exit path, hand the trainer back at zero load.
if let RunEvent::ExitRequested { .. } = &event {
state::release_trainer(app);
}
if let RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } = &event {
state::release_trainer(app);
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Hide the console window on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bikecontrol_app_lib::run();
}
+222
View File
@@ -0,0 +1,222 @@
//! A synthetic rider, so the UI can be built and judged before `crates/ble`
//! and `crates/core` are finished.
//!
//! It fabricates plausible power and cadence, then runs them through the §5.7
//! physics equations to get virtual speed, distance and elevation gain. The
//! numbers are fake; their *shape* is not — power is deliberately noisy so the
//! rolling average (FR-9.11) has something to smooth, and speed responds to
//! gradient with inertia rather than snapping (FR-7.3).
//!
//! Replaced wholesale by a `RideSession`-backed implementation; see
//! [`crate::backend::RideBackend`].
use bikecontrol_core::profile::Position;
use bikecontrol_core::types::{ControlMode, ControlTarget, RideSnapshot, Telemetry};
use crate::backend::{RideBackend, RideInputs, Tick};
use crate::events::RideStatus;
/// Deterministic, dependency-free noise source.
struct Rng(u64);
impl Rng {
fn next_f32(&mut self) -> f32 {
// xorshift64*
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
/// Symmetric noise in `[-1, 1]`.
fn bipolar(&mut self) -> f32 {
self.next_f32() * 2.0 - 1.0
}
}
pub struct MockBackend {
rng: Rng,
t_s: f64,
elapsed_ms: u64,
speed_ms: f32,
distance_m: f64,
elevation_gain_m: f32,
energy_kj: f32,
power_w: f32,
cadence: f32,
/// Slow effort wander, so the rider drifts rather than jitters.
effort: f32,
last_target: Option<ControlTarget>,
}
impl Default for MockBackend {
fn default() -> Self {
Self {
rng: Rng(0x9E37_79B9_7F4A_7C15),
t_s: 0.0,
elapsed_ms: 0,
speed_ms: 0.0,
distance_m: 0.0,
elevation_gain_m: 0.0,
energy_kj: 0.0,
power_w: 0.0,
cadence: 0.0,
effort: 1.0,
last_target: None,
}
}
}
impl MockBackend {
/// Base gradient before the rider's manual trim.
fn base_gradient(&self, inputs: &RideInputs) -> f32 {
match inputs.mode {
ControlMode::Profile => inputs
.profile
.as_deref()
.and_then(|p| p.sample(self.position()))
.and_then(|t| match t {
ControlTarget::Gradient { percent } => Some(percent),
_ => None,
})
.unwrap_or(0.0),
_ => inputs.manual_gradient_pct,
}
}
/// What the profile wants right now, whatever channel it drives.
fn profile_target(&self, inputs: &RideInputs) -> Option<ControlTarget> {
inputs.profile.as_deref().and_then(|p| p.sample(self.position()))
}
fn position(&self) -> Position {
Position { elapsed_s: self.t_s, distance_m: self.distance_m }
}
}
impl RideBackend for MockBackend {
fn source(&self) -> &'static str {
"mock"
}
fn reset(&mut self) {
let rng = Rng(self.rng.0);
*self = Self { rng, ..Self::default() };
}
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
let running = inputs.status == RideStatus::Running;
if running {
self.t_s += dt_s as f64;
self.elapsed_ms += (dt_s * 1000.0).round() as u64;
}
let gradient_pct = self.base_gradient(inputs) + inputs.gradient_offset_pct;
// ---- what we would send to the trainer -----------------------------
let raw_target = match inputs.mode {
ControlMode::ManualGrade => ControlTarget::Gradient { percent: gradient_pct },
ControlMode::Resistance => ControlTarget::Resistance { level: inputs.resistance_level },
ControlMode::Erg => ControlTarget::Power { watts: inputs.power_target_w },
ControlMode::Profile => match self.profile_target(inputs) {
Some(ControlTarget::Gradient { .. }) | None => {
ControlTarget::Gradient { percent: gradient_pct }
}
Some(other) => other,
},
};
// SAF-3: clamped at the point of transmission, whatever the source.
let target = inputs.limits.clamp(raw_target);
// ---- synthesise a rider --------------------------------------------
if running {
// Slow wander in effort plus a breathing cycle.
self.effort += (self.rng.bipolar() * 0.02 - (self.effort - 1.0) * 0.02) * dt_s;
self.effort = self.effort.clamp(0.75, 1.3);
let breathing = 1.0 + 0.06 * (self.t_s as f32 / 23.0).sin();
let demand = match target {
ControlTarget::Power { watts } => watts as f32,
ControlTarget::Resistance { level } => 90.0 + level as f32 * 3.2,
ControlTarget::Gradient { percent } => 165.0 + percent * 13.0,
};
let wanted = (demand * self.effort * breathing).clamp(0.0, 800.0);
// First-order lag: legs do not step.
let tau = 2.5;
self.power_w += (wanted - self.power_w) * (dt_s / tau).min(1.0);
let noisy = (self.power_w + self.rng.bipolar() * 14.0).max(0.0);
let cadence_wanted = (78.0 + 14.0 * self.effort - gradient_pct * 1.1).clamp(55.0, 105.0);
self.cadence += (cadence_wanted - self.cadence) * (dt_s / 1.8).min(1.0);
self.energy_kj += noisy * dt_s / 1000.0;
// ---- §5.7 physics ----------------------------------------------
let cfg = inputs.rider;
let m = cfg.total_mass_kg();
let g = 9.80665f32;
let theta = (gradient_pct / 100.0).atan();
let v = self.speed_ms.max(0.5);
let f_prop = (noisy * cfg.drivetrain_efficiency) / v;
let f_grav = m * g * theta.sin();
let f_roll = m * g * cfg.crr * theta.cos();
let f_aero = 0.5 * cfg.air_density * cfg.cda * self.speed_ms * self.speed_ms;
let a = (f_prop - f_grav - f_roll - f_aero) / m;
self.speed_ms = (self.speed_ms + a * dt_s).max(0.0);
let step = self.speed_ms as f64 * dt_s as f64;
self.distance_m += step;
if gradient_pct > 0.0 {
self.elevation_gain_m += (step * (gradient_pct as f64 / 100.0)) as f32;
}
} else {
// Coast down when paused so the readouts settle rather than freeze.
self.power_w *= 1.0 - (dt_s * 2.0).min(1.0);
self.cadence *= 1.0 - (dt_s * 2.0).min(1.0);
self.speed_ms *= 1.0 - (dt_s * 0.6).min(1.0);
}
let power_out = if running { (self.power_w + self.rng.bipolar() * 12.0).max(0.0) } else { 0.0 };
let telemetry = Telemetry {
elapsed_ms: self.elapsed_ms,
power_w: Some(power_out.round() as i16),
cadence_rpm: Some(if self.cadence < 2.0 { 0.0 } else { self.cadence }),
// Trainer-reported speed is deliberately a little off the virtual
// speed — it is diagnostic only (FR-7.5).
speed_kph: Some(self.speed_ms * 3.6 * 0.98),
resistance_level: match target {
ControlTarget::Resistance { level } => Some(level),
_ => None,
},
heart_rate_bpm: Some((118.0 + power_out * 0.13).clamp(60.0, 195.0) as u8),
total_distance_m: Some(self.distance_m as u32),
total_energy_kcal: Some((self.energy_kj / 4.184) as u16),
};
let snapshot = RideSnapshot {
elapsed_ms: self.elapsed_ms,
telemetry,
virtual_speed_kph: self.speed_ms * 3.6,
virtual_distance_m: self.distance_m,
gradient_pct,
elevation_gain_m: self.elevation_gain_m,
mode: inputs.mode,
target: Some(target),
profile_progress: inputs
.profile
.as_deref()
.and_then(|p| p.total_extent().progress(self.position())),
};
let changed = self.last_target != Some(target);
self.last_target = Some(target);
Tick {
snapshot,
// SAF-1/SAF-8: only transmit while running, and only on change —
// the real backend rate-limits to ≤4 Hz here too (FR-2.8).
command: (running && changed).then_some(target),
}
}
}
+330
View File
@@ -0,0 +1,330 @@
//! The route, as the ride screen needs to draw it.
//!
//! `crates/core` owns profile *semantics* — `Profile::sample`,
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
//! model*: the elevation trace, the block breakdown, and the geometry needed to
//! place the current-position marker and answer "how much climbing is left".
//!
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.
use bikecontrol_core::profile::{
Block, Channel, Extent, Position, Profile, Waveform,
};
use serde::Serialize;
/// Which axis the profile is drawn against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum XUnit {
#[default]
Seconds,
Metres,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSummary {
pub index: usize,
/// `constant` | `ramp` | `wave` | `segments` | `terrain`
pub kind: &'static str,
pub channel: Channel,
pub label: String,
pub start_x: f64,
pub end_x: f64,
pub unit: XUnit,
}
/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView {
pub name: String,
pub description: Option<String>,
pub looping: bool,
/// Where it came from: a file path, a sample name, or `"editor"`.
pub source: String,
/// The channel the value series plots.
pub channel: Channel,
pub x_unit: XUnit,
pub total_x: f64,
/// Total ride duration, if the profile is measured in time.
pub total_seconds: Option<f64>,
/// Total ride distance, if the profile is measured in distance.
pub total_metres: Option<f64>,
/// `[x, value]` along the axis — gradient %, watts or resistance level.
pub series: Vec<[f64; 2]>,
/// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
/// integrated from gradient otherwise. This is the hero chart.
pub elevation: Option<Vec<[f64; 2]>>,
pub elevation_min_m: Option<f32>,
pub elevation_max_m: Option<f32>,
pub total_ascent_m: Option<f32>,
pub blocks: Vec<BlockSummary>,
/// The profile as YAML, for the in-app editor.
pub yaml: String,
}
/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
/// serialised — the frontend gets answers, not arrays to search.
#[derive(Debug, Clone, Default)]
pub struct ProfileGeometry {
pub xs: Vec<f64>,
pub elevation: Vec<f32>,
/// Cumulative ascent at each sample, so "climbing remaining" is a
/// subtraction rather than a scan.
pub cum_ascent: Vec<f32>,
pub total_x: f64,
pub x_unit: XUnit,
pub looping: bool,
pub total_seconds: Option<f64>,
pub total_metres: Option<f64>,
}
impl ProfileGeometry {
/// Elevation at a position on the axis, linearly interpolated.
pub fn elevation_at(&self, x: f64) -> Option<f32> {
interp(&self.xs, &self.elevation, x)
}
/// Metres of climbing still to come from `x` to the end.
pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
let total = *self.cum_ascent.last()?;
let done = interp(&self.xs, &self.cum_ascent, x)?;
Some((total - done).max(0.0))
}
pub fn total_ascent(&self) -> Option<f32> {
self.cum_ascent.last().copied()
}
}
fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
if xs.is_empty() || xs.len() != ys.len() {
return None;
}
if x <= xs[0] {
return Some(ys[0]);
}
let last = xs.len() - 1;
if x >= xs[last] {
return Some(ys[last]);
}
let i = xs.partition_point(|v| *v <= x).clamp(1, last);
let (x0, x1) = (xs[i - 1], xs[i]);
let (y0, y1) = (ys[i - 1], ys[i]);
let span = x1 - x0;
Some(if span.abs() < f64::EPSILON { y1 } else { y0 + (y1 - y0) * ((x - x0) / span) as f32 })
}
const PREVIEW_SAMPLES: usize = 1400;
fn extent_parts(extent: Extent) -> (f64, XUnit) {
match extent {
Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
}
}
/// Build the view model and the geometry that goes with it.
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
let extent = profile.total_extent();
let x_unit = match (extent.metres, extent.seconds) {
(Some(m), Some(s)) => {
if m >= s {
XUnit::Metres
} else {
XUnit::Seconds
}
}
(Some(_), None) => XUnit::Metres,
_ => XUnit::Seconds,
};
let preview = profile.preview(PREVIEW_SAMPLES);
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
let channel = profile.blocks.first().map(|b| b.channel()).unwrap_or(Channel::Gradient);
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
// block that already carries surveyed elevation. Otherwise integrate the
// gradient, which is what a hand-authored segment profile implies anyway.
let mut geom = ProfileGeometry {
total_x,
x_unit,
looping: profile.looping,
total_seconds: extent.seconds,
total_metres: extent.metres,
..Default::default()
};
let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
let surveyed = surveyed_elevation(profile);
let pairs = match surveyed {
Some(points) => points,
None if x_unit == XUnit::Metres => integrate_gradient(&series),
None => Vec::new(),
};
if pairs.len() < 2 {
None
} else {
geom.xs = pairs.iter().map(|p| p[0]).collect();
geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
let mut cum = Vec::with_capacity(geom.elevation.len());
let mut acc = 0.0f32;
let mut prev = geom.elevation[0];
for e in &geom.elevation {
acc += (e - prev).max(0.0);
prev = *e;
cum.push(acc);
}
geom.cum_ascent = cum;
Some(pairs)
}
} else {
None
};
let (elevation_min_m, elevation_max_m) = match &geom.elevation {
e if e.is_empty() => (None, None),
e => (
Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
),
};
let mut blocks = Vec::with_capacity(profile.blocks.len());
let mut cursor = 0.0f64;
for (index, block) in profile.blocks.iter().enumerate() {
let (span, unit) = extent_parts(block.extent());
blocks.push(BlockSummary {
index,
kind: block_kind(block),
channel: block.channel(),
label: block_label(block),
start_x: cursor,
end_x: cursor + span,
unit,
});
cursor += span;
}
let view = ProfileView {
name: profile.name.clone(),
description: profile.description.clone(),
looping: profile.looping,
source: source.into(),
channel,
x_unit,
total_x,
total_seconds: extent.seconds,
total_metres: extent.metres,
series,
elevation,
elevation_min_m,
elevation_max_m,
total_ascent_m: geom.total_ascent(),
blocks,
yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
};
(view, geom)
}
/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
/// join up rather than each restarting at zero distance.
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
let mut out: Vec<[f64; 2]> = Vec::new();
let mut offset = 0.0f64;
let mut any = false;
for block in &profile.blocks {
let (span, _) = extent_parts(block.extent());
if let Block::Terrain { points } = block {
any = true;
let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
for p in points {
out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
}
}
offset += span;
}
any.then_some(out)
}
/// Integrate gradient over distance to get a relative elevation trace.
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut elev = 0.0f64;
let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
series
.iter()
.map(|[x, grade]| {
elev += (x - prev_x).max(0.0) * (grade / 100.0);
prev_x = *x;
[*x, elev]
})
.collect()
}
/// Where the rider is on the preview axis right now.
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
let raw = match geom.x_unit {
XUnit::Seconds => elapsed_s,
XUnit::Metres => distance_m,
};
if geom.looping && geom.total_x > 0.0 {
raw.rem_euclid(geom.total_x)
} else {
raw.clamp(0.0, geom.total_x.max(0.0))
}
}
/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
Position { elapsed_s, distance_m }
}
fn block_kind(block: &Block) -> &'static str {
match block {
Block::Constant { .. } => "constant",
Block::Ramp { .. } => "ramp",
Block::Wave { .. } => "wave",
Block::Segments { .. } => "segments",
Block::Terrain { .. } => "terrain",
}
}
fn unit_suffix(channel: Channel) -> &'static str {
match channel {
Channel::Gradient => "%",
Channel::Resistance => "",
Channel::Power => " W",
}
}
fn block_label(block: &Block) -> String {
let u = unit_suffix(block.channel());
match block {
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u}{to:.0}{u}"),
Block::Wave { shape, midpoint, amplitude, repeats, .. } => format!(
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
match shape {
Waveform::Sine => "sine",
Waveform::Square => "square",
Waveform::Triangle => "triangle",
Waveform::Sawtooth => "sawtooth",
},
midpoint,
amplitude,
repeats
),
Block::Segments { segments } => {
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
}
Block::Terrain { points } => {
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
format!("terrain · {:.1} km", d / 1000.0)
}
}
}
+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)
}
+75
View File
@@ -0,0 +1,75 @@
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
//! telemetry.
//!
//! Compiled only under `--features real-session`, because
//! `RideSession::tick`/`snapshot` are still `todo!()` and would panic on the
//! first tick. Enabling the feature (and disabling `mock-ride`) is the whole
//! swap — nothing above [`crate::backend::RideBackend`] changes, and the
//! frontend does not change at all.
#![cfg(feature = "real-session")]
use bikecontrol_core::session::{RideSession, SessionEvent};
use bikecontrol_core::types::{ControlTarget, RideSnapshot, Telemetry};
use tokio::sync::watch;
use crate::backend::{RideBackend, RideInputs, Tick};
use crate::events::RideStatus;
pub struct SessionBackend {
session: RideSession,
/// Latest decoded Indoor Bike Data, published by `bikecontrol_ble`.
telemetry: watch::Receiver<Telemetry>,
last_snapshot: Option<RideSnapshot>,
}
impl SessionBackend {
pub fn new(inputs: &RideInputs, telemetry: watch::Receiver<Telemetry>) -> Self {
Self {
session: RideSession::new(inputs.rider, inputs.limits),
telemetry,
last_snapshot: None,
}
}
}
impl RideBackend for SessionBackend {
fn source(&self) -> &'static str {
"ftms"
}
fn reset(&mut self) {
self.session = RideSession::new(self.session.config, self.session.limits);
}
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
self.session.mode = inputs.mode;
if let Some(profile) = inputs.profile.as_deref() {
if self.session.profile().is_none() {
self.session.load_profile(profile.clone());
}
}
match inputs.status {
RideStatus::Running => self.session.start(),
RideStatus::Paused => self.session.pause(),
_ => {}
}
let telemetry = *self.telemetry.borrow();
let mut command = None;
let mut snapshot = None;
for event in self.session.tick(telemetry, dt_s) {
match event {
SessionEvent::Command(target) => command = Some(target),
SessionEvent::Snapshot(s) => snapshot = Some(s),
SessionEvent::ProfileFinished | SessionEvent::Lap { .. } => {}
}
}
let snapshot = snapshot
.or(self.last_snapshot)
.unwrap_or_else(|| self.session.snapshot(telemetry));
self.last_snapshot = Some(snapshot);
let _: Option<ControlTarget> = command;
Tick { snapshot, command }
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Application state and the two background loops that drive the UI.
//!
//! §4.3: the control loop lives here, in Rust. The webview never computes
//! anything — it receives `RideSnapshot`s on a timer and sends intents back as
//! commands.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};
use crate::backend::{RideBackend, RideInputs};
use crate::devices::DeviceRegistry;
use crate::events;
use crate::events::{
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::mock::MockBackend;
use crate::profile_view::{ProfileGeometry, ProfileView};
/// Snapshot push rate. FTMS notifies at 14 Hz (NFR-2); we publish at the top
/// of that range and the frontend interpolates nothing.
pub const TICK_HZ: u64 = 4;
const TICK_MS: u64 = 1000 / TICK_HZ;
/// Device list refresh, deliberately slower than the ride loop.
const SCAN_TICK_MS: u64 = 500;
pub struct Inner {
pub inputs: RideInputs,
pub backend: Box<dyn RideBackend>,
pub devices: DeviceRegistry,
pub profile_view: Option<ProfileView>,
/// Precomputed route geometry, kept Rust-side so the per-tick elevation and
/// ascent-remaining lookups are a binary search rather than a scan.
pub geometry: Option<ProfileGeometry>,
pub deriver: Deriver,
pub last_snapshot: Option<RideSnapshot>,
pub last_derived: Option<Derived>,
pub lap_index: u32,
pub laps: Vec<LapSummary>,
lap_start_ms: u64,
lap_start_m: f64,
lap_power_sum: f64,
lap_power_n: u64,
}
impl Inner {
fn new() -> Self {
Self {
inputs: RideInputs::default(),
backend: Box::new(MockBackend::default()),
devices: DeviceRegistry::new(),
profile_view: None,
geometry: None,
deriver: Deriver::default(),
last_snapshot: None,
last_derived: None,
lap_index: 1,
laps: Vec::new(),
lap_start_ms: 0,
lap_start_m: 0.0,
lap_power_sum: 0.0,
lap_power_n: 0,
}
}
pub fn ride_state(&self) -> RideState {
RideState {
status: self.inputs.status,
mode: self.inputs.mode,
target: self.last_snapshot.and_then(|s| s.target),
gradient_offset_pct: self.inputs.gradient_offset_pct,
manual_gradient_pct: self.inputs.manual_gradient_pct,
resistance_level: self.inputs.resistance_level,
power_target_w: self.inputs.power_target_w,
lap: self.lap_index,
laps: self.laps.clone(),
profile: self.profile_view.clone(),
source: self.backend.source(),
}
}
pub fn set_profile(&mut self, profile: Profile, view: ProfileView, geom: ProfileGeometry) {
self.inputs.profile = Some(Arc::new(profile));
self.profile_view = Some(view);
self.geometry = Some(geom);
self.inputs.mode = bikecontrol_core::types::ControlMode::Profile;
}
pub fn clear_profile(&mut self) {
self.inputs.profile = None;
self.profile_view = None;
self.geometry = None;
if self.inputs.mode == bikecontrol_core::types::ControlMode::Profile {
self.inputs.mode = bikecontrol_core::types::ControlMode::ManualGrade;
}
}
/// Close the current lap and open the next (FR-3.19, FR-8.7).
pub fn mark_lap(&mut self) -> LapSummary {
let snapshot = self.last_snapshot;
let elapsed_ms = snapshot.map(|s| s.elapsed_ms).unwrap_or(0);
let distance_m = snapshot.map(|s| s.virtual_distance_m).unwrap_or(0.0);
let lap = LapSummary {
index: self.lap_index,
elapsed_ms: elapsed_ms.saturating_sub(self.lap_start_ms),
distance_m: distance_m - self.lap_start_m,
avg_power_w: if self.lap_power_n == 0 {
0.0
} else {
(self.lap_power_sum / self.lap_power_n as f64) as f32
},
};
self.laps.push(lap);
self.lap_index += 1;
self.lap_start_ms = elapsed_ms;
self.lap_start_m = distance_m;
self.lap_power_sum = 0.0;
self.lap_power_n = 0;
lap
}
pub fn reset_ride(&mut self) {
self.backend.reset();
self.deriver.reset();
self.last_derived = None;
self.inputs.status = RideStatus::Idle;
self.inputs.gradient_offset_pct = 0.0;
self.last_snapshot = None;
self.lap_index = 1;
self.laps.clear();
self.lap_start_ms = 0;
self.lap_start_m = 0.0;
self.lap_power_sum = 0.0;
self.lap_power_n = 0;
}
/// Fold a fresh snapshot into the lap accumulators and the rolling windows.
fn absorb(&mut self, snapshot: &RideSnapshot) -> Derived {
let running = self.inputs.status == RideStatus::Running;
if running {
if let Some(p) = snapshot.telemetry.power_w {
self.lap_power_sum += p as f64;
self.lap_power_n += 1;
}
}
let profile = self.inputs.profile.clone();
let derived =
self.deriver
.update(snapshot, running, profile.as_deref(), self.geometry.as_ref());
self.last_derived = Some(derived);
derived
}
}
#[derive(Clone)]
pub struct AppState(Arc<Mutex<Inner>>);
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Inner::new())))
}
/// Panics are impossible to recover from here, and a poisoned lock means
/// the ride loop already died — surface it rather than hide it.
pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// Emit the low-frequency ride state. Call after anything that changes mode,
/// target, status, laps or the loaded profile.
pub fn emit_ride_state(app: &AppHandle) {
let state = app.state::<AppState>();
let payload = state.lock().ride_state();
let _ = app.emit(events::RIDE_STATE, payload);
}
pub fn emit_devices(app: &AppHandle) {
let state = app.state::<AppState>();
let (scanning, devices) = {
let inner = state.lock();
(inner.devices.scanning, inner.devices.list())
};
let _ = app.emit(events::DEVICES_UPDATED, DeviceList { scanning, devices });
}
pub fn notify(app: &AppHandle, notice: Notice) {
let _ = app.emit(events::APP_NOTICE, notice);
}
/// Confirm an input registered so the UI can flash the control (FR-9.9).
pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
}
/// The ride loop. One tick: advance the backend, publish the snapshot, and
/// transmit the (already clamped) target to the trainer.
pub fn spawn_ride_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let dt_s = TICK_MS as f32 / 1000.0;
loop {
interval.tick().await;
let (frame, command) = {
let state = app.state::<AppState>();
let mut inner = state.lock();
let inputs = inner.inputs.clone();
let tick = inner.backend.tick(dt_s, &inputs);
inner.last_snapshot = Some(tick.snapshot);
let derived = inner.absorb(&tick.snapshot);
(RideFrame { snapshot: tick.snapshot, derived }, tick.command)
};
let _ = app.emit(events::RIDE_SNAPSHOT, frame);
if let Some(target) = command {
transmit(&app, target);
}
}
});
}
/// Where the FTMS control-point write will go. Until `crates/ble` exists this
/// only logs — but every target already passed `SafetyLimits::clamp` before it
/// got here (SAF-3), so wiring the real write is a one-line change.
fn transmit(_app: &AppHandle, target: ControlTarget) {
tracing::debug!(?target, "control target (no trainer attached — mock backend)");
}
/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit.
pub fn release_trainer(app: &AppHandle) {
let state = app.state::<AppState>();
let limits = state.lock().inputs.limits;
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tracing::info!(?safe, "releasing trainer (SAF-2)");
transmit(app, safe);
}
/// The scan loop: advances the device mock and pushes the list when it changes.
pub fn spawn_device_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let result = {
let state = app.state::<AppState>();
let mut inner = state.lock();
inner.devices.poll()
};
for device in &result.transitions {
let _ = app.emit(
events::DEVICE_CONNECTION,
ConnectionEvent {
device_id: device.id.clone(),
state: device.state.clone(),
control_acquired: device.control_acquired,
error: device.error.clone(),
},
);
}
if result.changed {
emit_devices(&app);
}
}
});
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>BikeControl</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1625
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bikecontrol-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vite": "^6.0.11"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}
+230
View File
@@ -0,0 +1,230 @@
/*
* Dark theme for a screen someone stares at while suffering (FR-9.12).
*
* Rules this stylesheet follows:
* - Near-black ground, bright data. Contrast comes from the numbers, not
* from boxes.
* - No borders unless they carry meaning. Structure comes from space.
* - Numbers are tabular so they do not jitter as digits change.
* - Type scales with the viewport: the primary readouts must be legible from
* a riding position about a metre away (FR-9.5).
*/
:root {
--bg: #05070a;
--bg-lift: #0b0f15;
--hairline: #161c25;
--ink: #f4f7fb;
--ink-soft: #9fb0c2;
--ink-dim: #5d6c7d;
--ink-faint: #313d4a;
--route: #45d0ff;
--route-deep: #123a4d;
--climb: #ff9a3c;
--climb-deep: #3d2712;
--ok: #35d9a0;
--warn: #ffcf4a;
--bad: #ff5a52;
--power: #dfe8f3;
--power-raw: #3f4d5d;
--gap: clamp(0.75rem, 1.4vw, 1.5rem);
--edge: clamp(1rem, 2.4vw, 2.75rem);
color-scheme: dark;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--ink);
font-family:
'Inter var', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1, 'cv01' 1;
letter-spacing: -0.01em;
user-select: none;
-webkit-user-select: none;
}
#app {
height: 100%;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
/* ---------- shared primitives ---------------------------------------- */
.label {
font-size: clamp(0.6rem, 0.72vw, 0.78rem);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-dim);
white-space: nowrap;
}
.hairline {
border-top: 1px solid var(--hairline);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.45em;
padding: 0.3em 0.7em;
border-radius: 999px;
background: var(--bg-lift);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-soft);
white-space: nowrap;
}
.dot {
width: 0.5em;
height: 0.5em;
border-radius: 50%;
background: currentColor;
flex: none;
}
.tone-ok {
color: var(--ok);
}
.tone-warn {
color: var(--warn);
}
.tone-bad {
color: var(--bad);
}
.tone-idle {
color: var(--ink-dim);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 0.62em 1.05em;
border-radius: 0.5rem;
background: var(--bg-lift);
color: var(--ink-soft);
font-size: 0.92rem;
font-weight: 600;
transition:
background 120ms ease,
color 120ms ease,
transform 90ms ease;
}
.btn:hover {
background: #131a24;
color: var(--ink);
}
.btn:active {
transform: translateY(1px);
}
.btn.primary {
background: var(--route);
color: #04121a;
}
.btn.primary:hover {
background: #6cdcff;
color: #04121a;
}
.btn.ghost {
background: transparent;
}
.btn.ghost:hover {
background: var(--bg-lift);
}
.btn.danger:hover {
background: #2a1113;
color: var(--bad);
}
.btn[disabled] {
opacity: 0.35;
pointer-events: none;
}
.kbd {
display: inline-block;
min-width: 1.5em;
padding: 0.1em 0.35em;
border-radius: 0.28em;
background: #10161f;
color: var(--ink-dim);
font-size: 0.68rem;
font-weight: 700;
text-align: center;
letter-spacing: 0.02em;
}
/* ---------- uPlot, restyled for the dark theme ------------------------ */
.uplot,
.u-wrap {
width: 100% !important;
}
.u-title,
.u-legend {
display: none;
}
.u-axis {
color: var(--ink-dim);
}
.u-select {
background: rgba(69, 208, 255, 0.12);
}
.u-cursor-x,
.u-cursor-y {
border-color: var(--ink-faint) !important;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #1b232e;
border-radius: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
/**
* One number, sized by importance. The whole ride screen is built from these,
* which is how the visual hierarchy stays honest: importance is a prop, not a
* pile of one-off styles.
*/
interface Props {
label: string;
value: string;
unit?: string;
size?: 'hero' | 'big' | 'mid' | 'small';
colour?: string;
sub?: string | null;
dim?: boolean;
align?: 'start' | 'end';
}
let {
label,
value,
unit = '',
size = 'mid',
colour = 'var(--ink)',
sub = null,
dim = false,
align = 'start',
}: Props = $props();
</script>
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
<span class="label">{label}</span>
<span class="value" style:color={colour}>
{value}{#if unit}<span class="unit">{unit}</span>{/if}
</span>
{#if sub}<span class="sub">{sub}</span>{/if}
</div>
<style>
.readout {
display: flex;
flex-direction: column;
gap: 0.15em;
min-width: 0;
}
.value {
font-weight: 300;
line-height: 0.92;
letter-spacing: -0.035em;
white-space: nowrap;
}
.unit {
font-size: 0.34em;
font-weight: 600;
letter-spacing: 0.02em;
margin-left: 0.22em;
color: var(--ink-dim);
vertical-align: baseline;
}
.sub {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-dim);
white-space: nowrap;
}
.hero .value {
font-size: clamp(3.4rem, 7vw, 7.2rem);
}
.big .value {
font-size: clamp(2.4rem, 4.6vw, 4.8rem);
}
.mid .value {
font-size: clamp(1.6rem, 2.6vw, 2.8rem);
font-weight: 400;
}
.small .value {
font-size: clamp(1.05rem, 1.5vw, 1.65rem);
font-weight: 500;
}
.dim .value {
color: var(--ink-dim) !important;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
/**
* The hero element: the route, with the rider's position on it.
*
* For a GPX or terrain profile this is a real elevation trace. For a
* waveform profile there is no elevation, so it plots the profile's own
* channel instead — same chart, same marker, honest label (FR-9.7).
*
* The ridden part is drawn bright and filled; what is still to come is dim.
* At a glance, from a metre away, that is the one thing a rider wants: how
* much of this is left, and does it go up.
*/
import { onMount } from 'svelte';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { axis, observeSize, positionMarker } from '../lib/uplot';
import type { ProfileView } from '../lib/types';
interface Props {
profile: ProfileView | null;
positionX: number;
revision: number;
}
let { profile, positionX, revision }: Props = $props();
let host = $state<HTMLDivElement | null>(null);
let plot: uPlot | null = null;
let disposeSize: (() => void) | null = null;
let currentX = 0;
/** Elevation when we have it, otherwise the profile's own channel. */
const source = $derived.by(() => {
if (!profile) return null;
const pairs = profile.elevation ?? profile.series;
if (!pairs || pairs.length < 2) return null;
return { pairs, isElevation: profile.elevation != null };
});
function build(): void {
if (!host || !source) return;
plot?.destroy();
plot = null;
const xs = source.pairs.map((p) => p[0]);
const ys = source.pairs.map((p) => p[1]);
const isMetres = profile?.xUnit === 'metres';
const opts: uPlot.Options = {
width: host.clientWidth || 800,
height: host.clientHeight || 260,
padding: [12, 8, 0, 0],
cursor: { show: false },
legend: { show: false },
scales: { x: { time: false } },
axes: [
axis({
values: (_u, splits) =>
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
}),
axis({
side: 3,
size: 46,
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
}),
],
series: [
{},
{
// Everything still to come.
stroke: '#2b4d5e',
width: 1,
fill: 'rgba(24, 58, 76, 0.55)',
points: { show: false },
},
{
// Ridden so far.
stroke: '#45d0ff',
width: 2,
fill: 'rgba(69, 208, 255, 0.22)',
points: { show: false },
},
],
plugins: [positionMarker(() => currentX, '#ffffff')],
};
plot = new uPlot(opts, [xs, ys, ys.map(() => null)] as unknown as uPlot.AlignedData, host);
disposeSize?.();
disposeSize = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
update();
}
function formatMinutes(v: number): string {
const m = Math.round(v / 60);
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
}
/** Split the trace at the current position; only the split array changes. */
function update(): void {
if (!plot || !source) return;
currentX = positionX;
const xs = source.pairs;
const ridden: (number | null)[] = new Array(xs.length);
for (let i = 0; i < xs.length; i++) {
ridden[i] = xs[i][0] <= positionX ? xs[i][1] : null;
}
// Carry one point past the split so the bright fill meets the marker.
const idx = ridden.findIndex((v) => v === null);
if (idx > 0) ridden[idx] = xs[idx][1];
plot.setData(
[xs.map((p) => p[0]), xs.map((p) => p[1]), ridden] as unknown as uPlot.AlignedData,
true,
);
}
onMount(() => {
build();
return () => {
disposeSize?.();
plot?.destroy();
};
});
// Rebuild only when the route itself changes; otherwise just re-split.
let builtFor = $state<string | null>(null);
$effect(() => {
const key = profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
if (key !== builtFor) {
builtFor = key;
if (key) build();
else {
plot?.destroy();
plot = null;
}
}
});
$effect(() => {
revision;
positionX;
update();
});
</script>
<div class="route">
<div class="canvas" bind:this={host}></div>
{#if !source}
<div class="empty">
<span class="label">No route loaded</span>
<p>Load a GPX or a YAML profile to see the terrain ahead.</p>
</div>
{/if}
{#if source && !source.isElevation}
<span class="overlay label">
{profile?.channel} profile — no elevation
</span>
{/if}
</div>
<style>
.route {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
}
.canvas {
width: 100%;
height: 100%;
}
.empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--ink-dim);
}
.empty p {
margin: 0;
font-size: 0.95rem;
color: var(--ink-faint);
}
.overlay {
position: absolute;
top: 0.25rem;
right: 0.5rem;
color: var(--ink-faint);
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
/**
* A live streaming chart backed by a decimating `History` (NFR-3). The data
* arrays are typed-array views that never grow, so this stays cheap for a
* two-hour ride.
*/
import { onMount } from 'svelte';
import uPlot from 'uplot';
import { axis, observeSize } from '../lib/uplot';
import type { History } from '../lib/history';
interface SeriesSpec {
stroke: string;
width?: number;
fill?: string;
dash?: number[];
}
interface Props {
history: History;
series: SeriesSpec[];
revision: number;
/** Fixed y range, or null to autoscale. */
range?: [number, number] | null;
zeroLine?: boolean;
}
let { history, series, revision, range = null, zeroLine = false }: Props = $props();
let host = $state<HTMLDivElement | null>(null);
let plot: uPlot | null = null;
let dispose: (() => void) | null = null;
onMount(() => {
if (!host) return;
const opts: uPlot.Options = {
width: host.clientWidth || 400,
height: host.clientHeight || 140,
padding: [8, 6, 0, 0],
cursor: { show: false },
legend: { show: false },
scales: {
x: { time: false },
y: range ? { range: () => range as [number, number] } : {},
},
axes: [
axis({
values: (_u, splits) => splits.map((v) => formatClock(v)),
}),
axis({ side: 3, size: 42, values: (_u, splits) => splits.map((v) => v.toFixed(0)) }),
],
series: [
{},
...series.map((s) => ({
stroke: s.stroke,
width: s.width ?? 1.5,
fill: s.fill,
dash: s.dash,
points: { show: false },
})),
],
hooks: zeroLine
? {
draw: [
(u: uPlot) => {
const y = u.valToPos(0, 'y', true);
if (!Number.isFinite(y)) return;
u.ctx.save();
u.ctx.strokeStyle = '#243040';
u.ctx.lineWidth = 1;
u.ctx.beginPath();
u.ctx.moveTo(u.bbox.left, y);
u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
u.ctx.stroke();
u.ctx.restore();
},
],
}
: {},
};
plot = new uPlot(opts, history.view() as unknown as uPlot.AlignedData, host);
dispose = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
return () => {
dispose?.();
plot?.destroy();
};
});
function formatClock(v: number): string {
const m = Math.floor(v / 60);
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
}
$effect(() => {
revision;
if (plot && history.length > 1) {
plot.setData(history.view() as unknown as uPlot.AlignedData, true);
}
});
</script>
<div class="stream" bind:this={host}></div>
<style>
.stream {
width: 100%;
height: 100%;
min-height: 0;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
/**
* Client-side view state. Everything here is either received from Rust or is
* purely presentational (which screen is showing, which toast is up).
*/
import { api, subscribe } from './bridge';
import { History } from './history';
import type {
DeviceList,
InputAck,
LapSummary,
Notice,
RideFrame,
RideState,
SampleProfile,
} from './types';
export type Screen = 'connect' | 'ride';
let toastSeq = 0;
class AppStore {
screen = $state<Screen>('connect');
frame = $state<RideFrame | null>(null);
ride = $state<RideState | null>(null);
devices = $state<DeviceList>({ scanning: false, devices: [] });
samples = $state<SampleProfile[]>([]);
toasts = $state<(Notice & { id: number })[]>([]);
lastAck = $state<(InputAck & { at: number }) | null>(null);
lastLap = $state<LapSummary | null>(null);
showHelp = $state(false);
showProfiles = $state(false);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0);
/** Bounded chart history — raw power, rolling power. */
readonly power = new History(2);
/** Bounded chart history — commanded gradient. */
readonly grade = new History(1);
private lastElapsed = -1;
async init(): Promise<void> {
const [ride, devices, samples] = await Promise.all([
api.rideState(),
api.deviceList(),
api.sampleProfiles(),
]);
this.ride = ride;
this.devices = devices;
this.samples = samples;
await subscribe({
onFrame: (f) => this.onFrame(f),
onRideState: (s) => {
this.ride = s;
},
onDevices: (d) => {
this.devices = d;
},
onLap: (l) => {
this.lastLap = l;
},
onNotice: (n) => this.toast(n),
onInputAck: (a) => {
this.lastAck = { ...a, at: performance.now() };
},
});
}
private onFrame(f: RideFrame): void {
this.frame = f;
const t = f.snapshot.elapsed_ms / 1000;
// The ride clock only advances while running; a paused ride should not
// stack duplicate points onto the charts.
if (t > this.lastElapsed) {
this.lastElapsed = t;
this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]);
this.grade.push(t, [f.snapshot.gradient_pct]);
} else if (t < this.lastElapsed) {
this.clearHistory();
this.lastElapsed = t;
}
this.revision++;
}
clearHistory(): void {
this.power.clear();
this.grade.clear();
this.lastElapsed = -1;
}
toast(n: Notice): void {
const entry = { ...n, id: ++toastSeq };
this.toasts = [...this.toasts, entry];
const ttl = n.level === 'error' ? 8000 : 4000;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== entry.id);
}, ttl);
}
dismiss(id: number): void {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
/** Run a command and surface any rejection as a toast rather than silently. */
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
try {
return await fn();
} catch (e) {
this.toast({ level: 'error', message: String(e) });
return undefined;
}
}
}
export const app = new AppStore();
+108
View File
@@ -0,0 +1,108 @@
/**
* The only place that talks to Rust.
*
* Commands are intents; they never mutate local state directly. Truth comes
* back on the event channel (§4.3).
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type {
ControlMode,
DeviceInfo,
DeviceList,
InputAck,
LapSummary,
Notice,
ProfileView,
RideFrame,
RideState,
RiderConfig,
SafetyLimits,
SampleProfile,
} from './types';
export const EVENTS = {
snapshot: 'ride://snapshot',
rideState: 'ride://state',
lap: 'ride://lap',
devices: 'devices://updated',
connection: 'devices://connection',
notice: 'app://notice',
inputAck: 'app://input-ack',
} as const;
/** True when running inside the Tauri shell rather than a bare browser. */
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return invoke<T>(cmd, args);
}
export const api = {
// ride lifecycle
rideState: () => call<RideState>('ride_state'),
start: () => call<RideState>('start_ride'),
pause: () => call<RideState>('pause_ride'),
resume: () => call<RideState>('resume_ride'),
togglePause: () => call<RideState>('toggle_pause'),
stop: () => call<RideState>('stop_ride'),
reset: () => call<RideState>('reset_ride'),
// control modes and targets
setMode: (mode: ControlMode) => call<RideState>('set_control_mode', { mode }),
cycleMode: () => call<RideState>('cycle_control_mode'),
nudgeGradient: (deltaPct: number) => call<RideState>('nudge_gradient', { deltaPct }),
setGradient: (percent: number) => call<RideState>('set_gradient', { percent }),
resetGradient: () => call<RideState>('reset_gradient'),
setResistance: (level: number) => call<RideState>('set_target_resistance', { level }),
setPower: (watts: number) => call<RideState>('set_target_power', { watts }),
markLap: () => call<LapSummary>('mark_lap'),
// configuration
riderConfig: () => call<RiderConfig>('rider_config'),
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
safetyLimits: () => call<SafetyLimits>('safety_limits'),
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
// profiles
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
loadProfileText: (name: string, text: string, isGpx: boolean) =>
call<ProfileView>('load_profile_from_text', { name, text, isGpx }),
previewYaml: (yaml: string) => call<ProfileView>('preview_profile_yaml', { yaml }),
clearProfile: () => call<RideState>('clear_profile'),
sampleProfiles: () => call<SampleProfile[]>('sample_profiles'),
// devices
deviceList: () => call<DeviceList>('device_list'),
startScan: () => call<void>('start_scan'),
stopScan: () => call<void>('stop_scan'),
connect: (deviceId: string) => call<DeviceInfo>('connect_device', { deviceId }),
disconnect: (deviceId: string) => call<DeviceInfo>('disconnect_device', { deviceId }),
forget: (deviceId: string) => call<void>('forget_device', { deviceId }),
trainerControllable: () => call<boolean>('trainer_controllable'),
};
type Handlers = {
onFrame?: (f: RideFrame) => void;
onRideState?: (s: RideState) => void;
onLap?: (l: LapSummary) => void;
onDevices?: (d: DeviceList) => void;
onNotice?: (n: Notice) => void;
onInputAck?: (a: InputAck) => void;
};
/** Subscribe to the whole event channel. Returns a single unsubscribe. */
export async function subscribe(h: Handlers): Promise<UnlistenFn> {
const offs: UnlistenFn[] = [];
const add = async <T>(name: string, fn?: (p: T) => void) => {
if (!fn) return;
offs.push(await listen<T>(name, (e) => fn(e.payload)));
};
await add(EVENTS.snapshot, h.onFrame);
await add(EVENTS.rideState, h.onRideState);
await add(EVENTS.lap, h.onLap);
await add(EVENTS.devices, h.onDevices);
await add(EVENTS.notice, h.onNotice);
await add(EVENTS.inputAck, h.onInputAck);
return () => offs.forEach((off) => off());
}
+104
View File
@@ -0,0 +1,104 @@
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
const EM_DASH = '—';
export function clock(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
const s = Math.max(0, Math.round(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
/** Shorter form for an ETA: "1h 04" / "42 min" / "38 s". */
export function duration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
const s = Math.max(0, Math.round(seconds));
if (s >= 3600) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${String(m).padStart(2, '0')}`;
}
if (s >= 60) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
return `${s}s`;
}
/** Wall-clock time of arrival, e.g. "14:37". */
export function finishAt(secondsFromNow: number | null | undefined): string {
if (secondsFromNow == null || !Number.isFinite(secondsFromNow)) return EM_DASH;
const t = new Date(Date.now() + secondsFromNow * 1000);
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
}
export function km(metres: number | null | undefined, digits = 2): string {
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
return (metres / 1000).toFixed(digits);
}
export function num(v: number | null | undefined, digits = 0): string {
if (v == null || !Number.isFinite(v)) return EM_DASH;
return v.toFixed(digits);
}
export function signed(v: number | null | undefined, digits = 1): string {
if (v == null || !Number.isFinite(v)) return EM_DASH;
return `${v >= 0 ? '' : ''}${Math.abs(v).toFixed(digits)}`;
}
export function axisLabel(unit: 'seconds' | 'metres'): string {
return unit === 'metres' ? 'distance' : 'time';
}
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
}
export function rssiBars(rssi: number): number {
if (rssi >= -55) return 4;
if (rssi >= -67) return 3;
if (rssi >= -78) return 2;
if (rssi >= -88) return 1;
return 0;
}
export function targetText(
target:
| { Gradient: { percent: number } }
| { Resistance: { level: number } }
| { Power: { watts: number } }
| null,
): string {
if (!target) return EM_DASH;
if ('Gradient' in target) return `${signed(target.Gradient.percent, 1)}%`;
if ('Resistance' in target) return `L${target.Resistance.level}`;
return `${target.Power.watts} W`;
}
export const MODE_LABEL: Record<string, string> = {
ManualGrade: 'Manual grade',
Resistance: 'Resistance',
Profile: 'Profile',
Erg: 'ERG',
};
export function connectionText(
state: string | { Lost: { reason: string } },
): { label: string; tone: 'ok' | 'warn' | 'bad' | 'idle' } {
if (typeof state !== 'string') return { label: `Lost — ${state.Lost.reason}`, tone: 'bad' };
switch (state) {
case 'Controlling':
return { label: 'Connected', tone: 'ok' };
case 'Connected':
return { label: 'Connected', tone: 'warn' };
case 'Connecting':
return { label: 'Connecting', tone: 'warn' };
case 'Reconnecting':
return { label: 'Reconnecting', tone: 'warn' };
case 'Scanning':
return { label: 'Discovered', tone: 'idle' };
default:
return { label: 'Idle', tone: 'idle' };
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Bounded, self-decimating chart history (NFR-3).
*
* A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is
* both a memory leak and a rendering cost that grows through the ride exactly
* what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it
* averages adjacent pairs in place, halving the point count and doubling the
* time each point represents. Later samples are then averaged in groups of that
* same stride before being stored.
*
* The result is constant memory and a constant point count for any ride
* length, with resolution degrading gracefully: full 250 ms detail for the
* first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so
* uPlot can consume them without a copy.
*/
const CAPACITY = 3600;
export class History {
readonly capacity: number;
readonly seriesCount: number;
/** Seconds since ride start. */
readonly x: Float64Array;
readonly y: Float64Array[];
/** Number of populated points. */
length = 0;
/** Raw samples currently folded into one stored point. */
stride = 1;
private pendingX = 0;
private pendingY: Float64Array;
private pendingN = 0;
constructor(seriesCount: number, capacity = CAPACITY) {
this.capacity = capacity;
this.seriesCount = seriesCount;
this.x = new Float64Array(capacity);
this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity));
this.pendingY = new Float64Array(seriesCount);
}
push(x: number, values: number[]): void {
this.pendingX += x;
for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0;
this.pendingN++;
if (this.pendingN < this.stride) return;
if (this.length >= this.capacity) this.compact();
const i = this.length++;
this.x[i] = this.pendingX / this.pendingN;
for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN;
this.pendingX = 0;
this.pendingY.fill(0);
this.pendingN = 0;
}
/** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */
private compact(): void {
const half = this.length >> 1;
for (let i = 0; i < half; i++) {
const a = i * 2;
const b = a + 1;
this.x[i] = (this.x[a] + this.x[b]) / 2;
for (let s = 0; s < this.seriesCount; s++) {
this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2;
}
}
this.length = half;
this.stride *= 2;
}
clear(): void {
this.length = 0;
this.stride = 1;
this.pendingX = 0;
this.pendingY.fill(0);
this.pendingN = 0;
}
/** Views sized to the populated region, ready for `uPlot.setData`. */
view(): Float64Array[] {
return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))];
}
}
+207
View File
@@ -0,0 +1,207 @@
/**
* TypeScript mirror of the Rust payloads.
*
* The authoritative definitions are `crates/core/src/types.rs` (frozen),
* `src-tauri/src/events.rs`, `src-tauri/src/derive.rs` and
* `src-tauri/src/profile_view.rs`. Nothing here is computed these are shapes
* that arrive over the event channel.
*/
// --- crates/core/src/types.rs (frozen contract) -----------------------------
export interface Telemetry {
elapsedMs: number;
power_w: number | null;
cadence_rpm: number | null;
speed_kph: number | null;
resistance_level: number | null;
heart_rate_bpm: number | null;
total_distance_m: number | null;
total_energy_kcal: number | null;
}
/** Serde externally-tagged enum. */
export type ControlTarget =
| { Gradient: { percent: number } }
| { Resistance: { level: number } }
| { Power: { watts: number } };
export type ControlMode = 'ManualGrade' | 'Resistance' | 'Profile' | 'Erg';
export type ConnectionState =
| 'Idle'
| 'Scanning'
| 'Connecting'
| 'Connected'
| 'Controlling'
| 'Reconnecting'
| { Lost: { reason: string } };
export interface RideSnapshot {
elapsed_ms: number;
telemetry: {
elapsed_ms: number;
power_w: number | null;
cadence_rpm: number | null;
speed_kph: number | null;
resistance_level: number | null;
heart_rate_bpm: number | null;
total_distance_m: number | null;
total_energy_kcal: number | null;
};
virtual_speed_kph: number;
virtual_distance_m: number;
gradient_pct: number;
elevation_gain_m: number;
mode: ControlMode;
target: ControlTarget | null;
profile_progress: number | null;
}
export interface RiderConfig {
rider_kg: number;
bike_kg: number;
crr: number;
cda: number;
drivetrain_efficiency: number;
air_density: number;
wheel_circumference_m: number;
}
export interface SafetyLimits {
min_gradient_pct: number;
max_gradient_pct: number;
min_resistance: number;
max_resistance: number;
min_power_w: number;
max_power_w: number;
}
// --- src-tauri/src/derive.rs -------------------------------------------------
export type EtaKind = 'exact' | 'estimated' | 'held' | 'looping' | 'unavailable';
export type XUnit = 'seconds' | 'metres';
export interface Derived {
etaKind: EtaKind;
timeRemainingS: number | null;
distanceTotalM: number | null;
distanceRemainingM: number | null;
elevationM: number | null;
ascentRemainingM: number | null;
positionX: number;
axisUnit: XUnit;
axisTotal: number;
loopIndex: number | null;
smoothedSpeedKph: number;
rollingPowerW: number;
rollingPowerWindowS: number;
avgPowerW: number;
maxPowerW: number;
normalisedPowerW: number | null;
avgCadenceRpm: number;
energyKj: number;
}
/** What arrives on `ride://snapshot`. */
export interface RideFrame {
snapshot: RideSnapshot;
derived: Derived;
}
// --- src-tauri/src/profile_view.rs ------------------------------------------
export type Channel = 'gradient' | 'resistance' | 'power';
export interface BlockSummary {
index: number;
kind: 'constant' | 'ramp' | 'wave' | 'segments' | 'terrain';
channel: Channel;
label: string;
startX: number;
endX: number;
unit: XUnit;
}
export interface ProfileView {
name: string;
description: string | null;
looping: boolean;
source: string;
channel: Channel;
xUnit: XUnit;
totalX: number;
totalSeconds: number | null;
totalMetres: number | null;
series: [number, number][];
elevation: [number, number][] | null;
elevationMinM: number | null;
elevationMaxM: number | null;
totalAscentM: number | null;
blocks: BlockSummary[];
yaml: string;
}
// --- src-tauri/src/events.rs -------------------------------------------------
export type RideStatus = 'idle' | 'running' | 'paused' | 'finished';
export interface LapSummary {
index: number;
elapsedMs: number;
distanceM: number;
avgPowerW: number;
}
export interface RideState {
status: RideStatus;
mode: ControlMode;
target: ControlTarget | null;
gradientOffsetPct: number;
manualGradientPct: number;
resistanceLevel: number;
powerTargetW: number;
lap: number;
laps: LapSummary[];
profile: ProfileView | null;
source: string;
}
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
export interface DeviceInfo {
id: string;
name: string;
address: string;
rssi: number;
kind: DeviceKind;
state: ConnectionState;
controlAcquired: boolean;
services: string[];
remembered: boolean;
batteryPct: number | null;
unlockExpiresInS: number | null;
error: string | null;
}
export interface DeviceList {
scanning: boolean;
devices: DeviceInfo[];
}
export interface Notice {
level: 'info' | 'warn' | 'error';
message: string;
}
export interface InputAck {
action: string;
detail: string | null;
}
export interface SampleProfile {
name: string;
summary: string;
text: string;
isGpx: boolean;
}
+67
View File
@@ -0,0 +1,67 @@
/** Shared uPlot styling and sizing helpers. */
import type uPlot from 'uplot';
export const INK_DIM = '#5d6c7d';
export const GRID = '#141a23';
export const FONT = '600 11px Inter, system-ui, sans-serif';
export function axis(overrides: Partial<uPlot.Axis> = {}): uPlot.Axis {
return {
stroke: INK_DIM,
font: FONT,
labelFont: FONT,
ticks: { stroke: GRID, width: 1, size: 4 },
grid: { stroke: GRID, width: 1 },
gap: 4,
...overrides,
};
}
/** Keep a chart sized to its container without a resize storm. */
export function observeSize(el: HTMLElement, apply: (w: number, h: number) => void): () => void {
let frame = 0;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) apply(Math.round(rect.width), Math.round(rect.height));
});
});
ro.observe(el);
return () => {
cancelAnimationFrame(frame);
ro.disconnect();
};
}
/**
* Vertical "you are here" marker, drawn straight onto the canvas after the
* series. Cheaper and steadier than a series with one point.
*/
export function positionMarker(getX: () => number | null, colour: string): uPlot.Plugin {
return {
hooks: {
draw: (u: uPlot) => {
const x = getX();
if (x == null || !Number.isFinite(x)) return;
const left = u.valToPos(x, 'x', true);
if (!Number.isFinite(left)) return;
const ctx = u.ctx;
const top = u.bbox.top;
const bottom = u.bbox.top + u.bbox.height;
ctx.save();
ctx.beginPath();
ctx.strokeStyle = colour;
ctx.lineWidth = Math.max(1, Math.round(devicePixelRatio));
ctx.moveTo(left, top);
ctx.lineTo(left, bottom);
ctx.stroke();
ctx.beginPath();
ctx.fillStyle = colour;
ctx.arc(left, bottom, 4 * devicePixelRatio, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
},
},
};
}
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": false,
"checkJs": false,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": false,
"types": ["svelte", "vite/client"]
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// Tauri drives this dev server; the port is fixed and must match
// src-tauri/tauri.conf.json's devUrl.
export default defineConfig({
plugins: [svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
// Rust sources are rebuilt by cargo, not by vite.
ignored: ['**/src-tauri/**'],
},
},
build: {
target: 'esnext',
sourcemap: false,
chunkSizeWarningLimit: 1200,
},
});