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>
This commit is contained in:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+31 -2
View File
@@ -20,6 +20,7 @@ SUBCOMMANDS:
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
@@ -29,16 +30,23 @@ TARGET (for `set`):
OPTIONS:
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
scan 6, monitor 30, set 15)
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.
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)]
@@ -62,6 +70,17 @@ pub enum Command {
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.
@@ -83,6 +102,8 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
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();
@@ -94,6 +115,8 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
"-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
@@ -167,6 +190,12 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
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`"),
};