Files
BikeControl/crates/probe/src/cli.rs
T
dtourolleandClaude Opus 5 c1c6ca390c Read the rejection properly: it was a command id, not a key refusal
Makinolo's Zwift Ride write-up gives the client command format — `00`
then protobuf field 1 with the parameter, so `00 08 00` is the
information request and `00 08 82 06` is parameter 770. Which explains
what the pod has been telling us all along.

We wrote frames beginning `0xff`. The pod answered `3e 08 ff 01 10 05` —
`{1: 255, 2: 5}`. **255 is 0xff**: our own first byte, echoed back as the
command id, with a status. It was never rejecting a key exchange; it was
saying "command 255, unsupported". `0xff` is a device-to-app notification
type and we were writing it back as though it were a command.

The same write-up records that Zwift "got rid of the Bluetooth
communication encryption they were using for the Play and the Click" —
and the Click v2 is newer than the Ride. So the crypto gate this line of
work assumed may not exist at all, which fits the plain fact that the
cleartext buttons work for the first fifty seconds.

That reopens A-2 from a better angle. It calls the thing that removes the
daily unlock a **keep-alive**: a periodic message, not a credential. So
`--keepalive <secs>` sends a chosen frame on a timer for the whole run
and lets the paddle oracle answer, and `info` and `param770` are
variants — the two commands the write-up documents, in the shape it
documents them.

If a periodic `00 08 00` holds the paddles open past the cliff, the fix
is a heartbeat in the controller supervisor and no cryptography at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:53:43 +02:00

489 lines
18 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
unlock <ADDR> Answer the pod's key offer and see whether its paddles survive
past the ~50 s cliff (--candidate ours|play|echo; omit for a
control run that answers nothing). --sweep sends every known
variant in one connection and compares the pod's replies —
no button presses needed. --variant <name> sends exactly one
frame; --keepalive <secs> sends it on a timer all run
(variants: info, param770, compressed+ours, compressed+none,
compressed+trailer, uncompressed+ours, play-rideon)
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
subscribe to everything and print each notification's
characteristic, length and bytes, undecoded
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, unlock 150)
--all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
`listen` greets by default, because a Click that has not been
greeted sends nothing and an empty capture proves nothing
--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,
},
/// Raw GATT interrogation: the whole tree including descriptors, then every
/// notifying characteristic dumped as unparsed bytes.
///
/// Deliberately knows nothing about FTMS or Zwift. When a device works on
/// one platform and not another, the question is which characteristic
/// carried what and how long it was — and any decoding layer is exactly
/// what you cannot trust while asking it.
Listen {
device: Device,
duration: Duration,
/// Write the Zwift `RideOn` handshake first. A Click streams nothing
/// until it is greeted, so listening alone hears silence from one.
handshake: bool,
},
/// Does answering the pod's key offer keep its paddles alive?
///
/// One candidate reply per run, then two minutes of watching the paddle
/// bits. See `unlock.rs` for what is being tested and why a guess is
/// affordable here.
Unlock {
device: Device,
duration: Duration,
/// Which field-2 marker to answer with; `None` sends nothing and is the
/// control run.
candidate: Option<String>,
/// Send every variant in one connection and compare the pod's replies.
/// Needs no button presses, so it works on a pod that has stopped
/// reporting them.
sweep: bool,
/// Send exactly one named variant, so its effect is unambiguous.
variant: Option<String>,
/// Send that variant on a timer for the whole run, rather than once.
keepalive: Option<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 candidate: Option<String> = None;
let mut sweep = false;
let mut variant: Option<String> = None;
let mut keepalive: Option<u64> = 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:?}"))?,
);
}
"--sweep" => sweep = true,
"--keepalive" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow!("--keepalive needs a value in seconds"))?
.clone();
keepalive = Some(
v.parse()
.map_err(|_| anyhow!("--keepalive expects whole seconds, got {v:?}"))?,
);
}
"--variant" => {
i += 1;
variant = Some(
args.get(i)
.ok_or_else(|| anyhow!("--variant needs a value"))?
.clone(),
);
}
"--candidate" => {
i += 1;
candidate = Some(
args.get(i)
.ok_or_else(|| anyhow!("--candidate needs a value"))?
.clone(),
);
}
"--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)),
}
}
"listen" => Command::Listen {
device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(60)),
handshake: !no_handshake,
},
"unlock" => Command::Unlock {
device: device(&positional, 1)?,
// Long enough to cross the ~50 s cliff twice over: a candidate that
// merely delays the failure must not read as one that fixed it.
duration: Duration::from_secs(secs.unwrap_or(150)),
candidate: candidate.clone(),
sweep,
variant: variant.clone(),
keepalive: keepalive.map(Duration::from_secs),
},
"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());
}
}