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
+365
View File
@@ -0,0 +1,365 @@
//! Hand-rolled argument parsing.
//!
//! Deliberately dependency-free: the probe is a Phase 0 diagnostic tool that
//! has to build and run on whatever machine is next to the trainer, and it does
//! not need an argument parser to do four subcommands.
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use bikecontrol_core::types::ControlTarget;
pub const USAGE: &str = "\
probe — Van Rysel D100 / FTMS protocol discovery (REQUIREMENTS.md Phase 0)
USAGE:
probe <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
scan List BLE peripherals: name, address, RSSI, advertised services
inspect <ADDR> Connect and dump every service, characteristic and capability
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
TARGET (for `set`):
gradient=<PCT> SetTargetInclination (0x03), e.g. gradient=4.5
sim=<PCT> SetIndoorBikeSimulation (0x11) — this is what answers A-1
resistance=<LEVEL> SetTargetResistanceLevel (0x04), e.g. resistance=30
power=<WATTS> SetTargetPower (0x05), e.g. power=200
OPTIONS:
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
scan 6, monitor 30, set 15)
--all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name
-v, --verbose debug-level logging, including every raw BLE frame (NFR-8)
-h, --help this text
ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF).
SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the
trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C.
";
#[derive(Debug, PartialEq)]
pub enum Command {
Help,
Scan {
duration: Duration,
all: bool,
},
Inspect {
device: Device,
},
Monitor {
device: Device,
duration: Duration,
},
Set {
device: Device,
target: ControlTarget,
/// True for `sim=`, which forces op code `0x11`.
simulation: bool,
hold: Duration,
},
}
/// How the user identified the trainer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Device {
Address(String),
Name(String),
}
#[derive(Debug, PartialEq)]
pub struct Args {
pub command: Command,
pub verbose: bool,
}
pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut args: Vec<String> = argv.into_iter().collect();
let mut verbose = false;
let mut secs: Option<u64> = None;
let mut all = false;
let mut name: Option<String> = None;
let mut help = false;
let mut positional: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = std::mem::take(&mut args[i]);
match arg.as_str() {
"-h" | "--help" | "help" => help = true,
"-v" | "--verbose" => verbose = true,
"--all" => all = true,
"--secs" | "--seconds" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow!("--secs needs a value"))?
.clone();
secs = Some(
v.parse()
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
);
}
"--name" => {
i += 1;
name = Some(
args.get(i)
.ok_or_else(|| anyhow!("--name needs a value"))?
.clone(),
);
}
other if other.starts_with('-') => bail!("unknown option {other:?}"),
other => positional.push(other.to_string()),
}
i += 1;
}
if help || positional.is_empty() {
return Ok(Args {
command: Command::Help,
verbose,
});
}
let device = |positional: &[String], index: usize| -> Result<Device> {
if let Some(n) = &name {
return Ok(Device::Name(n.clone()));
}
positional
.get(index)
.map(|a| Device::Address(a.clone()))
.ok_or_else(|| anyhow!("this subcommand needs an address (or --name <SUBSTR>)"))
};
let command = match positional[0].as_str() {
"scan" => Command::Scan {
duration: Duration::from_secs(secs.unwrap_or(6)),
all,
},
"inspect" => Command::Inspect {
device: device(&positional, 1)?,
},
"monitor" => Command::Monitor {
device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(30)),
},
"set" => {
// With --name the address slot is absent, so the target may be at
// index 1 or 2.
let target_arg = if name.is_some() && positional.len() == 2 {
positional[1].clone()
} else {
positional
.get(2)
.cloned()
.ok_or_else(|| anyhow!("`set` needs a target, e.g. gradient=4.5"))?
};
let (target, simulation) = parse_target(&target_arg)?;
Command::Set {
device: device(&positional, 1)?,
target,
simulation,
hold: Duration::from_secs(secs.unwrap_or(15)),
}
}
other => bail!("unknown subcommand {other:?} — run `probe --help`"),
};
Ok(Args { command, verbose })
}
/// Parse `gradient=4.5`, `resistance=30`, `power=200` or `sim=4.5`.
///
/// Returns the target and whether simulation mode (`0x11`) was requested.
pub fn parse_target(s: &str) -> Result<(ControlTarget, bool)> {
let (key, value) = s
.split_once('=')
.or_else(|| s.split_once(':'))
.ok_or_else(|| anyhow!("target must look like `gradient=4.5`, got {s:?}"))?;
let key = key.trim().to_lowercase();
let value = value.trim();
match key.as_str() {
"gradient" | "grade" | "incline" | "inclination" => {
let pct: f32 = value
.parse()
.map_err(|_| anyhow!("gradient must be a number of percent, got {value:?}"))?;
Ok((ControlTarget::Gradient { percent: pct }, false))
}
"sim" | "simulation" | "simgrade" => {
let pct: f32 = value
.parse()
.map_err(|_| anyhow!("sim grade must be a number of percent, got {value:?}"))?;
Ok((ControlTarget::Gradient { percent: pct }, true))
}
"resistance" | "res" | "level" => {
let level: i16 = value
.parse()
.map_err(|_| anyhow!("resistance must be a whole number, got {value:?}"))?;
Ok((ControlTarget::Resistance { level }, false))
}
"power" | "watts" | "erg" => {
let watts: u16 = value
.parse()
.map_err(|_| anyhow!("power must be a whole number of watts, got {value:?}"))?;
Ok((ControlTarget::Power { watts }, false))
}
other => bail!("unknown target channel {other:?} — use gradient, sim, resistance or power"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Result<Args> {
parse(v.iter().map(|s| s.to_string()))
}
#[test]
fn no_arguments_prints_help() {
assert_eq!(args(&[]).unwrap().command, Command::Help);
assert_eq!(args(&["--help"]).unwrap().command, Command::Help);
assert_eq!(args(&["scan", "-h"]).unwrap().command, Command::Help);
}
#[test]
fn scan_defaults_and_flags() {
assert_eq!(
args(&["scan"]).unwrap().command,
Command::Scan {
duration: Duration::from_secs(6),
all: false
}
);
assert_eq!(
args(&["scan", "--all", "--secs", "12"]).unwrap().command,
Command::Scan {
duration: Duration::from_secs(12),
all: true
}
);
}
#[test]
fn verbose_is_recognised_anywhere() {
assert!(args(&["-v", "scan"]).unwrap().verbose);
assert!(args(&["scan", "--verbose"]).unwrap().verbose);
assert!(!args(&["scan"]).unwrap().verbose);
}
#[test]
fn inspect_and_monitor_take_an_address() {
assert_eq!(
args(&["inspect", "AA:BB:CC:DD:EE:FF"]).unwrap().command,
Command::Inspect {
device: Device::Address("AA:BB:CC:DD:EE:FF".into())
}
);
assert_eq!(
args(&["monitor", "AA:BB:CC:DD:EE:FF", "--secs", "5"])
.unwrap()
.command,
Command::Monitor {
device: Device::Address("AA:BB:CC:DD:EE:FF".into()),
duration: Duration::from_secs(5)
}
);
}
#[test]
fn name_substitutes_for_an_address() {
assert_eq!(
args(&["inspect", "--name", "D100"]).unwrap().command,
Command::Inspect {
device: Device::Name("D100".into())
}
);
assert_eq!(
args(&["set", "--name", "D100", "power=150"]).unwrap().command,
Command::Set {
device: Device::Name("D100".into()),
target: ControlTarget::Power { watts: 150 },
simulation: false,
hold: Duration::from_secs(15),
}
);
}
#[test]
fn set_parses_every_channel() {
let cmd = args(&["set", "aa:bb", "gradient=4.5"]).unwrap().command;
assert_eq!(
cmd,
Command::Set {
device: Device::Address("aa:bb".into()),
target: ControlTarget::Gradient { percent: 4.5 },
simulation: false,
hold: Duration::from_secs(15),
}
);
let cmd = args(&["set", "aa:bb", "sim=-3.0", "--secs", "4"])
.unwrap()
.command;
assert_eq!(
cmd,
Command::Set {
device: Device::Address("aa:bb".into()),
target: ControlTarget::Gradient { percent: -3.0 },
simulation: true,
hold: Duration::from_secs(4),
}
);
}
#[test]
fn target_parsing_covers_aliases_and_signs() {
assert_eq!(
parse_target("grade=-7.5").unwrap(),
(ControlTarget::Gradient { percent: -7.5 }, false)
);
assert_eq!(
parse_target("res=30").unwrap(),
(ControlTarget::Resistance { level: 30 }, false)
);
assert_eq!(
parse_target("watts=250").unwrap(),
(ControlTarget::Power { watts: 250 }, false)
);
assert_eq!(
parse_target("SIM=6").unwrap(),
(ControlTarget::Gradient { percent: 6.0 }, true)
);
// Colon works too, for shells that dislike `=`.
assert_eq!(
parse_target("power:100").unwrap(),
(ControlTarget::Power { watts: 100 }, false)
);
}
#[test]
fn target_parsing_rejects_nonsense() {
assert!(parse_target("gradient").is_err());
assert!(parse_target("gradient=uphill").is_err());
assert!(parse_target("torque=5").is_err());
assert!(parse_target("power=-50").is_err(), "power is unsigned");
assert!(parse_target("resistance=1.5").is_err(), "resistance is integral");
}
#[test]
fn missing_and_unknown_arguments_are_errors() {
assert!(args(&["inspect"]).is_err());
assert!(args(&["set", "aa:bb"]).is_err());
assert!(args(&["scan", "--secs"]).is_err());
assert!(args(&["scan", "--secs", "soon"]).is_err());
assert!(args(&["frobnicate"]).is_err());
assert!(args(&["scan", "--wat"]).is_err());
}
}
+745
View File
@@ -0,0 +1,745 @@
//! The four probe subcommands.
//!
//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug`
//! directly, so they never take FTMS control and can be run safely while
//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which
//! means it exercises the same rate limiting, clamping, acknowledgement
//! handling and SAF-2 shutdown that the app will use.
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use bikecontrol_ble::capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
};
use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent};
use bikecontrol_ble::control_point::ResultCode;
use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
use bikecontrol_ble::{uuids, FtmsError};
use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
use btleplug::platform::Peripheral;
use futures::StreamExt;
use uuid::Uuid;
use crate::cli::Device;
impl Device {
fn selector(&self) -> TrainerSelector {
match self {
Device::Address(a) => TrainerSelector::Address(a.clone()),
Device::Name(n) => TrainerSelector::NameContains(n.clone()),
}
}
}
// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------
/// FR-1.1: list peripherals with name, address, RSSI and advertised services.
pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let kind = if all {
ScanKind::All
} else {
ScanKind::FitnessMachines
};
println!(
"Scanning for {} s ({})...",
duration.as_secs(),
if all {
"everything"
} else {
"fitness machines only — pass --all to see every peripheral"
}
);
let devices = scan::scan(&adapter, duration, kind).await?;
if devices.is_empty() {
println!("\nNothing found.");
println!(
"The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\
and scan again. A Zwift Click wakes on a button press."
);
return Ok(());
}
println!("\n{} device(s):\n", devices.len());
for d in &devices {
print_device(d);
}
Ok(())
}
fn print_device(d: &DiscoveredDevice) {
let kind = if d.is_fitness_machine() {
" [FTMS trainer]"
} else if d.is_zwift_device() {
" [Zwift device]"
} else {
""
};
println!("{} {}{}", d.address, d.label(), kind);
println!(
" rssi: {} tx power: {}",
d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()),
d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into())
);
if d.services.is_empty() {
println!(" services: (none advertised)");
} else {
println!(" services:");
for s in &d.services {
println!(" {}{}", s, named(*s));
}
}
for (id, data) in &d.manufacturer_data {
println!(" manufacturer 0x{id:04x} ({id}): {}", hex(data));
}
for (uuid, data) in &d.service_data {
println!(" service data {uuid}: {}", hex(data));
}
println!();
}
fn named(uuid: Uuid) -> String {
uuids::well_known_name(uuid)
.map(|n| format!(" ({n})"))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------
/// TASK-1: enumerate everything, and decode the capability characteristics.
pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
if let Some(d) = scan::describe(&peripheral).await {
println!("Connected to {} ({})\n", d.address, d.label());
}
println!("=== Services and characteristics ===\n");
let mut has_ftms = false;
for service in peripheral.services() {
if service.uuid == uuids::FITNESS_MACHINE_SERVICE {
has_ftms = true;
}
println!(
"service {}{}{}",
service.uuid,
named(service.uuid),
if service.primary { " [primary]" } else { "" }
);
for ch in &service.characteristics {
println!(
" char {}{}\n properties: {}",
ch.uuid,
named(ch.uuid),
properties(ch.properties)
);
// Reading is safe: every readable characteristic here is
// informational, and it is exactly what Phase 0 needs to see.
if ch.properties.contains(CharPropFlags::READ) {
match peripheral.read(ch).await {
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
Err(e) => println!(" value: <unreadable: {e}>"),
}
}
}
println!();
}
if !has_ftms {
println!(
"!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\
!! It is not an FTMS trainer, or it needs waking.\n"
);
}
println!("=== Fitness Machine Feature (0x2ACC) ===\n");
match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await {
Some(raw) => match FitnessMachineFeature::decode(&raw) {
Ok(f) => print_feature(&raw, f),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Resistance Level Range (0x2AD6) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await {
Some(raw) => match ResistanceLevelRange::decode(&raw) {
Ok(r) => {
let (lo, hi, inc) = r.scaled();
println!(" raw bytes: {}", hex(&raw));
println!(" minimum: {}", r.min);
println!(" maximum: {}", r.max);
println!(" increment: {}", r.increment);
println!(
" if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}"
);
println!(
"\n NOTE: resistance level is a trainer-specific unit. Whether the D100\n\
means raw integers or tenths is TASK-1/TASK-3 — compare these numbers\n\
with what `set resistance=<N>` actually does, and with the resistance\n\
level reported back in Indoor Bike Data.\n"
);
}
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Power Range (0x2AD8) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await {
Some(raw) => match PowerRange::decode(&raw) {
Ok(p) => println!(
" raw bytes: {}\n {} .. {} W, step {} W\n",
hex(&raw),
p.min_w,
p.max_w,
p.increment_w
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
println!("=== Supported Inclination Range (0x2AD5) ===\n");
match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await {
Some(raw) => match InclinationRange::decode(&raw) {
Ok(i) => println!(
" raw bytes: {}\n {} .. {} %, step {} %\n",
hex(&raw),
i.min_percent(),
i.max_percent(),
i.increment_percent()
),
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
},
None => println!(" not present or unreadable\n"),
}
disconnect(&peripheral).await;
Ok(())
}
fn print_feature(raw: &[u8], f: FitnessMachineFeature) {
println!(" raw bytes: {}", hex(raw));
println!(" machine field: 0x{:08x}", f.machine);
println!(" target field: 0x{:08x}\n", f.target);
println!(" Measures:");
let m = f.machine_feature_names();
if m.is_empty() {
println!(" (none)");
}
for name in m {
println!(" - {name}");
}
println!("\n Accepts as targets:");
let t = f.target_feature_names();
if t.is_empty() {
println!(" (none)");
}
for name in t {
println!(" - {name}");
}
println!("\n Answers to the questions Phase 0 is asking:");
println!(
" SetTargetInclination (0x03): {}",
yes_no(f.supports_inclination_target())
);
println!(
" SetTargetResistanceLevel (0x04): {}",
yes_no(f.supports_resistance_target())
);
println!(
" SetTargetPower (0x05): {}",
yes_no(f.supports_power_target())
);
println!(
" SetIndoorBikeSimulationParameters (0x11): {} <-- A-1",
yes_no(f.supports_simulation())
);
println!(
"\n The 0x11 bit is only what the trainer *claims*. Confirm it with\n\
`probe set <addr> sim=4.0`, which writes the op code regardless.\n"
);
}
fn yes_no(b: bool) -> &'static str {
if b {
"advertised"
} else {
"NOT advertised"
}
}
// ---------------------------------------------------------------------------
// monitor
// ---------------------------------------------------------------------------
/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious.
pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| {
anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)")
})?;
let mut notifications = peripheral.notifications().await?;
peripheral.subscribe(&bike_data).await?;
println!(
"Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\
Pedal the trainer — most trainers send nothing at all when stationary.\n\
Press Ctrl-C to stop early.\n",
duration.as_secs()
);
let start = Instant::now();
let mut count: u64 = 0;
let mut failures: u64 = 0;
let deadline = tokio::time::sleep(duration);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
n = notifications.next() => {
let Some(n) = n else {
println!("\nNotification stream ended (the trainer disconnected).");
break;
};
if n.uuid != uuids::INDOOR_BIKE_DATA {
continue;
}
count += 1;
let t = start.elapsed().as_secs_f32();
println!("[{t:7.2}s] #{count} raw 2ad2: {}", hex(&n.value));
match indoor_bike_data::decode(&n.value) {
Ok(d) => print_decoded(&d, n.value.len()),
Err(e) => {
failures += 1;
println!(" DECODE FAILED: {e}");
}
}
println!();
}
}
}
println!(
"\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).",
start.elapsed().as_secs_f32(),
count as f32 / start.elapsed().as_secs_f32().max(0.001)
);
if count > 0 && failures == 0 {
println!("Decoder agrees with the trainer on every packet.");
}
let _ = peripheral.unsubscribe(&bike_data).await;
disconnect(&peripheral).await;
Ok(())
}
fn print_decoded(d: &IndoorBikeData, len: usize) {
println!(
" flags: 0x{:04x} ({})",
d.flags,
flag_names(d.flags)
);
let row = |label: &str, value: Option<String>| {
if let Some(v) = value {
println!(" {label:<10} {v}");
}
};
row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h")));
row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h")));
row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm")));
row("distance:", d.total_distance_m.map(|v| format!("{v} m")));
row("resist:", d.resistance_level.map(|v| v.to_string()));
row("power:", d.instant_power_w.map(|v| format!("{v} W")));
row("avg power:", d.average_power_w.map(|v| format!("{v} W")));
row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal")));
row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string()));
row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string()));
row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm")));
row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}")));
row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s")));
row("remaining:", d.remaining_time_s.map(|v| format!("{v} s")));
if d.consumed != len {
println!(
" !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for",
d.consumed,
len - d.consumed
);
}
}
fn flag_names(flags: u16) -> String {
use indoor_bike_data::flag as f;
let mut names = Vec::new();
// Bit 0 is inverted: speed is present when it is CLEAR.
if flags & f::MORE_DATA == 0 {
names.push("InstantaneousSpeed(bit0 clear)");
} else {
names.push("MoreData(bit0 set: no speed)");
}
for (bit, name) in [
(f::AVERAGE_SPEED, "AvgSpeed"),
(f::INSTANTANEOUS_CADENCE, "Cadence"),
(f::AVERAGE_CADENCE, "AvgCadence"),
(f::TOTAL_DISTANCE, "TotalDistance"),
(f::RESISTANCE_LEVEL, "Resistance"),
(f::INSTANTANEOUS_POWER, "Power"),
(f::AVERAGE_POWER, "AvgPower"),
(f::EXPENDED_ENERGY, "Energy"),
(f::HEART_RATE, "HeartRate"),
(f::METABOLIC_EQUIVALENT, "MET"),
(f::ELAPSED_TIME, "ElapsedTime"),
(f::REMAINING_TIME, "RemainingTime"),
] {
if flags & bit != 0 {
names.push(name);
}
}
if flags & 0xE000 != 0 {
names.push("<reserved bits set>");
}
names.join(" | ")
}
// ---------------------------------------------------------------------------
// set
// ---------------------------------------------------------------------------
/// TASK-2, and the experiment that answers A-1.
pub async fn set(
device: &Device,
target: ControlTarget,
simulation: bool,
hold: Duration,
scan_timeout: Duration,
) -> Result<()> {
let config = FtmsConfig {
use_simulation_mode: simulation,
scan_timeout,
// Discovery: write the op code even when the feature bit is clear, so
// the trainer's own response settles the question rather than our
// reading of its advertisement.
ignore_advertised_features: true,
..FtmsConfig::default()
};
println!("Connecting and requesting FTMS control...");
let client = FtmsClient::connect(device.selector(), config).await?;
println!(
"Control acquired on {} ({}).\n",
client.address(),
client.name().unwrap_or("no name")
);
let caps = client.capabilities();
if let Some(f) = caps.feature {
println!("Trainer advertises target support: {:?}\n", f.target_feature_names());
}
let mut events = client.events();
let mut telemetry = client.telemetry();
let (op, note) = describe_write(&target, simulation);
println!("Writing {op} ({note})...");
let outcome = client.set_target(target).await;
report_outcome(&outcome, simulation);
// Drain the indication that came back, so the raw result code is visible
// even when the write succeeded.
while let Ok(event) = events.try_recv() {
if let FtmsEvent::ControlResponse { op, result } = event {
println!(
" indication: op {:?}, result {} (0x{:02x})",
op,
result,
result.as_u8()
);
}
}
if outcome.is_ok() {
println!(
"\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\
(TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\
Ctrl-C to stop early.\n",
hold.as_secs()
);
let deadline = tokio::time::sleep(hold);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
sample = telemetry.recv() => {
if let Ok(s) = sample {
println!(
" {:6.1}s power {:>5} cadence {:>6} speed {:>7} resistance {:>5}",
s.elapsed_ms as f32 / 1000.0,
s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()),
s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()),
s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()),
s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()),
);
}
}
}
}
}
println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)...");
client.shutdown().await?;
println!("Done.");
Ok(())
}
fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) {
match target {
ControlTarget::Gradient { percent } if simulation => (
"SetIndoorBikeSimulationParameters (0x11)",
format!("grade {percent} %"),
),
ControlTarget::Gradient { percent } => (
"SetTargetInclination (0x03)",
format!("inclination {percent} %"),
),
ControlTarget::Resistance { level } => (
"SetTargetResistanceLevel (0x04)",
format!("level {level}"),
),
ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")),
}
}
fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool) {
match outcome {
Ok(ControlOutcome::Acknowledged { sent }) => {
println!(" ACCEPTED. Trainer acknowledged with Success.");
println!(" value actually transmitted (post-clamp): {sent:?}");
if simulation {
println!(
"\n >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\
>>> FR-2.3 may use 0x11 for gradient."
);
}
}
Ok(ControlOutcome::Superseded) => {
println!(" superseded before transmission (should not happen for a single write)");
}
Err(FtmsError::Rejected { op, result }) => {
println!(" REJECTED. Trainer answered {op} with: {result}");
if simulation && *result == ResultCode::OpCodeNotSupported {
println!(
"\n >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\
>>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\
>>> exactly as the MIT reference implementation does. Low impact —\n\
>>> the app owns the physics (FR-7.1)."
);
}
if *result == ResultCode::ControlNotPermitted {
println!(
" (RequestControl succeeded but the trainer withdrew control — another\n\
app may be connected. Only one BLE host may hold the trainer, per A-3.)"
);
}
}
Err(FtmsError::Unacknowledged { op, timeout_ms }) => {
println!(" NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms.");
println!(" This is the silent-failure mode FR-2.7 exists to catch.");
}
Err(FtmsError::Unsupported(e)) => {
println!(" BLOCKED BEFORE TRANSMISSION: {e}");
}
Err(e) => println!(" FAILED: {e}"),
}
}
// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------
async fn connect(device: &Device, scan_timeout: Duration) -> Result<Peripheral> {
let adapter = scan::default_adapter()
.await
.context("no Bluetooth adapter — is the radio on?")?;
let selector = device.selector();
println!("Looking for {}...", selector.describe());
let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout)
.await
.with_context(|| {
format!(
"could not find {}. The trainer may be asleep — pedal it and try again (A-4)",
selector.describe()
)
})?;
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await.context("connect failed")?;
}
peripheral
.discover_services()
.await
.context("service discovery failed")?;
Ok(peripheral)
}
async fn disconnect(peripheral: &Peripheral) {
if let Err(e) = peripheral.disconnect().await {
tracing::debug!(error = %e, "disconnect failed");
}
}
fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option<Characteristic> {
peripheral.characteristics().into_iter().find(|c| c.uuid == uuid)
}
async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option<Vec<u8>> {
let ch = find_characteristic(peripheral, uuid)?;
peripheral.read(&ch).await.ok()
}
/// Render a characteristic's bytes as text when they look like a string —
/// Device Information holds model and firmware numbers this way.
fn as_text(v: &[u8]) -> String {
if !v.is_empty()
&& v.iter()
.all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r')
{
format!("\"{}\"", String::from_utf8_lossy(v).trim())
} else {
String::new()
}
}
fn properties(p: CharPropFlags) -> String {
let mut out = Vec::new();
for (flag, name) in [
(CharPropFlags::BROADCAST, "broadcast"),
(CharPropFlags::READ, "read"),
(CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"),
(CharPropFlags::WRITE, "write"),
(CharPropFlags::NOTIFY, "notify"),
(CharPropFlags::INDICATE, "indicate"),
(
CharPropFlags::AUTHENTICATED_SIGNED_WRITES,
"authenticated-signed-writes",
),
(CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"),
] {
if p.contains(flag) {
out.push(name);
}
}
if out.is_empty() {
"(none)".to_string()
} else {
out.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_ble::indoor_bike_data::flag;
#[test]
fn flag_names_call_out_the_inverted_bit_zero() {
// Bit 0 clear means speed IS present.
assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)"));
// Bit 0 set means it is not.
assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)"));
}
#[test]
fn flag_names_list_every_present_field() {
let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER);
assert!(names.contains("Cadence"));
assert!(names.contains("Power"));
assert!(!names.contains("HeartRate"));
}
#[test]
fn flag_names_flag_reserved_bits() {
assert!(flag_names(0x8000).contains("<reserved bits set>"));
assert!(!flag_names(0x0001).contains("<reserved bits set>"));
}
#[test]
fn device_selector_mapping() {
assert_eq!(
Device::Address("AA:BB".into()).selector(),
TrainerSelector::Address("AA:BB".into())
);
assert_eq!(
Device::Name("D100".into()).selector(),
TrainerSelector::NameContains("D100".into())
);
}
#[test]
fn describe_write_names_the_op_code() {
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0,
"SetTargetInclination (0x03)"
);
assert_eq!(
describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0,
"SetIndoorBikeSimulationParameters (0x11)"
);
assert_eq!(
describe_write(&ControlTarget::Resistance { level: 10 }, false).0,
"SetTargetResistanceLevel (0x04)"
);
assert_eq!(
describe_write(&ControlTarget::Power { watts: 100 }, false).0,
"SetTargetPower (0x05)"
);
}
#[test]
fn as_text_only_renders_printable_payloads() {
assert_eq!(as_text(b"D100"), "\"D100\"");
assert_eq!(as_text(&[0x00, 0x01, 0xff]), "");
assert_eq!(as_text(&[]), "");
}
#[test]
fn properties_are_listed_in_order() {
assert_eq!(
properties(CharPropFlags::READ | CharPropFlags::INDICATE),
"read, indicate"
);
assert_eq!(properties(CharPropFlags::empty()), "(none)");
}
}
+68 -1
View File
@@ -1 +1,68 @@
fn main() { println!("probe: not yet implemented"); }
//! `probe` — BLE protocol discovery against real hardware.
//!
//! This is the Phase 0 tool from REQUIREMENTS.md §9: TASK-1 (enumerate the
//! D100's services, dump its capability characteristics, log decoded Indoor
//! Bike Data, resolve whether op code `0x11` works) and TASK-2 (write a control
//! command and confirm a physical resistance change).
//!
//! It is intentionally separate from the app: it prints raw bytes next to
//! decoded values (NFR-8) so that a decoder bug shows up as a disagreement on
//! screen rather than as a strange number in a chart.
mod cli;
mod commands;
use std::time::Duration;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
/// How long to look for the device before giving up.
const SCAN_TIMEOUT: Duration = Duration::from_secs(20);
#[tokio::main]
async fn main() -> Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {
Ok(args) => args,
Err(e) => {
eprintln!("error: {e}\n");
eprint!("{}", cli::USAGE);
std::process::exit(2);
}
};
init_logging(args.verbose);
match args.command {
cli::Command::Help => {
print!("{}", cli::USAGE);
Ok(())
}
cli::Command::Scan { duration, all } => commands::scan_cmd(duration, all).await,
cli::Command::Inspect { device } => commands::inspect(&device, SCAN_TIMEOUT).await,
cli::Command::Monitor { device, duration } => {
commands::monitor(&device, duration, SCAN_TIMEOUT).await
}
cli::Command::Set {
device,
target,
simulation,
hold,
} => commands::set(&device, target, simulation, hold, SCAN_TIMEOUT).await,
}
}
fn init_logging(verbose: bool) {
// `-v` turns on the raw-frame logging required by NFR-8. RUST_LOG still
// wins, so `RUST_LOG=trace` gets every notification.
let default = if verbose {
"bikecontrol_ble=debug,probe=debug,info"
} else {
"warn"
};
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default.into()))
.with_target(false)
.without_time()
.init();
}