Files
BikeControl/crates/probe/src/cli.rs
T
dtourolleandClaude Opus 5 57eb5e809b Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:28 +02:00

395 lines
13 KiB
Rust

//! 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
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
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, zwift 60)
--all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name
--no-handshake `zwift`: subscribe and listen without writing RideOn
--buttons `zwift`: collapse the ~10 Hz button stream to one line per
press and release, for mapping bits to physical buttons
-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. `zwift` is
read-mostly: the only thing it ever writes is the RideOn handshake.
The Click must be unlocked in the free Zwift app first — pair it there, hold it for
~30 s, then quit Zwift. The unlock lasts about a day (REQUIREMENTS.md §2.3).
";
#[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,
},
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
/// it — a Click, or the trainer itself.
Zwift {
device: Device,
duration: Duration,
/// True to listen only, writing nothing at all.
no_handshake: bool,
/// True to print one line per button state change instead of every
/// frame — the mode for mapping bits to physical buttons.
buttons_only: bool,
},
}
/// 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 no_handshake = false;
let mut buttons_only = 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,
"--no-handshake" => no_handshake = true,
"--buttons" => buttons_only = 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)),
}
}
"zwift" => Command::Zwift {
device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(60)),
no_handshake,
buttons_only,
},
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());
}
}