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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,70 @@
|
||||
//! FTMS client and BLE transport. See REQUIREMENTS.md §5.1–5.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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -6,7 +6,14 @@
|
||||
//! Elevation must be smoothed before gradients are derived, and the result
|
||||
//! 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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -54,27 +61,865 @@ pub enum GpxError {
|
||||
/// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`,
|
||||
/// missing `<ele>` on some points, multiple segments, and namespaced documents.
|
||||
pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> {
|
||||
let _ = xml;
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
let doc = roxmltree::Document::parse(xml).map_err(|e| GpxError::Malformed(e.to_string()))?;
|
||||
|
||||
// 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.
|
||||
pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
|
||||
let _ = (a, b);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
let lat1 = a.lat_deg.to_radians();
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// 6–8% climb and −4.5% descent.
|
||||
pub fn to_terrain(
|
||||
points: &[TrackPoint],
|
||||
cfg: &SmoothingConfig,
|
||||
) -> Result<Vec<TerrainPoint>, GpxError> {
|
||||
let _ = (points, cfg);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
if points.len() < 2 {
|
||||
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.
|
||||
pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> {
|
||||
let _ = (xml, name, cfg);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
let points = parse(xml)?;
|
||||
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 6–8%
|
||||
/// (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
@@ -23,6 +23,24 @@ pub const GRAVITY: f32 = 9.80665;
|
||||
/// below which the rider is considered stopped.
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct PhysicsState {
|
||||
@@ -41,8 +59,50 @@ impl PhysicsState {
|
||||
/// rather than snapping to it — and must never produce negative speed,
|
||||
/// NaN, or unbounded values for any finite input.
|
||||
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
|
||||
let _ = (power_w, gradient_pct, cfg, dt);
|
||||
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
|
||||
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
|
||||
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 {
|
||||
@@ -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
|
||||
/// propulsive and resistive forces balance. Useful for tests and for sanity
|
||||
/// 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 {
|
||||
let _ = (power_w, gradient_pct, cfg);
|
||||
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
|
||||
let forces = Forces::new(power_w, gradient_pct, cfg);
|
||||
|
||||
// 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
File diff suppressed because it is too large
Load Diff
+752
-5
@@ -32,6 +32,12 @@ pub enum RideStatus {
|
||||
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 config: RiderConfig,
|
||||
pub limits: SafetyLimits,
|
||||
@@ -41,6 +47,10 @@ pub struct RideSession {
|
||||
profile: Option<Profile>,
|
||||
/// Manual gradient trim applied on top of the profile's gradient.
|
||||
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,
|
||||
last_target: Option<ControlTarget>,
|
||||
}
|
||||
@@ -55,6 +65,8 @@ impl RideSession {
|
||||
physics: PhysicsState::default(),
|
||||
profile: None,
|
||||
gradient_offset_pct: 0.0,
|
||||
manual_resistance: 0,
|
||||
erg_watts: 150,
|
||||
elapsed_ms: 0,
|
||||
last_target: None,
|
||||
}
|
||||
@@ -93,6 +105,50 @@ impl RideSession {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// (treat as zero rather than panicking).
|
||||
pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> {
|
||||
let _ = (telemetry, dt_s);
|
||||
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
|
||||
let mut events = Vec::new();
|
||||
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.
|
||||
pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot {
|
||||
let _ = telemetry;
|
||||
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
|
||||
RideSnapshot {
|
||||
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.
|
||||
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,3 +8,11 @@ license.workspace = true
|
||||
bikecontrol-core = { workspace = true }
|
||||
thiserror = { 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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user