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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+804
View File
@@ -0,0 +1,804 @@
//! What the trainer says it can do, and the gate that stops us asking it for
//! anything else.
//!
//! Two sources:
//!
//! * **Fitness Machine Feature** (`0x2ACC`) — two little-endian uint32
//! bitfields: machine features, then target-setting features. The second is
//! what tells us whether `SetTargetInclination`, `SetTargetResistanceLevel`,
//! `SetTargetPower` and `SetIndoorBikeSimulationParameters` are supported.
//! * **Supported * Range** characteristics — `0x2AD5` inclination, `0x2AD6`
//! resistance level, `0x2AD8` power. Each is `min, max, increment`.
//!
//! Together these satisfy FR-2.6: never send an unsupported or out-of-range
//! command. [`gate_target`] is the single choke point; it is a pure function so
//! the whole of SAF-3 is unit-testable without hardware.
use bikecontrol_core::types::{ControlTarget, SafetyLimits};
use crate::control_point::OpCode;
/// Why a [`ControlTarget`] cannot be sent to this trainer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UnsupportedTarget {
#[error("trainer does not advertise support for {0}")]
OpCodeUnsupported(OpCode),
#[error(
"trainer's supported range for {what} is {min}..={max}, which excludes every value \
permitted by the configured safety limits"
)]
EmptyRange {
what: &'static str,
min: i32,
max: i32,
},
}
/// Decoded Fitness Machine Feature characteristic (`0x2ACC`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FitnessMachineFeature {
/// Fitness Machine Features bitfield (what the machine *measures*).
pub machine: u32,
/// Target Setting Features bitfield (what the machine can be *told*).
pub target: u32,
}
/// Bit positions in the Fitness Machine Features field.
pub mod machine_feature {
pub const AVERAGE_SPEED: u32 = 1 << 0;
pub const CADENCE: u32 = 1 << 1;
pub const TOTAL_DISTANCE: u32 = 1 << 2;
pub const INCLINATION: u32 = 1 << 3;
pub const ELEVATION_GAIN: u32 = 1 << 4;
pub const PACE: u32 = 1 << 5;
pub const STEP_COUNT: u32 = 1 << 6;
pub const RESISTANCE_LEVEL: u32 = 1 << 7;
pub const STRIDE_COUNT: u32 = 1 << 8;
pub const EXPENDED_ENERGY: u32 = 1 << 9;
pub const HEART_RATE_MEASUREMENT: u32 = 1 << 10;
pub const METABOLIC_EQUIVALENT: u32 = 1 << 11;
pub const ELAPSED_TIME: u32 = 1 << 12;
pub const REMAINING_TIME: u32 = 1 << 13;
pub const POWER_MEASUREMENT: u32 = 1 << 14;
pub const FORCE_ON_BELT_AND_POWER_OUTPUT: u32 = 1 << 15;
pub const USER_DATA_RETENTION: u32 = 1 << 16;
}
/// Bit positions in the Target Setting Features field.
pub mod target_feature {
pub const SPEED: u32 = 1 << 0;
pub const INCLINATION: u32 = 1 << 1;
pub const RESISTANCE: u32 = 1 << 2;
pub const POWER: u32 = 1 << 3;
pub const HEART_RATE: u32 = 1 << 4;
pub const EXPENDED_ENERGY: u32 = 1 << 5;
pub const STEP_NUMBER: u32 = 1 << 6;
pub const STRIDE_NUMBER: u32 = 1 << 7;
pub const DISTANCE: u32 = 1 << 8;
pub const TRAINING_TIME: u32 = 1 << 9;
pub const TIME_IN_TWO_HR_ZONES: u32 = 1 << 10;
pub const TIME_IN_THREE_HR_ZONES: u32 = 1 << 11;
pub const TIME_IN_FIVE_HR_ZONES: u32 = 1 << 12;
/// Bit 13 — `SetIndoorBikeSimulationParameters` (`0x11`). Open question A-1.
pub const INDOOR_BIKE_SIMULATION: u32 = 1 << 13;
pub const WHEEL_CIRCUMFERENCE: u32 = 1 << 14;
pub const SPIN_DOWN: u32 = 1 << 15;
pub const CADENCE: u32 = 1 << 16;
}
/// A characteristic (`0x2ACC` etc.) was shorter than its definition requires.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("{what} characteristic is {len} bytes; {need} are required")]
pub struct FieldTooShort {
pub what: &'static str,
pub len: usize,
pub need: usize,
}
impl FitnessMachineFeature {
/// Decode the 8-byte Fitness Machine Feature characteristic.
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
if data.len() < 8 {
return Err(FieldTooShort {
what: "Fitness Machine Feature",
len: data.len(),
need: 8,
});
}
Ok(Self {
machine: u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
target: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
})
}
pub fn has_machine(self, bit: u32) -> bool {
self.machine & bit != 0
}
pub fn has_target(self, bit: u32) -> bool {
self.target & bit != 0
}
pub fn supports_inclination_target(self) -> bool {
self.has_target(target_feature::INCLINATION)
}
pub fn supports_resistance_target(self) -> bool {
self.has_target(target_feature::RESISTANCE)
}
pub fn supports_power_target(self) -> bool {
self.has_target(target_feature::POWER)
}
/// A-1: whether `0x11` is advertised. Advertised support and *actual*
/// support are not the same thing — `probe set` writes the op code to find
/// out for certain.
pub fn supports_simulation(self) -> bool {
self.has_target(target_feature::INDOOR_BIKE_SIMULATION)
}
/// Human-readable list of set machine-feature bits, for the probe CLI.
pub fn machine_feature_names(self) -> Vec<&'static str> {
use machine_feature as m;
let table: [(u32, &'static str); 17] = [
(m::AVERAGE_SPEED, "Average Speed"),
(m::CADENCE, "Cadence"),
(m::TOTAL_DISTANCE, "Total Distance"),
(m::INCLINATION, "Inclination"),
(m::ELEVATION_GAIN, "Elevation Gain"),
(m::PACE, "Pace"),
(m::STEP_COUNT, "Step Count"),
(m::RESISTANCE_LEVEL, "Resistance Level"),
(m::STRIDE_COUNT, "Stride Count"),
(m::EXPENDED_ENERGY, "Expended Energy"),
(m::HEART_RATE_MEASUREMENT, "Heart Rate Measurement"),
(m::METABOLIC_EQUIVALENT, "Metabolic Equivalent"),
(m::ELAPSED_TIME, "Elapsed Time"),
(m::REMAINING_TIME, "Remaining Time"),
(m::POWER_MEASUREMENT, "Power Measurement"),
(m::FORCE_ON_BELT_AND_POWER_OUTPUT, "Force on Belt and Power Output"),
(m::USER_DATA_RETENTION, "User Data Retention"),
];
table
.iter()
.filter(|(bit, _)| self.machine & bit != 0)
.map(|(_, name)| *name)
.collect()
}
/// Human-readable list of set target-setting bits, for the probe CLI.
pub fn target_feature_names(self) -> Vec<&'static str> {
use target_feature as t;
let table: [(u32, &'static str); 17] = [
(t::SPEED, "Speed Target Setting"),
(t::INCLINATION, "Inclination Target Setting (0x03)"),
(t::RESISTANCE, "Resistance Target Setting (0x04)"),
(t::POWER, "Power Target Setting (0x05)"),
(t::HEART_RATE, "Heart Rate Target Setting"),
(t::EXPENDED_ENERGY, "Targeted Expended Energy Configuration"),
(t::STEP_NUMBER, "Targeted Step Number Configuration"),
(t::STRIDE_NUMBER, "Targeted Stride Number Configuration"),
(t::DISTANCE, "Targeted Distance Configuration"),
(t::TRAINING_TIME, "Targeted Training Time Configuration"),
(t::TIME_IN_TWO_HR_ZONES, "Targeted Time in Two HR Zones"),
(t::TIME_IN_THREE_HR_ZONES, "Targeted Time in Three HR Zones"),
(t::TIME_IN_FIVE_HR_ZONES, "Targeted Time in Five HR Zones"),
(t::INDOOR_BIKE_SIMULATION, "Indoor Bike Simulation Parameters (0x11)"),
(t::WHEEL_CIRCUMFERENCE, "Wheel Circumference Configuration"),
(t::SPIN_DOWN, "Spin Down Control"),
(t::CADENCE, "Targeted Cadence Configuration"),
];
table
.iter()
.filter(|(bit, _)| self.target & bit != 0)
.map(|(_, name)| *name)
.collect()
}
}
/// Supported Resistance Level Range (`0x2AD6`): sint16 min, sint16 max,
/// uint16 increment.
///
/// The spec assigns these a resolution of 0.1, but resistance level is a
/// trainer-specific unit and the D100 reference works in raw integers capped at
/// 100. We therefore keep the **raw** values as authoritative for clamping —
/// they are in the same units as [`ControlTarget::Resistance`] and
/// [`crate::control_point::set_target_resistance`] — and expose the 0.1-scaled
/// interpretation separately for display. **Needs hardware verification**
/// (TASK-1) to know which the D100 actually means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResistanceLevelRange {
pub min: i16,
pub max: i16,
pub increment: u16,
}
impl ResistanceLevelRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (min, max, increment) = decode_range("Supported Resistance Level Range", data)?;
Ok(Self {
min,
max,
increment,
})
}
/// The spec-scaled interpretation (0.1 units), for display only.
pub fn scaled(&self) -> (f32, f32, f32) {
(
self.min as f32 * 0.1,
self.max as f32 * 0.1,
self.increment as f32 * 0.1,
)
}
}
/// Supported Power Range (`0x2AD8`): sint16 min W, sint16 max W, uint16
/// increment W. Resolution 1 W.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PowerRange {
pub min_w: i16,
pub max_w: i16,
pub increment_w: u16,
}
impl PowerRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (min_w, max_w, increment_w) = decode_range("Supported Power Range", data)?;
Ok(Self {
min_w,
max_w,
increment_w,
})
}
}
/// Supported Inclination Range (`0x2AD5`): sint16 min, sint16 max, uint16
/// increment, all with 0.1% resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InclinationRange {
raw_min: i16,
raw_max: i16,
raw_increment: u16,
}
impl InclinationRange {
pub fn decode(data: &[u8]) -> Result<Self, FieldTooShort> {
let (raw_min, raw_max, raw_increment) =
decode_range("Supported Inclination Range", data)?;
Ok(Self {
raw_min,
raw_max,
raw_increment,
})
}
pub fn min_percent(&self) -> f32 {
self.raw_min as f32 * 0.1
}
pub fn max_percent(&self) -> f32 {
self.raw_max as f32 * 0.1
}
pub fn increment_percent(&self) -> f32 {
self.raw_increment as f32 * 0.1
}
}
fn decode_range(what: &'static str, data: &[u8]) -> Result<(i16, i16, u16), FieldTooShort> {
if data.len() < 6 {
return Err(FieldTooShort {
what,
len: data.len(),
need: 6,
});
}
Ok((
i16::from_le_bytes([data[0], data[1]]),
i16::from_le_bytes([data[2], data[3]]),
u16::from_le_bytes([data[4], data[5]]),
))
}
/// Everything the trainer told us about itself. Any field may be `None` if the
/// corresponding characteristic is absent or unreadable — a trainer is not
/// required to expose the optional range characteristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TrainerCapabilities {
pub feature: Option<FitnessMachineFeature>,
pub resistance_range: Option<ResistanceLevelRange>,
pub power_range: Option<PowerRange>,
pub inclination_range: Option<InclinationRange>,
}
impl TrainerCapabilities {
/// Whether the op code used for a given target is advertised.
///
/// If the Fitness Machine Feature characteristic could not be read we
/// return `true` — refusing to control a trainer that simply did not expose
/// `0x2ACC` would be worse than trying and reading the error response, and
/// the response indication (FR-2.7) is the real backstop.
pub fn supports(&self, target: &ControlTarget, use_simulation: bool) -> bool {
let Some(f) = self.feature else {
return true;
};
match target {
ControlTarget::Gradient { .. } => {
if use_simulation {
f.supports_simulation()
} else {
f.supports_inclination_target()
}
}
ControlTarget::Resistance { .. } => f.supports_resistance_target(),
ControlTarget::Power { .. } => f.supports_power_target(),
}
}
/// The op code that would carry this target.
pub fn op_code_for(target: &ControlTarget, use_simulation: bool) -> OpCode {
match target {
ControlTarget::Gradient { .. } if use_simulation => {
OpCode::SetIndoorBikeSimulationParameters
}
ControlTarget::Gradient { .. } => OpCode::SetTargetInclination,
ControlTarget::Resistance { .. } => OpCode::SetTargetResistanceLevel,
ControlTarget::Power { .. } => OpCode::SetTargetPower,
}
}
}
/// Clamp `target` first to the configured [`SafetyLimits`] (SAF-3) and then to
/// the range the trainer itself reported (FR-2.6, SAF-3), and reject it
/// outright if the op code is not advertised.
///
/// This is the **only** function the client uses to prepare a target for
/// transmission. Everything else — profiles, waveforms, the D-pad, the UI —
/// funnels through here, which is what makes "clamp at the point of
/// transmission" true rather than aspirational.
pub fn gate_target(
limits: &SafetyLimits,
caps: &TrainerCapabilities,
target: ControlTarget,
use_simulation: bool,
) -> Result<ControlTarget, UnsupportedTarget> {
if !caps.supports(&target, use_simulation) {
return Err(UnsupportedTarget::OpCodeUnsupported(
TrainerCapabilities::op_code_for(&target, use_simulation),
));
}
// 1. Configured safety limits.
let target = limits.clamp(target);
// 2. The trainer's own reported range, where it gave us one.
let target = match target {
ControlTarget::Gradient { percent } => {
if let Some(r) = caps.inclination_range {
let (lo, hi) = (r.min_percent(), r.max_percent());
if lo > hi {
return Err(UnsupportedTarget::EmptyRange {
what: "inclination",
min: r.raw_min as i32,
max: r.raw_max as i32,
});
}
ControlTarget::Gradient {
percent: percent.clamp(lo, hi),
}
} else {
ControlTarget::Gradient { percent }
}
}
ControlTarget::Resistance { level } => {
if let Some(r) = caps.resistance_range {
if r.min > r.max {
return Err(UnsupportedTarget::EmptyRange {
what: "resistance level",
min: r.min as i32,
max: r.max as i32,
});
}
ControlTarget::Resistance {
level: level.clamp(r.min, r.max),
}
} else {
ControlTarget::Resistance { level }
}
}
ControlTarget::Power { watts } => {
if let Some(r) = caps.power_range {
if r.min_w > r.max_w {
return Err(UnsupportedTarget::EmptyRange {
what: "power",
min: r.min_w as i32,
max: r.max_w as i32,
});
}
// ControlTarget::Power is u16; the range is sint16. Negative
// minima are meaningless for a trainer, so floor at zero.
let lo = r.min_w.max(0) as u16;
let hi = r.max_w.max(0) as u16;
ControlTarget::Power {
watts: watts.clamp(lo, hi),
}
} else {
ControlTarget::Power { watts }
}
}
};
Ok(target)
}
/// Encode a gated target into control point bytes.
///
/// `use_simulation` selects `0x11` over `0x03` for gradient; `sim_template`
/// supplies the rolling/aero coefficients that accompany the grade.
pub fn encode_target(
target: ControlTarget,
use_simulation: bool,
sim_template: crate::control_point::SimulationParameters,
) -> (OpCode, Vec<u8>) {
use crate::control_point as cp;
match target {
ControlTarget::Gradient { percent } if use_simulation => (
OpCode::SetIndoorBikeSimulationParameters,
cp::set_simulation_parameters(cp::SimulationParameters {
grade_percent: percent,
..sim_template
}),
),
ControlTarget::Gradient { percent } => (
OpCode::SetTargetInclination,
cp::set_target_inclination(percent),
),
ControlTarget::Resistance { level } => (
OpCode::SetTargetResistanceLevel,
cp::set_target_resistance(level),
),
ControlTarget::Power { watts } => (
OpCode::SetTargetPower,
// watts is u16 but the wire format is sint16; the gate has already
// clamped it to the trainer's range, and `min(i16::MAX)` keeps a
// pathological value from wrapping negative.
cp::set_target_power(watts.min(i16::MAX as u16) as i16),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feature(machine: u32, target: u32) -> FitnessMachineFeature {
FitnessMachineFeature { machine, target }
}
#[test]
fn feature_decodes_two_little_endian_u32s() {
// machine = 0x00000086, target = 0x0000200C
let bytes = [0x86, 0x00, 0x00, 0x00, 0x0c, 0x20, 0x00, 0x00];
let f = FitnessMachineFeature::decode(&bytes).unwrap();
assert_eq!(f.machine, 0x0000_0086);
assert_eq!(f.target, 0x0000_200C);
assert!(f.has_machine(machine_feature::CADENCE));
assert!(f.has_machine(machine_feature::RESISTANCE_LEVEL));
assert!(!f.has_machine(machine_feature::AVERAGE_SPEED));
assert!(f.supports_resistance_target());
assert!(f.supports_power_target());
assert!(f.supports_simulation());
assert!(!f.supports_inclination_target());
}
#[test]
fn feature_decode_rejects_short_data() {
assert_eq!(
FitnessMachineFeature::decode(&[0u8; 7]).unwrap_err(),
FieldTooShort {
what: "Fitness Machine Feature",
len: 7,
need: 8
}
);
}
#[test]
fn feature_extra_trailing_bytes_are_tolerated() {
let f = FitnessMachineFeature::decode(&[0x02, 0, 0, 0, 0x04, 0, 0, 0, 0xff]).unwrap();
assert_eq!(f.machine, 2);
assert_eq!(f.target, 4);
}
#[test]
fn feature_name_lists() {
let f = feature(
machine_feature::CADENCE | machine_feature::POWER_MEASUREMENT,
target_feature::RESISTANCE | target_feature::INDOOR_BIKE_SIMULATION,
);
assert_eq!(
f.machine_feature_names(),
vec!["Cadence", "Power Measurement"]
);
assert_eq!(
f.target_feature_names(),
vec![
"Resistance Target Setting (0x04)",
"Indoor Bike Simulation Parameters (0x11)"
]
);
}
#[test]
fn resistance_range_decodes() {
// min 0, max 100, increment 1
let r = ResistanceLevelRange::decode(&[0x00, 0x00, 0x64, 0x00, 0x01, 0x00]).unwrap();
assert_eq!(
r,
ResistanceLevelRange {
min: 0,
max: 100,
increment: 1
}
);
let (lo, hi, inc) = r.scaled();
assert_eq!((lo, hi, inc), (0.0, 10.0, 0.1));
}
#[test]
fn resistance_range_handles_negative_minimum() {
let r = ResistanceLevelRange::decode(&[0xf6, 0xff, 0x64, 0x00, 0x02, 0x00]).unwrap();
assert_eq!(r.min, -10);
assert_eq!(r.max, 100);
assert_eq!(r.increment, 2);
}
#[test]
fn power_and_inclination_ranges_decode() {
let p = PowerRange::decode(&[0x32, 0x00, 0x58, 0x02, 0x01, 0x00]).unwrap();
assert_eq!(p.min_w, 50);
assert_eq!(p.max_w, 600);
assert_eq!(p.increment_w, 1);
// -10.0% .. +20.0%, 0.5% increment
let i = InclinationRange::decode(&[0x9c, 0xff, 0xc8, 0x00, 0x05, 0x00]).unwrap();
assert_eq!(i.min_percent(), -10.0);
assert_eq!(i.max_percent(), 20.0);
assert_eq!(i.increment_percent(), 0.5);
}
#[test]
fn range_decode_rejects_short_data() {
assert!(ResistanceLevelRange::decode(&[0, 0, 0, 0, 0]).is_err());
assert!(PowerRange::decode(&[]).is_err());
assert!(InclinationRange::decode(&[1, 2, 3]).is_err());
}
// -- gate_target -------------------------------------------------------
fn caps_all() -> TrainerCapabilities {
TrainerCapabilities {
feature: Some(feature(
0,
target_feature::INCLINATION
| target_feature::RESISTANCE
| target_feature::POWER
| target_feature::INDOOR_BIKE_SIMULATION,
)),
resistance_range: Some(ResistanceLevelRange {
min: 0,
max: 100,
increment: 1,
}),
power_range: Some(PowerRange {
min_w: 50,
max_w: 600,
increment_w: 1,
}),
inclination_range: Some(InclinationRange {
raw_min: -100,
raw_max: 200,
raw_increment: 5,
}),
}
}
#[test]
fn gate_passes_an_in_range_target_unchanged() {
let l = SafetyLimits::default();
let c = caps_all();
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap(),
ControlTarget::Power { watts: 200 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 3.5 }, false).unwrap(),
ControlTarget::Gradient { percent: 3.5 }
);
}
#[test]
fn gate_clamps_to_safety_limits() {
let l = SafetyLimits::default(); // -10..15 %, 0..100 res, 50..600 W
let c = TrainerCapabilities::default(); // trainer told us nothing
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 99.0 }, false).unwrap(),
ControlTarget::Gradient { percent: 15.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: -99.0 }, false).unwrap(),
ControlTarget::Gradient { percent: -10.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 5000 }, false).unwrap(),
ControlTarget::Power { watts: 600 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 500 }, false).unwrap(),
ControlTarget::Resistance { level: 100 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: -500 }, false).unwrap(),
ControlTarget::Resistance { level: 0 }
);
}
/// The trainer's reported range is *narrower* than the safety limits, so it
/// must win. This is FR-2.6.
#[test]
fn gate_clamps_to_the_trainers_narrower_range() {
let l = SafetyLimits {
min_gradient_pct: -25.0,
max_gradient_pct: 25.0,
min_resistance: -200,
max_resistance: 200,
min_power_w: 0,
max_power_w: 2000,
};
let c = caps_all(); // incl -10..20 %, res 0..100, power 50..600 W
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 24.0 }, false).unwrap(),
ControlTarget::Gradient { percent: 20.0 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 150 }, false).unwrap(),
ControlTarget::Resistance { level: 100 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 1500 }, false).unwrap(),
ControlTarget::Power { watts: 600 }
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 10 }, false).unwrap(),
ControlTarget::Power { watts: 50 }
);
}
#[test]
fn gate_rejects_unadvertised_op_codes() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
feature: Some(feature(0, target_feature::RESISTANCE)),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetPower)
);
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, false).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetInclination)
);
assert!(gate_target(&l, &c, ControlTarget::Resistance { level: 5 }, false).is_ok());
}
/// A-1 in gate form: if the trainer does not advertise bit 13, asking for
/// gradient in simulation mode is refused before anything is transmitted.
#[test]
fn gate_rejects_simulation_mode_when_unadvertised() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
feature: Some(feature(0, target_feature::INCLINATION)),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, true).unwrap_err(),
UnsupportedTarget::OpCodeUnsupported(OpCode::SetIndoorBikeSimulationParameters)
);
// ...but plain inclination is fine.
assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, false).is_ok());
}
#[test]
fn gate_allows_everything_when_the_feature_characteristic_is_missing() {
let l = SafetyLimits::default();
let c = TrainerCapabilities::default();
assert!(gate_target(&l, &c, ControlTarget::Power { watts: 100 }, false).is_ok());
assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, true).is_ok());
}
#[test]
fn gate_rejects_an_inverted_range() {
let l = SafetyLimits::default();
let c = TrainerCapabilities {
resistance_range: Some(ResistanceLevelRange {
min: 50,
max: 10,
increment: 1,
}),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Resistance { level: 20 }, false).unwrap_err(),
UnsupportedTarget::EmptyRange {
what: "resistance level",
min: 50,
max: 10
}
);
}
#[test]
fn gate_handles_a_negative_power_minimum() {
let l = SafetyLimits {
min_power_w: 0,
..SafetyLimits::default()
};
let c = TrainerCapabilities {
power_range: Some(PowerRange {
min_w: -100,
max_w: 400,
increment_w: 1,
}),
..Default::default()
};
assert_eq!(
gate_target(&l, &c, ControlTarget::Power { watts: 0 }, false).unwrap(),
ControlTarget::Power { watts: 0 }
);
}
// -- encode_target -----------------------------------------------------
#[test]
fn encode_target_picks_the_right_op_code_and_bytes() {
let sim = crate::control_point::SimulationParameters::default();
assert_eq!(
encode_target(ControlTarget::Gradient { percent: 5.0 }, false, sim),
(OpCode::SetTargetInclination, vec![0x03, 0x32, 0x00])
);
assert_eq!(
encode_target(ControlTarget::Resistance { level: 40 }, false, sim),
(OpCode::SetTargetResistanceLevel, vec![0x04, 0x28, 0x00])
);
assert_eq!(
encode_target(ControlTarget::Power { watts: 250 }, false, sim),
(OpCode::SetTargetPower, vec![0x05, 0xfa, 0x00])
);
}
#[test]
fn encode_target_in_simulation_mode_carries_the_grade() {
let sim = crate::control_point::SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 999.0, // must be overridden by the target
crr: 0.004,
wind_resistance_coefficient: 0.51,
};
let (op, bytes) = encode_target(ControlTarget::Gradient { percent: 4.5 }, true, sim);
assert_eq!(op, OpCode::SetIndoorBikeSimulationParameters);
assert_eq!(bytes, vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]);
}
#[test]
fn encode_target_power_never_wraps_negative() {
let sim = crate::control_point::SimulationParameters::default();
let (_, bytes) = encode_target(ControlTarget::Power { watts: 60000 }, false, sim);
let raw = i16::from_le_bytes([bytes[1], bytes[2]]);
assert!(raw > 0, "power must not wrap to a negative sint16");
assert_eq!(raw, i16::MAX);
}
}
File diff suppressed because it is too large Load Diff
+488
View File
@@ -0,0 +1,488 @@
//! Encoders and decoders for the FTMS **Fitness Machine Control Point**
//! (`0x2AD9`).
//!
//! The control point is a request/response characteristic: the central writes a
//! procedure (with response), and the fitness machine replies with an
//! **indication** of the form `[0x80, request_op_code, result_code, ...params]`.
//! Treating writes as fire-and-forget is a bug (FR-2.7) — a trainer that has
//! not granted control will silently ignore everything until `RequestControl`
//! succeeds, and there is no other way to find that out.
//!
//! The op-code table and the sint16 encodings for inclination, resistance and
//! target power were ported from `obostjancic/smart-trainer-control`
//! (`src/lib/bike/ftms-control.ts`), MIT licensed, Copyright (c) 2025 Ogi —
//! a working Van Rysel D100 client. See REQUIREMENTS.md §3.2.
/// FTMS Fitness Machine Control Point op codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum OpCode {
RequestControl = 0x00,
Reset = 0x01,
SetTargetSpeed = 0x02,
SetTargetInclination = 0x03,
SetTargetResistanceLevel = 0x04,
SetTargetPower = 0x05,
SetTargetHeartRate = 0x06,
StartOrResume = 0x07,
StopOrPause = 0x08,
SetTargetedExpendedEnergy = 0x09,
SetTargetedNumberOfSteps = 0x0A,
SetTargetedNumberOfStrides = 0x0B,
SetTargetedDistance = 0x0C,
SetTargetedTrainingTime = 0x0D,
SetTargetedTimeInTwoHeartRateZones = 0x0E,
SetTargetedTimeInThreeHeartRateZones = 0x0F,
SetTargetedTimeInFiveHeartRateZones = 0x10,
SetIndoorBikeSimulationParameters = 0x11,
SetWheelCircumference = 0x12,
SetSpinDownControl = 0x13,
SetTargetedCadence = 0x14,
}
impl OpCode {
pub fn from_u8(v: u8) -> Option<Self> {
use OpCode::*;
Some(match v {
0x00 => RequestControl,
0x01 => Reset,
0x02 => SetTargetSpeed,
0x03 => SetTargetInclination,
0x04 => SetTargetResistanceLevel,
0x05 => SetTargetPower,
0x06 => SetTargetHeartRate,
0x07 => StartOrResume,
0x08 => StopOrPause,
0x09 => SetTargetedExpendedEnergy,
0x0A => SetTargetedNumberOfSteps,
0x0B => SetTargetedNumberOfStrides,
0x0C => SetTargetedDistance,
0x0D => SetTargetedTrainingTime,
0x0E => SetTargetedTimeInTwoHeartRateZones,
0x0F => SetTargetedTimeInThreeHeartRateZones,
0x10 => SetTargetedTimeInFiveHeartRateZones,
0x11 => SetIndoorBikeSimulationParameters,
0x12 => SetWheelCircumference,
0x13 => SetSpinDownControl,
0x14 => SetTargetedCadence,
_ => return None,
})
}
pub fn as_u8(self) -> u8 {
self as u8
}
}
impl std::fmt::Display for OpCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?} (0x{:02x})", self.as_u8())
}
}
/// The first byte of every control point indication.
pub const RESPONSE_CODE: u8 = 0x80;
/// Result codes carried in a control point indication.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultCode {
Success,
OpCodeNotSupported,
InvalidParameter,
OperationFailed,
ControlNotPermitted,
/// Reserved or vendor-specific.
Unknown(u8),
}
impl ResultCode {
pub fn from_u8(v: u8) -> Self {
match v {
0x01 => ResultCode::Success,
0x02 => ResultCode::OpCodeNotSupported,
0x03 => ResultCode::InvalidParameter,
0x04 => ResultCode::OperationFailed,
0x05 => ResultCode::ControlNotPermitted,
other => ResultCode::Unknown(other),
}
}
pub fn as_u8(self) -> u8 {
match self {
ResultCode::Success => 0x01,
ResultCode::OpCodeNotSupported => 0x02,
ResultCode::InvalidParameter => 0x03,
ResultCode::OperationFailed => 0x04,
ResultCode::ControlNotPermitted => 0x05,
ResultCode::Unknown(v) => v,
}
}
pub fn is_success(self) -> bool {
matches!(self, ResultCode::Success)
}
}
impl std::fmt::Display for ResultCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResultCode::Success => write!(f, "Success"),
ResultCode::OpCodeNotSupported => write!(f, "Op Code not supported"),
ResultCode::InvalidParameter => write!(f, "Invalid parameter"),
ResultCode::OperationFailed => write!(f, "Operation failed"),
ResultCode::ControlNotPermitted => {
write!(f, "Control not permitted (RequestControl first)")
}
ResultCode::Unknown(v) => write!(f, "Unknown result code 0x{v:02x}"),
}
}
}
/// A decoded control point indication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ControlPointResponse {
/// The op code this response refers to, if it is one we know.
pub request_op_code: Option<OpCode>,
/// The raw op code byte, even when unrecognised.
pub raw_request_op_code: u8,
pub result: ResultCode,
/// Response parameters, present only for some procedures.
pub parameters: Vec<u8>,
}
impl ControlPointResponse {
pub fn is_success(&self) -> bool {
self.result.is_success()
}
}
/// Why a control point indication could not be decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ResponseError {
#[error("control point indication is {len} bytes; at least 3 are required")]
TooShort { len: usize },
#[error("control point indication started with 0x{first:02x}, expected 0x80")]
NotAResponse { first: u8 },
}
/// Decode a control point indication. Pure function; no hardware needed.
pub fn decode_response(data: &[u8]) -> Result<ControlPointResponse, ResponseError> {
if data.len() < 3 {
return Err(ResponseError::TooShort { len: data.len() });
}
if data[0] != RESPONSE_CODE {
return Err(ResponseError::NotAResponse { first: data[0] });
}
Ok(ControlPointResponse {
request_op_code: OpCode::from_u8(data[1]),
raw_request_op_code: data[1],
result: ResultCode::from_u8(data[2]),
parameters: data[3..].to_vec(),
})
}
// ---------------------------------------------------------------------------
// Request encoders
// ---------------------------------------------------------------------------
/// `StopOrPause` (`0x08`) takes a one-byte control parameter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopOrPause {
Stop = 0x01,
Pause = 0x02,
}
/// Parameters for `SetIndoorBikeSimulationParameters` (`0x11`).
///
/// Whether the D100 accepts this at all is open question **A-1** — the MIT
/// reference drives grade with `SetTargetInclination` (`0x03`) instead. The
/// `probe set` subcommand exists to resolve it against real hardware.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulationParameters {
/// Wind speed, m/s. Encoded as sint16 with 0.001 resolution.
pub wind_speed_mps: f32,
/// Grade, percent. Encoded as sint16 with 0.01 resolution.
pub grade_percent: f32,
/// Coefficient of rolling resistance. Encoded as uint8 with 0.0001
/// resolution (so the representable range is 0.0000 0.0255).
pub crr: f32,
/// Wind resistance coefficient, kg/m. Encoded as uint8 with 0.01
/// resolution (representable range 0.00 2.55).
pub wind_resistance_coefficient: f32,
}
impl Default for SimulationParameters {
fn default() -> Self {
Self {
wind_speed_mps: 0.0,
grade_percent: 0.0,
crr: 0.004,
wind_resistance_coefficient: 0.51,
}
}
}
/// `[0x00]` — take control of the machine. Must succeed before any target
/// setting procedure is accepted (FR-2.1).
pub fn request_control() -> Vec<u8> {
vec![OpCode::RequestControl.as_u8()]
}
/// `[0x01]` — reset the machine to its default state. Part of the shutdown
/// safety sequence (SAF-2).
pub fn reset() -> Vec<u8> {
vec![OpCode::Reset.as_u8()]
}
/// `[0x07]` — start or resume.
pub fn start_or_resume() -> Vec<u8> {
vec![OpCode::StartOrResume.as_u8()]
}
/// `[0x08, param]` — stop or pause.
pub fn stop_or_pause(what: StopOrPause) -> Vec<u8> {
vec![OpCode::StopOrPause.as_u8(), what as u8]
}
/// `[0x03, sint16 LE]` — target inclination in percent, 0.1 resolution.
///
/// Values outside the sint16 range after scaling are saturated rather than
/// wrapped: wrapping a +400% mistake into a large negative grade would be a
/// safety hazard.
pub fn set_target_inclination(percent: f32) -> Vec<u8> {
let raw = scale_to_i16(percent, 10.0);
let mut v = vec![OpCode::SetTargetInclination.as_u8()];
v.extend_from_slice(&raw.to_le_bytes());
v
}
/// `[0x04, sint16 LE]` — target resistance level, in the trainer's own units.
///
/// Note: the FTMS specification defines this parameter as a *uint8* with 0.1
/// resolution, but the working D100 reference implementation (§3.2) sends a
/// sint16 and the trainer accepts it. We follow the reference, since it is the
/// only behaviour confirmed on this hardware. **Needs hardware confirmation**
/// if a different trainer is ever targeted.
pub fn set_target_resistance(level: i16) -> Vec<u8> {
let mut v = vec![OpCode::SetTargetResistanceLevel.as_u8()];
v.extend_from_slice(&level.to_le_bytes());
v
}
/// `[0x05, sint16 LE]` — target power in watts.
pub fn set_target_power(watts: i16) -> Vec<u8> {
let mut v = vec![OpCode::SetTargetPower.as_u8()];
v.extend_from_slice(&watts.to_le_bytes());
v
}
/// `[0x11, sint16 wind, sint16 grade, uint8 crr, uint8 cw]` — indoor bike
/// simulation parameters. See [`SimulationParameters`] and open question A-1.
pub fn set_simulation_parameters(p: SimulationParameters) -> Vec<u8> {
let wind = scale_to_i16(p.wind_speed_mps, 1000.0);
let grade = scale_to_i16(p.grade_percent, 100.0);
let crr = scale_to_u8(p.crr, 10_000.0);
let cw = scale_to_u8(p.wind_resistance_coefficient, 100.0);
let mut v = vec![OpCode::SetIndoorBikeSimulationParameters.as_u8()];
v.extend_from_slice(&wind.to_le_bytes());
v.extend_from_slice(&grade.to_le_bytes());
v.push(crr);
v.push(cw);
v
}
/// Scale a physical value by `factor` and saturate into sint16.
fn scale_to_i16(value: f32, factor: f32) -> i16 {
if !value.is_finite() {
return 0;
}
let scaled = (value * factor).round();
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}
/// Scale a physical value by `factor` and saturate into uint8.
fn scale_to_u8(value: f32, factor: f32) -> u8 {
if !value.is_finite() {
return 0;
}
let scaled = (value * factor).round();
scaled.clamp(0.0, u8::MAX as f32) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_procedures_are_single_bytes() {
assert_eq!(request_control(), vec![0x00]);
assert_eq!(reset(), vec![0x01]);
assert_eq!(start_or_resume(), vec![0x07]);
assert_eq!(stop_or_pause(StopOrPause::Stop), vec![0x08, 0x01]);
assert_eq!(stop_or_pause(StopOrPause::Pause), vec![0x08, 0x02]);
}
#[test]
fn inclination_is_sint16_tenths_of_a_percent_little_endian() {
// +5.0% -> 50 -> 0x0032
assert_eq!(set_target_inclination(5.0), vec![0x03, 0x32, 0x00]);
// 0% -> 0
assert_eq!(set_target_inclination(0.0), vec![0x03, 0x00, 0x00]);
// -7.5% -> -75 -> 0xFFB5
assert_eq!(set_target_inclination(-7.5), vec![0x03, 0xb5, 0xff]);
// Rounding, not truncation.
assert_eq!(set_target_inclination(1.26), vec![0x03, 0x0d, 0x00]);
}
#[test]
fn inclination_saturates_instead_of_wrapping() {
// 5000% * 10 = 50000, past i16::MAX. Must clamp to +32767, not wrap to
// a large negative grade.
assert_eq!(set_target_inclination(5000.0), vec![0x03, 0xff, 0x7f]);
assert_eq!(set_target_inclination(-5000.0), vec![0x03, 0x00, 0x80]);
assert_eq!(set_target_inclination(f32::NAN), vec![0x03, 0x00, 0x00]);
assert_eq!(set_target_inclination(f32::INFINITY), vec![0x03, 0x00, 0x00]);
}
#[test]
fn resistance_is_sint16_little_endian() {
assert_eq!(set_target_resistance(0), vec![0x04, 0x00, 0x00]);
assert_eq!(set_target_resistance(50), vec![0x04, 0x32, 0x00]);
assert_eq!(set_target_resistance(100), vec![0x04, 0x64, 0x00]);
assert_eq!(set_target_resistance(-1), vec![0x04, 0xff, 0xff]);
}
#[test]
fn power_is_sint16_watts_little_endian() {
assert_eq!(set_target_power(200), vec![0x05, 0xc8, 0x00]);
assert_eq!(set_target_power(600), vec![0x05, 0x58, 0x02]);
// Negative target power is nonsense but must still encode as sint16
// rather than panic; the safety gate is what prevents it being sent.
assert_eq!(set_target_power(-10), vec![0x05, 0xf6, 0xff]);
}
#[test]
fn simulation_parameters_layout() {
let p = SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 4.5, // * 100 = 450 = 0x01C2
crr: 0.004, // * 10000 = 40 = 0x28
wind_resistance_coefficient: 0.51, // * 100 = 51 = 0x33
};
assert_eq!(
set_simulation_parameters(p),
vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]
);
assert_eq!(set_simulation_parameters(p).len(), 7);
}
#[test]
fn simulation_parameters_negative_grade_and_wind() {
let p = SimulationParameters {
wind_speed_mps: -1.5, // * 1000 = -1500 = 0xFA24
grade_percent: -8.0, // * 100 = -800 = 0xFCE0
crr: 0.0,
wind_resistance_coefficient: 0.0,
};
assert_eq!(
set_simulation_parameters(p),
vec![0x11, 0x24, 0xfa, 0xe0, 0xfc, 0x00, 0x00]
);
}
#[test]
fn simulation_parameter_bytes_saturate() {
let p = SimulationParameters {
wind_speed_mps: 0.0,
grade_percent: 0.0,
crr: 1.0, // * 10000 = 10000, way past u8
wind_resistance_coefficient: -5.0, // negative, clamps to 0
};
let b = set_simulation_parameters(p);
assert_eq!(b[5], 0xff);
assert_eq!(b[6], 0x00);
}
#[test]
fn response_decoding() {
let r = decode_response(&[0x80, 0x05, 0x01]).unwrap();
assert_eq!(r.request_op_code, Some(OpCode::SetTargetPower));
assert_eq!(r.result, ResultCode::Success);
assert!(r.is_success());
assert!(r.parameters.is_empty());
}
#[test]
fn response_decoding_reports_every_error_code() {
for (byte, expect) in [
(0x02u8, ResultCode::OpCodeNotSupported),
(0x03, ResultCode::InvalidParameter),
(0x04, ResultCode::OperationFailed),
(0x05, ResultCode::ControlNotPermitted),
(0x77, ResultCode::Unknown(0x77)),
] {
let r = decode_response(&[0x80, 0x11, byte]).unwrap();
assert_eq!(r.result, expect);
assert!(!r.is_success());
}
}
/// A-1: this is exactly the byte sequence that tells us the D100 rejects
/// simulation mode.
#[test]
fn sim_mode_rejection_is_recognisable() {
let r = decode_response(&[0x80, 0x11, 0x02]).unwrap();
assert_eq!(
r.request_op_code,
Some(OpCode::SetIndoorBikeSimulationParameters)
);
assert_eq!(r.result, ResultCode::OpCodeNotSupported);
}
#[test]
fn response_with_parameters() {
let r = decode_response(&[0x80, 0x13, 0x01, 0xaa, 0xbb]).unwrap();
assert_eq!(r.parameters, vec![0xaa, 0xbb]);
}
#[test]
fn unknown_op_code_in_response_is_preserved_not_dropped() {
let r = decode_response(&[0x80, 0xfe, 0x02]).unwrap();
assert_eq!(r.request_op_code, None);
assert_eq!(r.raw_request_op_code, 0xfe);
}
#[test]
fn malformed_responses_are_errors() {
assert_eq!(
decode_response(&[]).unwrap_err(),
ResponseError::TooShort { len: 0 }
);
assert_eq!(
decode_response(&[0x80, 0x05]).unwrap_err(),
ResponseError::TooShort { len: 2 }
);
assert_eq!(
decode_response(&[0x01, 0x05, 0x01]).unwrap_err(),
ResponseError::NotAResponse { first: 0x01 }
);
}
#[test]
fn op_code_round_trips() {
for v in 0x00u8..=0x14 {
let op = OpCode::from_u8(v).expect("all of 0x00..=0x14 are defined");
assert_eq!(op.as_u8(), v);
}
assert_eq!(OpCode::from_u8(0x15), None);
assert_eq!(OpCode::from_u8(0xff), None);
}
#[test]
fn result_code_round_trips() {
for v in 0x00u8..=0xff {
assert_eq!(ResultCode::from_u8(v).as_u8(), v);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Error type for the BLE layer.
//!
//! NFR-4: no BLE dropout, malformed packet or missing characteristic may crash
//! the app. Everything that can go wrong on the wire is a value here, not a
//! panic.
use crate::capabilities::{FieldTooShort, UnsupportedTarget};
use crate::control_point::{OpCode, ResponseError, ResultCode};
use crate::indoor_bike_data::DecodeError;
#[derive(Debug, thiserror::Error)]
pub enum FtmsError {
#[error("bluetooth error: {0}")]
Bluetooth(#[from] btleplug::Error),
#[error("no bluetooth adapter available")]
NoAdapter,
#[error("no device found matching {0} (the trainer may be asleep — pedal it and retry)")]
NotFound(String),
#[error("peripheral does not expose the Fitness Machine Service (0x1826)")]
NotAFitnessMachine,
#[error("the trainer is missing a required characteristic: {0}")]
MissingCharacteristic(&'static str),
#[error("{0} is not writable on this trainer")]
NotWritable(&'static str),
#[error("could not decode Indoor Bike Data: {0}")]
IndoorBikeData(#[from] DecodeError),
#[error("could not decode a control point indication: {0}")]
ControlPointResponse(#[from] ResponseError),
#[error("could not decode a capability characteristic: {0}")]
Capability(#[from] FieldTooShort),
#[error("{0}")]
Unsupported(#[from] UnsupportedTarget),
/// The trainer answered, but with an error. FR-2.7 — this is the whole
/// reason control writes are not fire-and-forget.
#[error("trainer rejected {op}: {result}")]
Rejected { op: OpCode, result: ResultCode },
/// The trainer answered a *different* op code than the one in flight.
#[error("trainer answered op code 0x{got:02x} while 0x{expected:02x} was in flight")]
MismatchedResponse { expected: u8, got: u8 },
#[error("trainer did not acknowledge {op} within {timeout_ms} ms")]
Unacknowledged { op: OpCode, timeout_ms: u64 },
/// SAF-4 — repeated unacknowledged writes stop the control path and the
/// rider must be alerted.
#[error(
"control halted after {failures} consecutive unacknowledged control point writes (SAF-4); \
reconnect the trainer to resume"
)]
ControlHalted { failures: u32 },
#[error("not connected to the trainer")]
NotConnected,
#[error("the FTMS client has shut down")]
ClientShutDown,
#[error("gave up reconnecting after {attempts} attempts: {reason}")]
ReconnectFailed { attempts: u32, reason: String },
}
+520
View File
@@ -0,0 +1,520 @@
//! Decoder for the FTMS **Indoor Bike Data** characteristic (`0x2AD2`).
//!
//! The packet is variable length. A leading little-endian 16-bit flags field
//! declares which fields follow, and the fields must be consumed in *strict*
//! specification order — there is no tagging, so a single mis-ordered or
//! mis-sized field turns everything after it into garbage.
//!
//! **The C1 flag (bit 0) is inverted.** It is named "More Data", and
//! Instantaneous Speed is present when the bit is **clear**. Every other bit is
//! a normal "present when set" flag. This trips up nearly every first
//! implementation; see REQUIREMENTS.md §5.2.
//!
//! Field order and units per the Bluetooth SIG FTMS specification v1.0:
//!
//! | # | Field | Flag | Type | Resolution |
//! |---|-------|------|------|------------|
//! | 1 | Instantaneous Speed | bit 0 **clear** | uint16 | 0.01 km/h |
//! | 2 | Average Speed | bit 1 set | uint16 | 0.01 km/h |
//! | 3 | Instantaneous Cadence | bit 2 set | uint16 | 0.5 rpm |
//! | 4 | Average Cadence | bit 3 set | uint16 | 0.5 rpm |
//! | 5 | Total Distance | bit 4 set | uint24 | 1 m |
//! | 6 | Resistance Level | bit 5 set | sint16 | 1 (unitless) |
//! | 7 | Instantaneous Power | bit 6 set | sint16 | 1 W |
//! | 8 | Average Power | bit 7 set | sint16 | 1 W |
//! | 9 | Total Energy / Energy per Hour / Energy per Minute | bit 8 set | uint16, uint16, uint8 | kcal |
//! | 10 | Heart Rate | bit 9 set | uint8 | 1 bpm |
//! | 11 | Metabolic Equivalent | bit 10 set | uint8 | 0.1 |
//! | 12 | Elapsed Time | bit 11 set | uint16 | 1 s |
//! | 13 | Remaining Time | bit 12 set | uint16 | 1 s |
//!
//! Portions of the field ordering and scaling in this module were ported from
//! `obostjancic/smart-trainer-control` (`src/lib/bike/ftms.ts`), MIT licensed,
//! Copyright (c) 2025 Ogi — a working Van Rysel D100 client. See
//! REQUIREMENTS.md §3.2. This Rust version differs in that a truncated packet
//! is a hard error rather than being silently zero-filled (NFR-4).
use bikecontrol_core::types::Telemetry;
/// Bit positions in the Indoor Bike Data flags field.
pub mod flag {
/// Bit 0 — **inverted**: Instantaneous Speed is present when this is CLEAR.
pub const MORE_DATA: u16 = 1 << 0;
pub const AVERAGE_SPEED: u16 = 1 << 1;
pub const INSTANTANEOUS_CADENCE: u16 = 1 << 2;
pub const AVERAGE_CADENCE: u16 = 1 << 3;
pub const TOTAL_DISTANCE: u16 = 1 << 4;
pub const RESISTANCE_LEVEL: u16 = 1 << 5;
pub const INSTANTANEOUS_POWER: u16 = 1 << 6;
pub const AVERAGE_POWER: u16 = 1 << 7;
pub const EXPENDED_ENERGY: u16 = 1 << 8;
pub const HEART_RATE: u16 = 1 << 9;
pub const METABOLIC_EQUIVALENT: u16 = 1 << 10;
pub const ELAPSED_TIME: u16 = 1 << 11;
pub const REMAINING_TIME: u16 = 1 << 12;
}
/// Why an Indoor Bike Data packet could not be decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
/// Fewer than the two flag bytes were present.
#[error("indoor bike data packet is {len} bytes; at least 2 (flags) are required")]
MissingFlags { len: usize },
/// The flags promised a field the packet was too short to contain.
#[error(
"indoor bike data packet truncated: field {field} needs {need} byte(s) at offset {offset}, \
but the packet is only {len} bytes"
)]
Truncated {
field: &'static str,
offset: usize,
need: usize,
len: usize,
},
}
/// Every field FTMS can put in an Indoor Bike Data packet, already scaled into
/// physical units. `None` means the trainer did not send the field.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct IndoorBikeData {
/// Raw flags field, retained for diagnostics (NFR-8).
pub flags: u16,
/// km/h.
pub instant_speed_kph: Option<f32>,
/// km/h.
pub average_speed_kph: Option<f32>,
/// rpm.
pub instant_cadence_rpm: Option<f32>,
/// rpm.
pub average_cadence_rpm: Option<f32>,
/// metres.
pub total_distance_m: Option<u32>,
/// Trainer-specific resistance units.
pub resistance_level: Option<i16>,
/// watts.
pub instant_power_w: Option<i16>,
/// watts.
pub average_power_w: Option<i16>,
/// kcal.
pub total_energy_kcal: Option<u16>,
/// kcal/h.
pub energy_per_hour_kcal: Option<u16>,
/// kcal/min.
pub energy_per_minute_kcal: Option<u8>,
/// bpm.
pub heart_rate_bpm: Option<u8>,
pub metabolic_equivalent: Option<f32>,
/// seconds.
pub elapsed_time_s: Option<u16>,
/// seconds.
pub remaining_time_s: Option<u16>,
/// Number of bytes consumed. If this is less than the packet length the
/// trainer appended data we do not understand — worth logging, not an error.
pub consumed: usize,
}
impl IndoorBikeData {
/// Project onto the shared [`Telemetry`] contract. `elapsed_ms` is the
/// ride-clock timestamp; the packet's own Elapsed Time field is the
/// *machine's* session clock and is deliberately not used for it.
pub fn to_telemetry(&self, elapsed_ms: u64) -> Telemetry {
Telemetry {
elapsed_ms,
power_w: self.instant_power_w,
cadence_rpm: self.instant_cadence_rpm,
speed_kph: self.instant_speed_kph,
resistance_level: self.resistance_level,
heart_rate_bpm: self.heart_rate_bpm,
total_distance_m: self.total_distance_m,
total_energy_kcal: self.total_energy_kcal,
}
}
}
/// A bounds-checked little-endian cursor. Every read names the field it is
/// reading so a truncated packet produces a diagnosable error.
struct Cursor<'a> {
data: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, offset: 0 }
}
fn take(&mut self, field: &'static str, n: usize) -> Result<&'a [u8], DecodeError> {
let end = self.offset.checked_add(n).ok_or(DecodeError::Truncated {
field,
offset: self.offset,
need: n,
len: self.data.len(),
})?;
if end > self.data.len() {
return Err(DecodeError::Truncated {
field,
offset: self.offset,
need: n,
len: self.data.len(),
});
}
let out = &self.data[self.offset..end];
self.offset = end;
Ok(out)
}
fn u8(&mut self, field: &'static str) -> Result<u8, DecodeError> {
Ok(self.take(field, 1)?[0])
}
fn u16(&mut self, field: &'static str) -> Result<u16, DecodeError> {
let b = self.take(field, 2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn i16(&mut self, field: &'static str) -> Result<i16, DecodeError> {
Ok(self.u16(field)? as i16)
}
/// uint24, little-endian.
fn u24(&mut self, field: &'static str) -> Result<u32, DecodeError> {
let b = self.take(field, 3)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], 0]))
}
}
/// Decode one Indoor Bike Data notification.
///
/// This is a pure function over bytes — no I/O, no state — so it is fully
/// testable without hardware.
pub fn decode(data: &[u8]) -> Result<IndoorBikeData, DecodeError> {
if data.len() < 2 {
return Err(DecodeError::MissingFlags { len: data.len() });
}
let mut cur = Cursor::new(data);
let flags = cur.u16("flags")?;
let present = |bit: u16| flags & bit != 0;
let mut out = IndoorBikeData {
flags,
..Default::default()
};
// 1. Instantaneous Speed — INVERTED FLAG: present when bit 0 is CLEAR.
if !present(flag::MORE_DATA) {
out.instant_speed_kph = Some(cur.u16("instantaneous speed")? as f32 / 100.0);
}
// 2. Average Speed.
if present(flag::AVERAGE_SPEED) {
out.average_speed_kph = Some(cur.u16("average speed")? as f32 / 100.0);
}
// 3. Instantaneous Cadence.
if present(flag::INSTANTANEOUS_CADENCE) {
out.instant_cadence_rpm = Some(cur.u16("instantaneous cadence")? as f32 / 2.0);
}
// 4. Average Cadence.
if present(flag::AVERAGE_CADENCE) {
out.average_cadence_rpm = Some(cur.u16("average cadence")? as f32 / 2.0);
}
// 5. Total Distance — uint24.
if present(flag::TOTAL_DISTANCE) {
out.total_distance_m = Some(cur.u24("total distance")?);
}
// 6. Resistance Level — sint16.
if present(flag::RESISTANCE_LEVEL) {
out.resistance_level = Some(cur.i16("resistance level")?);
}
// 7. Instantaneous Power — sint16 watts.
if present(flag::INSTANTANEOUS_POWER) {
out.instant_power_w = Some(cur.i16("instantaneous power")?);
}
// 8. Average Power — sint16 watts.
if present(flag::AVERAGE_POWER) {
out.average_power_w = Some(cur.i16("average power")?);
}
// 9. Expended Energy — three fields under one flag.
if present(flag::EXPENDED_ENERGY) {
out.total_energy_kcal = Some(cur.u16("total energy")?);
out.energy_per_hour_kcal = Some(cur.u16("energy per hour")?);
out.energy_per_minute_kcal = Some(cur.u8("energy per minute")?);
}
// 10. Heart Rate.
if present(flag::HEART_RATE) {
out.heart_rate_bpm = Some(cur.u8("heart rate")?);
}
// 11. Metabolic Equivalent — uint8, 0.1 resolution.
if present(flag::METABOLIC_EQUIVALENT) {
out.metabolic_equivalent = Some(cur.u8("metabolic equivalent")? as f32 / 10.0);
}
// 12. Elapsed Time.
if present(flag::ELAPSED_TIME) {
out.elapsed_time_s = Some(cur.u16("elapsed time")?);
}
// 13. Remaining Time.
if present(flag::REMAINING_TIME) {
out.remaining_time_s = Some(cur.u16("remaining time")?);
}
out.consumed = cur.offset;
Ok(out)
}
/// Format a byte slice as lowercase hex, for `NFR-8` style raw logging.
pub fn hex(data: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(data.len() * 2);
for b in data {
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::flag::*;
use super::*;
/// Build a packet from a flags value and a payload.
fn packet(flags: u16, payload: &[u8]) -> Vec<u8> {
let mut v = flags.to_le_bytes().to_vec();
v.extend_from_slice(payload);
v
}
#[test]
fn speed_present_when_bit0_is_clear() {
// flags = 0x0000: no bits set at all -> speed present, nothing else.
// 3000 * 0.01 = 30.00 km/h
let pkt = packet(0x0000, &3000u16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(30.0));
assert_eq!(d.consumed, 4);
assert_eq!(d.instant_power_w, None);
}
#[test]
fn speed_absent_when_bit0_is_set() {
// MORE_DATA set -> NO speed field. Power follows the flags directly.
let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &200i16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, None);
assert_eq!(d.instant_power_w, Some(200));
assert_eq!(d.consumed, 4);
}
/// The regression this whole module exists to prevent: if bit 0 were
/// treated as a normal present-when-set flag, the two power bytes would be
/// eaten by "speed" and power would decode as garbage (or fail).
#[test]
fn inverted_bit0_does_not_shift_later_fields() {
let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &250i16.to_le_bytes());
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_power_w, Some(250));
// And the mirrored case: bit 0 clear means speed IS there and power
// starts two bytes later.
let mut payload = 1234u16.to_le_bytes().to_vec(); // 12.34 km/h
payload.extend_from_slice(&250i16.to_le_bytes());
let pkt = packet(INSTANTANEOUS_POWER, &payload);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(12.34));
assert_eq!(d.instant_power_w, Some(250));
}
#[test]
fn typical_trainer_packet_speed_cadence_power() {
// A very common D100-class combination: speed (implicit), cadence,
// power. flags = cadence | power, bit 0 clear.
let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER;
let mut p = Vec::new();
p.extend_from_slice(&2550u16.to_le_bytes()); // 25.50 km/h
p.extend_from_slice(&180u16.to_le_bytes()); // 90.0 rpm (0.5 resolution)
p.extend_from_slice(&213i16.to_le_bytes()); // 213 W
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.instant_speed_kph, Some(25.5));
assert_eq!(d.instant_cadence_rpm, Some(90.0));
assert_eq!(d.instant_power_w, Some(213));
assert_eq!(d.consumed, 8);
assert_eq!(d.consumed, packet(flags, &p).len());
}
#[test]
fn all_fields_present() {
let flags = AVERAGE_SPEED
| INSTANTANEOUS_CADENCE
| AVERAGE_CADENCE
| TOTAL_DISTANCE
| RESISTANCE_LEVEL
| INSTANTANEOUS_POWER
| AVERAGE_POWER
| EXPENDED_ENERGY
| HEART_RATE
| METABOLIC_EQUIVALENT
| ELAPSED_TIME
| REMAINING_TIME; // bit 0 clear -> instantaneous speed also present
let mut p = Vec::new();
p.extend_from_slice(&3512u16.to_le_bytes()); // instant speed 35.12
p.extend_from_slice(&3000u16.to_le_bytes()); // average speed 30.00
p.extend_from_slice(&191u16.to_le_bytes()); // cadence 95.5
p.extend_from_slice(&180u16.to_le_bytes()); // avg cadence 90.0
p.extend_from_slice(&[0x40, 0x0d, 0x03]); // distance uint24 LE = 0x030d40 = 200000
p.extend_from_slice(&12i16.to_le_bytes()); // resistance 12
p.extend_from_slice(&245i16.to_le_bytes()); // power 245
p.extend_from_slice(&230i16.to_le_bytes()); // avg power 230
p.extend_from_slice(&150u16.to_le_bytes()); // total energy 150 kcal
p.extend_from_slice(&600u16.to_le_bytes()); // energy/hour
p.push(10); // energy/minute
p.push(142); // heart rate
p.push(85); // MET 8.5
p.extend_from_slice(&3600u16.to_le_bytes()); // elapsed 3600 s
p.extend_from_slice(&1800u16.to_le_bytes()); // remaining 1800 s
let pkt = packet(flags, &p);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_speed_kph, Some(35.12));
assert_eq!(d.average_speed_kph, Some(30.0));
assert_eq!(d.instant_cadence_rpm, Some(95.5));
assert_eq!(d.average_cadence_rpm, Some(90.0));
assert_eq!(d.total_distance_m, Some(200_000));
assert_eq!(d.resistance_level, Some(12));
assert_eq!(d.instant_power_w, Some(245));
assert_eq!(d.average_power_w, Some(230));
assert_eq!(d.total_energy_kcal, Some(150));
assert_eq!(d.energy_per_hour_kcal, Some(600));
assert_eq!(d.energy_per_minute_kcal, Some(10));
assert_eq!(d.heart_rate_bpm, Some(142));
assert_eq!(d.metabolic_equivalent, Some(8.5));
assert_eq!(d.elapsed_time_s, Some(3600));
assert_eq!(d.remaining_time_s, Some(1800));
// Every byte consumed: proof the field order and widths line up.
assert_eq!(d.consumed, pkt.len());
}
#[test]
fn total_distance_is_uint24_little_endian() {
// 0xAABBCC as uint24 LE is bytes CC BB AA.
let pkt = packet(MORE_DATA | TOTAL_DISTANCE, &[0xcc, 0xbb, 0xaa]);
let d = decode(&pkt).unwrap();
assert_eq!(d.total_distance_m, Some(0x00AA_BBCC));
assert_eq!(d.consumed, 5);
}
#[test]
fn negative_power_and_resistance_are_signed() {
let flags = MORE_DATA | RESISTANCE_LEVEL | INSTANTANEOUS_POWER;
let mut p = Vec::new();
p.extend_from_slice(&(-5i16).to_le_bytes());
p.extend_from_slice(&(-30i16).to_le_bytes());
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.resistance_level, Some(-5));
assert_eq!(d.instant_power_w, Some(-30));
}
#[test]
fn energy_block_is_three_fields_under_one_flag() {
// If EXPENDED_ENERGY were treated as a single uint16 the heart rate
// would come out wrong. This pins all five bytes.
let flags = MORE_DATA | EXPENDED_ENERGY | HEART_RATE;
let mut p = Vec::new();
p.extend_from_slice(&321u16.to_le_bytes());
p.extend_from_slice(&654u16.to_le_bytes());
p.push(9);
p.push(155);
let d = decode(&packet(flags, &p)).unwrap();
assert_eq!(d.total_energy_kcal, Some(321));
assert_eq!(d.energy_per_hour_kcal, Some(654));
assert_eq!(d.energy_per_minute_kcal, Some(9));
assert_eq!(d.heart_rate_bpm, Some(155));
}
#[test]
fn flags_only_packet_with_more_data_set_is_valid_and_empty() {
let d = decode(&packet(MORE_DATA, &[])).unwrap();
assert_eq!(d, IndoorBikeData {
flags: MORE_DATA,
consumed: 2,
..Default::default()
});
}
#[test]
fn unknown_high_bits_are_ignored_not_fatal() {
// Bits 13-15 are RFU. A trainer setting them must not break decoding of
// the fields we do understand.
let flags = 0xE000 | MORE_DATA | INSTANTANEOUS_POWER;
let d = decode(&packet(flags, &100i16.to_le_bytes())).unwrap();
assert_eq!(d.instant_power_w, Some(100));
}
#[test]
fn trailing_unknown_bytes_are_reported_not_fatal() {
let mut pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &100i16.to_le_bytes());
pkt.extend_from_slice(&[0xde, 0xad]);
let d = decode(&pkt).unwrap();
assert_eq!(d.instant_power_w, Some(100));
assert_eq!(d.consumed, 4);
assert!(d.consumed < pkt.len());
}
#[test]
fn truncated_packet_is_an_error_not_a_panic() {
// Flags promise power but only one byte follows.
let err = decode(&packet(MORE_DATA | INSTANTANEOUS_POWER, &[0x01])).unwrap_err();
assert_eq!(
err,
DecodeError::Truncated {
field: "instantaneous power",
offset: 2,
need: 2,
len: 3,
}
);
}
#[test]
fn short_packets_are_errors() {
assert_eq!(decode(&[]).unwrap_err(), DecodeError::MissingFlags { len: 0 });
assert_eq!(
decode(&[0x00]).unwrap_err(),
DecodeError::MissingFlags { len: 1 }
);
}
#[test]
fn no_input_panics_across_every_flag_combination_and_length() {
// Exhaustive robustness sweep (NFR-4): every meaningful flags value
// against every payload length up to a full packet must either decode
// or return an error, never panic.
for flags in 0u16..=0x1FFF {
for len in 0..40usize {
let payload: Vec<u8> = (0..len).map(|i| i as u8).collect();
let _ = decode(&packet(flags, &payload));
}
}
}
#[test]
fn to_telemetry_maps_the_core_contract() {
let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER | HEART_RATE;
let mut p = Vec::new();
p.extend_from_slice(&2000u16.to_le_bytes());
p.extend_from_slice(&170u16.to_le_bytes());
p.extend_from_slice(&199i16.to_le_bytes());
p.push(130);
let t = decode(&packet(flags, &p)).unwrap().to_telemetry(1234);
assert_eq!(t.elapsed_ms, 1234);
assert_eq!(t.speed_kph, Some(20.0));
assert_eq!(t.cadence_rpm, Some(85.0));
assert_eq!(t.power_w, Some(199));
assert_eq!(t.heart_rate_bpm, Some(130));
assert_eq!(t.total_distance_m, None);
}
#[test]
fn hex_formats_lowercase_fixed_width() {
assert_eq!(hex(&[0x00, 0x0f, 0xff]), "000fff");
}
}
+69
View File
@@ -1 +1,70 @@
//! FTMS client and BLE transport. See REQUIREMENTS.md §5.15.2.
//!
//! # Layout
//!
//! The crate is split so that everything protocol-shaped is a pure function
//! over bytes, and only [`client`] and [`scan`] touch a radio. That is what
//! lets the whole wire format be tested without a trainer on the desk:
//!
//! | Module | Contents | Needs hardware |
//! |--------|----------|----------------|
//! | [`uuids`] | FTMS assigned numbers | no |
//! | [`indoor_bike_data`] | `0x2AD2` decoder | no |
//! | [`control_point`] | `0x2AD9` encoders and response decoding | no |
//! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no |
//! | [`scan`] | discovery | yes |
//! | [`client`] | the connection actor | yes |
//!
//! # Usage
//!
//! ```no_run
//! use bikecontrol_ble::{FtmsClient, FtmsConfig, TrainerSelector};
//! use bikecontrol_core::types::ControlTarget;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let client = FtmsClient::connect(TrainerSelector::Any, FtmsConfig::default()).await?;
//!
//! let mut telemetry = client.telemetry();
//! tokio::spawn(async move {
//! while let Ok(sample) = telemetry.recv().await {
//! println!("{:?} W", sample.power_w);
//! }
//! });
//!
//! client.set_target(ControlTarget::Gradient { percent: 4.0 }).await?;
//!
//! // SAF-2: always leave the trainer at zero.
//! client.shutdown().await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Attribution
//!
//! The FTMS field ordering, scaling and control-point encodings are ported from
//! [`obostjancic/smart-trainer-control`](https://github.com/obostjancic/smart-trainer-control),
//! MIT licensed, Copyright (c) 2025 Ogi — a working Van Rysel D100 client
//! (REQUIREMENTS.md §3.2). Per-module attribution notes mark where.
pub mod capabilities;
pub mod client;
pub mod control_point;
pub mod error;
pub mod indoor_bike_data;
pub mod scan;
pub mod uuids;
pub use capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
UnsupportedTarget,
};
pub use client::{
safety_reset_commands, Backoff, ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent, Procedure,
};
pub use control_point::{ControlPointResponse, OpCode, ResultCode, SimulationParameters, StopOrPause};
pub use error::FtmsError;
pub use indoor_bike_data::{DecodeError, IndoorBikeData};
pub use scan::{
default_adapter, scan, scan_trainers, DiscoveredDevice, ScanKind, TrainerSelector,
};
pub use uuids::FITNESS_MACHINE_SERVICE;
+319
View File
@@ -0,0 +1,319 @@
//! BLE discovery (FR-1.1, FR-1.2).
use std::collections::HashMap;
use std::time::Duration;
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
use uuid::Uuid;
use crate::error::FtmsError;
use crate::uuids;
/// Zwift's custom service UUID, used to recognise Click pods during a scan
/// (FR-1.2). The Click *client* is Phase 3 and lives elsewhere; discovery only
/// needs the UUID so `probe scan` can label them.
pub const ZWIFT_SERVICE: Uuid = Uuid::from_fields(
0x0000_0001,
0x19CA,
0x4651,
&[0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1],
);
/// Zwift's Bluetooth SIG manufacturer ID (2378).
pub const ZWIFT_MANUFACTURER_ID: u16 = 0x094A;
/// A peripheral seen during a scan.
#[derive(Debug, Clone)]
pub struct DiscoveredDevice {
pub id: PeripheralId,
/// Canonical lowercase MAC-style address string.
pub address: String,
pub name: Option<String>,
pub rssi: Option<i16>,
pub tx_power: Option<i16>,
pub services: Vec<Uuid>,
pub manufacturer_data: HashMap<u16, Vec<u8>>,
pub service_data: HashMap<Uuid, Vec<u8>>,
}
impl DiscoveredDevice {
/// True when the peripheral advertises the FTMS service (FR-1.2).
///
/// Note that advertising is not mandatory: a trainer may expose FTMS
/// without listing it in its advertisement. `probe scan` therefore lists
/// everything, and connecting by address always works.
pub fn is_fitness_machine(&self) -> bool {
self.services.contains(&uuids::FITNESS_MACHINE_SERVICE)
}
/// True when the peripheral looks like a Zwift controller.
pub fn is_zwift_device(&self) -> bool {
self.services.contains(&ZWIFT_SERVICE)
|| self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID)
}
/// Best-effort human label.
pub fn label(&self) -> String {
match &self.name {
Some(n) if !n.is_empty() => n.clone(),
_ => "(no name)".to_string(),
}
}
}
/// Get the first Bluetooth adapter on the system.
pub async fn default_adapter() -> Result<Adapter, FtmsError> {
let manager = Manager::new().await?;
manager
.adapters()
.await?
.into_iter()
.next()
.ok_or(FtmsError::NoAdapter)
}
/// What to scan for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanKind {
/// Every peripheral the adapter reports.
All,
/// Only peripherals advertising the FTMS service.
FitnessMachines,
}
impl ScanKind {
fn filter(self) -> ScanFilter {
match self {
// An empty filter means "everything". Some backends require a
// filter for privacy reasons; on Linux/BlueZ an empty one is fine.
ScanKind::All => ScanFilter::default(),
ScanKind::FitnessMachines => ScanFilter {
services: vec![uuids::FITNESS_MACHINE_SERVICE],
},
}
}
}
/// Scan for `duration` and return everything seen.
///
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is
/// pedalled. An empty result means "nothing was advertising", not "no such
/// device exists"; FR-1.8 requires the UI to say so.
pub async fn scan(
adapter: &Adapter,
duration: Duration,
kind: ScanKind,
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
adapter.start_scan(kind.filter()).await?;
tokio::time::sleep(duration).await;
let peripherals = adapter.peripherals().await?;
// Stopping the scan is best effort; a failure here must not lose results.
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed");
}
let mut out = Vec::with_capacity(peripherals.len());
for p in peripherals {
if let Some(d) = describe(&p).await {
if kind == ScanKind::FitnessMachines && !d.is_fitness_machine() {
// Some backends ignore the service filter; enforce it here too.
continue;
}
out.push(d);
}
}
out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN)));
Ok(out)
}
/// Convenience wrapper: scan the default adapter for trainers.
pub async fn scan_trainers(duration: Duration) -> Result<Vec<DiscoveredDevice>, FtmsError> {
let adapter = default_adapter().await?;
scan(&adapter, duration, ScanKind::FitnessMachines).await
}
/// Snapshot a peripheral's advertisement data.
pub async fn describe(p: &Peripheral) -> Option<DiscoveredDevice> {
let props = p.properties().await.ok().flatten()?;
Some(DiscoveredDevice {
id: p.id(),
address: props.address.to_string().to_lowercase(),
name: props.local_name.clone(),
rssi: props.rssi,
tx_power: props.tx_power_level,
services: props.services.clone(),
manufacturer_data: props.manufacturer_data.clone(),
service_data: props.service_data.clone(),
})
}
/// How to pick a trainer out of a scan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrainerSelector {
/// The first peripheral advertising FTMS. Fine when only one trainer is in
/// the room; ambiguous otherwise.
Any,
/// Match on address, case-insensitively (`AA:BB:CC:DD:EE:FF`, or the
/// platform's opaque identifier on macOS).
Address(String),
/// Match when the advertised local name contains this, case-insensitively.
NameContains(String),
}
impl TrainerSelector {
/// Does this peripheral match the selector?
pub fn matches(&self, d: &DiscoveredDevice) -> bool {
self.matches_parts(
&d.address,
d.name.as_deref(),
d.is_fitness_machine(),
&format!("{:?}", d.id),
)
}
/// The matching rule, factored out so it can be unit-tested without a
/// `PeripheralId` (which only the platform backend can construct).
pub(crate) fn matches_parts(
&self,
address: &str,
name: Option<&str>,
is_fitness_machine: bool,
id_debug: &str,
) -> bool {
match self {
TrainerSelector::Any => is_fitness_machine,
TrainerSelector::Address(a) => {
address.eq_ignore_ascii_case(a)
|| id_debug.to_lowercase().contains(&a.to_lowercase())
}
TrainerSelector::NameContains(n) => name
.map(|name| name.to_lowercase().contains(&n.to_lowercase()))
.unwrap_or(false),
}
}
/// Human-readable description, for error messages.
pub fn describe(&self) -> String {
match self {
TrainerSelector::Any => "any FTMS trainer".to_string(),
TrainerSelector::Address(a) => format!("address {a}"),
TrainerSelector::NameContains(n) => format!("name containing {n:?}"),
}
}
/// A selector by address needs an unfiltered scan, because a peripheral is
/// not obliged to advertise the FTMS service.
fn scan_kind(&self) -> ScanKind {
match self {
TrainerSelector::Any => ScanKind::FitnessMachines,
_ => ScanKind::All,
}
}
}
/// Scan until a peripheral matching `selector` appears, or `timeout` elapses.
pub async fn find_peripheral(
adapter: &Adapter,
selector: &TrainerSelector,
timeout: Duration,
) -> Result<Peripheral, FtmsError> {
let kind = selector.scan_kind();
adapter.start_scan(kind.filter()).await?;
let deadline = tokio::time::Instant::now() + timeout;
let poll = Duration::from_millis(400);
let mut found: Option<Peripheral> = None;
'search: loop {
for p in adapter.peripherals().await?.into_iter() {
if let Some(d) = describe(&p).await {
if selector.matches(&d) {
tracing::info!(
address = %d.address,
name = d.label(),
rssi = ?d.rssi,
"matched trainer"
);
found = Some(p);
break 'search;
}
}
}
if tokio::time::Instant::now() >= deadline {
break 'search;
}
tokio::time::sleep(poll).await;
}
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed");
}
found.ok_or_else(|| FtmsError::NotFound(selector.describe()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zwift_service_uuid_matches_the_spec() {
assert_eq!(
ZWIFT_SERVICE.to_string(),
"00000001-19ca-4651-86e5-fa29dcdd09d1"
);
assert_eq!(ZWIFT_MANUFACTURER_ID, 2378);
}
#[test]
fn selector_any_requires_the_ftms_service() {
let s = TrainerSelector::Any;
assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), true, ""));
assert!(!s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), false, ""));
}
#[test]
fn selector_address_is_case_insensitive_and_ignores_advertised_services() {
let s = TrainerSelector::Address("AA:BB:CC:DD:EE:FF".into());
assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", None, false, ""));
assert!(!s.matches_parts("11:22:33:44:55:66", None, true, ""));
}
#[test]
fn selector_address_also_matches_an_opaque_platform_id() {
// macOS gives UUID-shaped PeripheralIds rather than MACs.
let s = TrainerSelector::Address("1E2F3A4B".into());
assert!(s.matches_parts("", None, false, "PeripheralId(1e2f3a4b-....)"));
}
#[test]
fn selector_name_is_a_case_insensitive_substring() {
let s = TrainerSelector::NameContains("d100".into());
assert!(s.matches_parts("", Some("VAN RYSEL D100 4321"), false, ""));
assert!(!s.matches_parts("", Some("KICKR CORE"), true, ""));
assert!(!s.matches_parts("", None, true, ""));
}
#[test]
fn selector_scan_kind_widens_for_address_and_name() {
assert_eq!(TrainerSelector::Any.scan_kind(), ScanKind::FitnessMachines);
assert_eq!(
TrainerSelector::Address("x".into()).scan_kind(),
ScanKind::All
);
assert_eq!(
TrainerSelector::NameContains("x".into()).scan_kind(),
ScanKind::All
);
}
#[test]
fn scan_filter_for_fitness_machines_carries_the_ftms_uuid() {
assert_eq!(
ScanKind::FitnessMachines.filter().services,
vec![uuids::FITNESS_MACHINE_SERVICE]
);
assert!(ScanKind::All.filter().services.is_empty());
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Bluetooth SIG assigned UUIDs for the Fitness Machine Service (FTMS).
//!
//! All of these are 16-bit assigned numbers expanded onto the Bluetooth Base
//! UUID `0000xxxx-0000-1000-8000-00805F9B34FB`.
use uuid::Uuid;
/// The tail of the Bluetooth Base UUID, `0000xxxx-0000-1000-8000-00805F9B34FB`.
const BASE_D2: u16 = 0x0000;
const BASE_D3: u16 = 0x1000;
const BASE_D4: [u8; 8] = [0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb];
/// Expand a 16-bit Bluetooth SIG assigned number onto the Bluetooth Base UUID.
pub const fn uuid16(assigned: u16) -> Uuid {
Uuid::from_fields(assigned as u32, BASE_D2, BASE_D3, &BASE_D4)
}
/// Fitness Machine Service — `0x1826`. Trainers are identified by advertising
/// this (FR-1.2).
pub const FITNESS_MACHINE_SERVICE: Uuid = uuid16(0x1826);
/// Fitness Machine Feature — `0x2ACC`, read.
pub const FITNESS_MACHINE_FEATURE: Uuid = uuid16(0x2ACC);
/// Indoor Bike Data — `0x2AD2`, notify.
pub const INDOOR_BIKE_DATA: Uuid = uuid16(0x2AD2);
/// Training Status — `0x2AD3`, read/notify.
pub const TRAINING_STATUS: Uuid = uuid16(0x2AD3);
/// Supported Speed Range — `0x2AD4`, read.
pub const SUPPORTED_SPEED_RANGE: Uuid = uuid16(0x2AD4);
/// Supported Inclination Range — `0x2AD5`, read.
pub const SUPPORTED_INCLINATION_RANGE: Uuid = uuid16(0x2AD5);
/// Supported Resistance Level Range — `0x2AD6`, read.
pub const SUPPORTED_RESISTANCE_LEVEL_RANGE: Uuid = uuid16(0x2AD6);
/// Supported Heart Rate Range — `0x2AD7`, read.
pub const SUPPORTED_HEART_RATE_RANGE: Uuid = uuid16(0x2AD7);
/// Supported Power Range — `0x2AD8`, read.
pub const SUPPORTED_POWER_RANGE: Uuid = uuid16(0x2AD8);
/// Fitness Machine Control Point — `0x2AD9`, write + indicate.
pub const FITNESS_MACHINE_CONTROL_POINT: Uuid = uuid16(0x2AD9);
/// Fitness Machine Status — `0x2ADA`, notify.
pub const FITNESS_MACHINE_STATUS: Uuid = uuid16(0x2ADA);
/// Device Information Service — `0x180A`. Useful for the probe.
pub const DEVICE_INFORMATION_SERVICE: Uuid = uuid16(0x180A);
/// Battery Service — `0x180F`.
pub const BATTERY_SERVICE: Uuid = uuid16(0x180F);
/// Human-readable name for a well-known UUID, for logging and the probe CLI.
/// Returns `None` for anything not recognised.
pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
let name = match short_id(uuid)? {
0x1826 => "Fitness Machine Service",
0x2ACC => "Fitness Machine Feature",
0x2AD2 => "Indoor Bike Data",
0x2AD3 => "Training Status",
0x2AD4 => "Supported Speed Range",
0x2AD5 => "Supported Inclination Range",
0x2AD6 => "Supported Resistance Level Range",
0x2AD7 => "Supported Heart Rate Range",
0x2AD8 => "Supported Power Range",
0x2AD9 => "Fitness Machine Control Point",
0x2ADA => "Fitness Machine Status",
0x180A => "Device Information",
0x180F => "Battery Service",
0x1800 => "Generic Access",
0x1801 => "Generic Attribute",
0x180D => "Heart Rate",
0x1818 => "Cycling Power",
0x1816 => "Cycling Speed and Cadence",
0x2A00 => "Device Name",
0x2A19 => "Battery Level",
0x2A24 => "Model Number String",
0x2A25 => "Serial Number String",
0x2A26 => "Firmware Revision String",
0x2A27 => "Hardware Revision String",
0x2A29 => "Manufacturer Name String",
_ => return None,
};
Some(name)
}
/// If `uuid` sits on the Bluetooth Base UUID, return its 16-bit assigned number.
pub fn short_id(uuid: Uuid) -> Option<u16> {
let (d1, d2, d3, d4) = uuid.as_fields();
if d2 == BASE_D2 && d3 == BASE_D3 && *d4 == BASE_D4 && d1 <= u16::MAX as u32 {
Some(d1 as u16)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ftms_service_uuid_is_correct() {
assert_eq!(
FITNESS_MACHINE_SERVICE.to_string(),
"00001826-0000-1000-8000-00805f9b34fb"
);
}
#[test]
fn characteristic_uuids_are_correct() {
assert_eq!(
INDOOR_BIKE_DATA.to_string(),
"00002ad2-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
FITNESS_MACHINE_CONTROL_POINT.to_string(),
"00002ad9-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
FITNESS_MACHINE_FEATURE.to_string(),
"00002acc-0000-1000-8000-00805f9b34fb"
);
assert_eq!(
SUPPORTED_RESISTANCE_LEVEL_RANGE.to_string(),
"00002ad6-0000-1000-8000-00805f9b34fb"
);
}
#[test]
fn short_id_round_trips() {
assert_eq!(short_id(uuid16(0x2AD2)), Some(0x2AD2));
assert_eq!(short_id(FITNESS_MACHINE_SERVICE), Some(0x1826));
// A vendor UUID (the Zwift custom service) is not on the base UUID.
let zwift = Uuid::parse_str("00000001-19CA-4651-86E5-FA29DCDD09D1").unwrap();
assert_eq!(short_id(zwift), None);
}
#[test]
fn well_known_names_resolve() {
assert_eq!(
well_known_name(INDOOR_BIKE_DATA),
Some("Indoor Bike Data")
);
assert_eq!(well_known_name(uuid16(0x2A19)), Some("Battery Level"));
assert_eq!(well_known_name(uuid16(0xFF01)), None);
}
}