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
+745
View File
@@ -0,0 +1,745 @@
//! The four probe subcommands.
//!
//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug`
//! directly, so they never take FTMS control and can be run safely while
//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which
//! means it exercises the same rate limiting, clamping, acknowledgement
//! handling and SAF-2 shutdown that the app will use.
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use bikecontrol_ble::capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
};
use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent};
use bikecontrol_ble::control_point::ResultCode;
use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
use bikecontrol_ble::{uuids, FtmsError};
use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
use btleplug::platform::Peripheral;
use futures::StreamExt;
use uuid::Uuid;
use crate::cli::Device;
impl Device {
fn selector(&self) -> TrainerSelector {
match self {
Device::Address(a) => TrainerSelector::Address(a.clone()),
Device::Name(n) => TrainerSelector::NameContains(n.clone()),
}
}
}
// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------
/// FR-1.1: list peripherals with name, address, RSSI and advertised services.
pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let kind = if all {
ScanKind::All
} else {
ScanKind::FitnessMachines
};
println!(
"Scanning for {} s ({})...",
duration.as_secs(),
if all {
"everything"
} else {
"fitness machines only — pass --all to see every peripheral"
}
);
let devices = scan::scan(&adapter, duration, kind).await?;
if devices.is_empty() {
println!("\nNothing found.");
println!(
"The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\
and scan again. A Zwift Click wakes on a button press."
);
return Ok(());
}
println!("\n{} device(s):\n", devices.len());
for d in &devices {
print_device(d);
}
Ok(())
}
fn print_device(d: &DiscoveredDevice) {
let kind = if d.is_fitness_machine() {
" [FTMS trainer]"
} else if d.is_zwift_device() {
" [Zwift device]"
} else {
""
};
println!("{} {}{}", d.address, d.label(), kind);
println!(
" rssi: {} tx power: {}",
d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()),
d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into())
);
if d.services.is_empty() {
println!(" services: (none advertised)");
} else {
println!(" services:");
for s in &d.services {
println!(" {}{}", s, named(*s));
}
}
for (id, data) in &d.manufacturer_data {
println!(" manufacturer 0x{id:04x} ({id}): {}", hex(data));
}
for (uuid, data) in &d.service_data {
println!(" service data {uuid}: {}", hex(data));
}
println!();
}
fn named(uuid: Uuid) -> String {
uuids::well_known_name(uuid)
.map(|n| format!(" ({n})"))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------
/// TASK-1: enumerate everything, and decode the capability characteristics.
pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
if let Some(d) = scan::describe(&peripheral).await {
println!("Connected to {} ({})\n", d.address, d.label());
}
println!("=== Services and characteristics ===\n");
let mut has_ftms = false;
for service in peripheral.services() {
if service.uuid == uuids::FITNESS_MACHINE_SERVICE {
has_ftms = true;
}
println!(
"service {}{}{}",
service.uuid,
named(service.uuid),
if service.primary { " [primary]" } else { "" }
);
for ch in &service.characteristics {
println!(
" char {}{}\n properties: {}",
ch.uuid,
named(ch.uuid),
properties(ch.properties)
);
// Reading is safe: every readable characteristic here is
// informational, and it is exactly what Phase 0 needs to see.
if ch.properties.contains(CharPropFlags::READ) {
match peripheral.read(ch).await {
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
Err(e) => println!(" value: <unreadable: {e}>"),
}
}
}
println!();
}
if !has_ftms {
println!(
"!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\
!! It is not an FTMS trainer, or it needs waking.\n"
);
}
println!("=== Fitness Machine Feature (0x2ACC) ===\n");
match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await {
Some(raw) => match FitnessMachineFeature::decode(&raw) {
Ok(f) => print_feature(&raw, f),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Resistance Level Range (0x2AD6) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await {
Some(raw) => match ResistanceLevelRange::decode(&raw) {
Ok(r) => {
let (lo, hi, inc) = r.scaled();
println!(" raw bytes: {}", hex(&raw));
println!(" minimum: {}", r.min);
println!(" maximum: {}", r.max);
println!(" increment: {}", r.increment);
println!(
" if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}"
);
println!(
"\n NOTE: resistance level is a trainer-specific unit. Whether the D100\n\
means raw integers or tenths is TASK-1/TASK-3 — compare these numbers\n\
with what `set resistance=<N>` actually does, and with the resistance\n\
level reported back in Indoor Bike Data.\n"
);
}
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Power Range (0x2AD8) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await {
Some(raw) => match PowerRange::decode(&raw) {
Ok(p) => println!(
" raw bytes: {}\n {} .. {} W, step {} W\n",
hex(&raw),
p.min_w,
p.max_w,
p.increment_w
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Inclination Range (0x2AD5) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await {
Some(raw) => match InclinationRange::decode(&raw) {
Ok(i) => println!(
" raw bytes: {}\n {} .. {} %, step {} %\n",
hex(&raw),
i.min_percent(),
i.max_percent(),
i.increment_percent()
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
disconnect(&peripheral).await;
Ok(())
}
fn print_feature(raw: &[u8], f: FitnessMachineFeature) {
println!(" raw bytes: {}", hex(raw));
println!(" machine field: 0x{:08x}", f.machine);
println!(" target field: 0x{:08x}\n", f.target);
println!(" Measures:");
let m = f.machine_feature_names();
if m.is_empty() {
println!(" (none)");
}
for name in m {
println!(" - {name}");
}
println!("\n Accepts as targets:");
let t = f.target_feature_names();
if t.is_empty() {
println!(" (none)");
}
for name in t {
println!(" - {name}");
}
println!("\n Answers to the questions Phase 0 is asking:");
println!(
" SetTargetInclination (0x03): {}",
yes_no(f.supports_inclination_target())
);
println!(
" SetTargetResistanceLevel (0x04): {}",
yes_no(f.supports_resistance_target())
);
println!(
" SetTargetPower (0x05): {}",
yes_no(f.supports_power_target())
);
println!(
" SetIndoorBikeSimulationParameters (0x11): {} <-- A-1",
yes_no(f.supports_simulation())
);
println!(
"\n The 0x11 bit is only what the trainer *claims*. Confirm it with\n\
`probe set <addr> sim=4.0`, which writes the op code regardless.\n"
);
}
fn yes_no(b: bool) -> &'static str {
if b {
"advertised"
} else {
"NOT advertised"
}
}
// ---------------------------------------------------------------------------
// monitor
// ---------------------------------------------------------------------------
/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious.
pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| {
anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)")
})?;
let mut notifications = peripheral.notifications().await?;
peripheral.subscribe(&bike_data).await?;
println!(
"Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\
Pedal the trainer — most trainers send nothing at all when stationary.\n\
Press Ctrl-C to stop early.\n",
duration.as_secs()
);
let start = Instant::now();
let mut count: u64 = 0;
let mut failures: u64 = 0;
let deadline = tokio::time::sleep(duration);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
n = notifications.next() => {
let Some(n) = n else {
println!("\nNotification stream ended (the trainer disconnected).");
break;
};
if n.uuid != uuids::INDOOR_BIKE_DATA {
continue;
}
count += 1;
let t = start.elapsed().as_secs_f32();
println!("[{t:7.2}s] #{count} raw 2ad2: {}", hex(&n.value));
match indoor_bike_data::decode(&n.value) {
Ok(d) => print_decoded(&d, n.value.len()),
Err(e) => {
failures += 1;
println!(" DECODE FAILED: {e}");
}
}
println!();
}
}
}
println!(
"\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).",
start.elapsed().as_secs_f32(),
count as f32 / start.elapsed().as_secs_f32().max(0.001)
);
if count > 0 && failures == 0 {
println!("Decoder agrees with the trainer on every packet.");
}
let _ = peripheral.unsubscribe(&bike_data).await;
disconnect(&peripheral).await;
Ok(())
}
fn print_decoded(d: &IndoorBikeData, len: usize) {
println!(
" flags: 0x{:04x} ({})",
d.flags,
flag_names(d.flags)
);
let row = |label: &str, value: Option<String>| {
if let Some(v) = value {
println!(" {label:<10} {v}");
}
};
row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h")));
row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h")));
row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("distance:", d.total_distance_m.map(|v| format!("{v} m")));
row("resist:", d.resistance_level.map(|v| v.to_string()));
row("power:", d.instant_power_w.map(|v| format!("{v} W")));
row("avg power:", d.average_power_w.map(|v| format!("{v} W")));
row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal")));
row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string()));
row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string()));
row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm")));
row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}")));
row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s")));
row("remaining:", d.remaining_time_s.map(|v| format!("{v} s")));
if d.consumed != len {
println!(
" !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for",
d.consumed,
len - d.consumed
);
}
}
fn flag_names(flags: u16) -> String {
use indoor_bike_data::flag as f;
let mut names = Vec::new();
// Bit 0 is inverted: speed is present when it is CLEAR.
if flags & f::MORE_DATA == 0 {
names.push("InstantaneousSpeed(bit0 clear)");
} else {
names.push("MoreData(bit0 set: no speed)");
}
for (bit, name) in [
(f::AVERAGE_SPEED, "AvgSpeed"),
(f::INSTANTANEOUS_CADENCE, "Cadence"),
(f::AVERAGE_CADENCE, "AvgCadence"),
(f::TOTAL_DISTANCE, "TotalDistance"),
(f::RESISTANCE_LEVEL, "Resistance"),
(f::INSTANTANEOUS_POWER, "Power"),
(f::AVERAGE_POWER, "AvgPower"),
(f::EXPENDED_ENERGY, "Energy"),
(f::HEART_RATE, "HeartRate"),
(f::METABOLIC_EQUIVALENT, "MET"),
(f::ELAPSED_TIME, "ElapsedTime"),
(f::REMAINING_TIME, "RemainingTime"),
] {
if flags & bit != 0 {
names.push(name);
}
}
if flags & 0xE000 != 0 {
names.push("<reserved bits set>");
}
names.join(" | ")
}
// ---------------------------------------------------------------------------
// set
// ---------------------------------------------------------------------------
/// TASK-2, and the experiment that answers A-1.
pub async fn set(
device: &Device,
target: ControlTarget,
simulation: bool,
hold: Duration,
scan_timeout: Duration,
) -> Result<()> {
let config = FtmsConfig {
use_simulation_mode: simulation,
scan_timeout,
// Discovery: write the op code even when the feature bit is clear, so
// the trainer's own response settles the question rather than our
// reading of its advertisement.
ignore_advertised_features: true,
..FtmsConfig::default()
};
println!("Connecting and requesting FTMS control...");
let client = FtmsClient::connect(device.selector(), config).await?;
println!(
"Control acquired on {} ({}).\n",
client.address(),
client.name().unwrap_or("no name")
);
let caps = client.capabilities();
if let Some(f) = caps.feature {
println!("Trainer advertises target support: {:?}\n", f.target_feature_names());
}
let mut events = client.events();
let mut telemetry = client.telemetry();
let (op, note) = describe_write(&target, simulation);
println!("Writing {op} ({note})...");
let outcome = client.set_target(target).await;
report_outcome(&outcome, simulation);
// Drain the indication that came back, so the raw result code is visible
// even when the write succeeded.
while let Ok(event) = events.try_recv() {
if let FtmsEvent::ControlResponse { op, result } = event {
println!(
" indication: op {:?}, result {} (0x{:02x})",
op,
result,
result.as_u8()
);
}
}
if outcome.is_ok() {
println!(
"\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\
(TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\
Ctrl-C to stop early.\n",
hold.as_secs()
);
let deadline = tokio::time::sleep(hold);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
sample = telemetry.recv() => {
if let Ok(s) = sample {
println!(
" {:6.1}s power {:>5} cadence {:>6} speed {:>7} resistance {:>5}",
s.elapsed_ms as f32 / 1000.0,
s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()),
s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()),
s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()),
s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()),
);
}
}
}
}
}
println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)...");
client.shutdown().await?;
println!("Done.");
Ok(())
}
fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) {
match target {
ControlTarget::Gradient { percent } if simulation => (
"SetIndoorBikeSimulationParameters (0x11)",
format!("grade {percent} %"),
),
ControlTarget::Gradient { percent } => (
"SetTargetInclination (0x03)",
format!("inclination {percent} %"),
),
ControlTarget::Resistance { level } => (
"SetTargetResistanceLevel (0x04)",
format!("level {level}"),
),
ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")),
}
}
fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool) {
match outcome {
Ok(ControlOutcome::Acknowledged { sent }) => {
println!(" ACCEPTED. Trainer acknowledged with Success.");
println!(" value actually transmitted (post-clamp): {sent:?}");
if simulation {
println!(
"\n >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\
>>> FR-2.3 may use 0x11 for gradient."
);
}
}
Ok(ControlOutcome::Superseded) => {
println!(" superseded before transmission (should not happen for a single write)");
}
Err(FtmsError::Rejected { op, result }) => {
println!(" REJECTED. Trainer answered {op} with: {result}");
if simulation && *result == ResultCode::OpCodeNotSupported {
println!(
"\n >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\
>>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\
>>> exactly as the MIT reference implementation does. Low impact —\n\
>>> the app owns the physics (FR-7.1)."
);
}
if *result == ResultCode::ControlNotPermitted {
println!(
" (RequestControl succeeded but the trainer withdrew control — another\n\
app may be connected. Only one BLE host may hold the trainer, per A-3.)"
);
}
}
Err(FtmsError::Unacknowledged { op, timeout_ms }) => {
println!(" NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms.");
println!(" This is the silent-failure mode FR-2.7 exists to catch.");
}
Err(FtmsError::Unsupported(e)) => {
println!(" BLOCKED BEFORE TRANSMISSION: {e}");
}
Err(e) => println!(" FAILED: {e}"),
}
}
// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------
async fn connect(device: &Device, scan_timeout: Duration) -> Result<Peripheral> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let selector = device.selector();
println!("Looking for {}...", selector.describe());
let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout)
.await
.with_context(|| {
format!(
"could not find {}. The trainer may be asleep — pedal it and try again (A-4)",
selector.describe()
)
})?;
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await.context("connect failed")?;
}
peripheral
.discover_services()
.await
.context("service discovery failed")?;
Ok(peripheral)
}
async fn disconnect(peripheral: &Peripheral) {
if let Err(e) = peripheral.disconnect().await {
tracing::debug!(error = %e, "disconnect failed");
}
}
fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option<Characteristic> {
peripheral.characteristics().into_iter().find(|c| c.uuid == uuid)
}
async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option<Vec<u8>> {
let ch = find_characteristic(peripheral, uuid)?;
peripheral.read(&ch).await.ok()
}
/// Render a characteristic's bytes as text when they look like a string —
/// Device Information holds model and firmware numbers this way.
fn as_text(v: &[u8]) -> String {
if !v.is_empty()
&& v.iter()
.all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r')
{
format!("\"{}\"", String::from_utf8_lossy(v).trim())
} else {
String::new()
}
}
fn properties(p: CharPropFlags) -> String {
let mut out = Vec::new();
for (flag, name) in [
(CharPropFlags::BROADCAST, "broadcast"),
(CharPropFlags::READ, "read"),
(CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"),
(CharPropFlags::WRITE, "write"),
(CharPropFlags::NOTIFY, "notify"),
(CharPropFlags::INDICATE, "indicate"),
(
CharPropFlags::AUTHENTICATED_SIGNED_WRITES,
"authenticated-signed-writes",
),
(CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"),
] {
if p.contains(flag) {
out.push(name);
}
}
if out.is_empty() {
"(none)".to_string()
} else {
out.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_ble::indoor_bike_data::flag;
#[test]
fn flag_names_call_out_the_inverted_bit_zero() {
// Bit 0 clear means speed IS present.
assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)"));
// Bit 0 set means it is not.
assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)"));
}
#[test]
fn flag_names_list_every_present_field() {
let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER);
assert!(names.contains("Cadence"));
assert!(names.contains("Power"));
assert!(!names.contains("HeartRate"));
}
#[test]
fn flag_names_flag_reserved_bits() {
assert!(flag_names(0x8000).contains("<reserved bits set>"));
assert!(!flag_names(0x0001).contains("<reserved bits set>"));
}
#[test]
fn device_selector_mapping() {
assert_eq!(
Device::Address("AA:BB".into()).selector(),
TrainerSelector::Address("AA:BB".into())
);
assert_eq!(
Device::Name("D100".into()).selector(),
TrainerSelector::NameContains("D100".into())
);
}
#[test]
fn describe_write_names_the_op_code() {
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0,
"SetTargetInclination (0x03)"
);
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0,
"SetIndoorBikeSimulationParameters (0x11)"
);
assert_eq!(
describe_write(&ControlTarget::Resistance { level: 10 }, false).0,
"SetTargetResistanceLevel (0x04)"
);
assert_eq!(
describe_write(&ControlTarget::Power { watts: 100 }, false).0,
"SetTargetPower (0x05)"
);
}
#[test]
fn as_text_only_renders_printable_payloads() {
assert_eq!(as_text(b"D100"), "\"D100\"");
assert_eq!(as_text(&[0x00, 0x01, 0xff]), "");
assert_eq!(as_text(&[]), "");
}
#[test]
fn properties_are_listed_in_order() {
assert_eq!(
properties(CharPropFlags::READ | CharPropFlags::INDICATE),
"read, indicate"
);
assert_eq!(properties(CharPropFlags::empty()), "(none)");
}
}