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`"),
};
+389 -1
View File
@@ -16,7 +16,7 @@ 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_ble::{uuids, zwift, FtmsError};
use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
use btleplug::platform::Peripheral;
@@ -584,6 +584,394 @@ fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool)
}
}
// ---------------------------------------------------------------------------
// zwift
// ---------------------------------------------------------------------------
/// TASK-0: connect to whatever advertises Zwift's custom service, try the
/// `RideOn` handshake unencrypted, and log every frame that comes back.
///
/// Points at either end of the open question. Against a **Click v2** it tests
/// A-3 — whether the unencrypted path still yields button events on a v2.
/// Against the **trainer** it asks what the D100 is doing with a Zwift service
/// at all; if it is the virtual-shifting endpoint, the Click may belong to the
/// trainer rather than to us.
///
/// Nothing here interprets a frame as a gear change or drives resistance. It
/// prints bytes. Everything the protocol module claims (§2.3.1) is a hypothesis
/// until the hex on screen agrees with it.
pub async fn zwift_cmd(
device: &Device,
duration: Duration,
no_handshake: bool,
buttons_only: bool,
scan_timeout: Duration,
) -> Result<()> {
let peripheral = connect(device, scan_timeout).await?;
if let Some(d) = scan::describe(&peripheral).await {
println!("Connected to {} ({})", d.address, d.label());
match d.zwift_kind() {
Some(kind) => println!("Advertised as: {}", kind.describe()),
None => println!(
"No Zwift manufacturer data in the advertisement — this is not a controller,\n\
or it was already connected when we found it."
),
}
println!();
}
// A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Take
// whichever is present rather than assuming, because which one a device
// speaks is itself a finding.
let service = zwift::SERVICES
.iter()
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
let Some(service) = service else {
println!("!! This peripheral exposes no known Zwift service. Looked for:");
for want in zwift::SERVICES {
println!(" {want}{}", zwift_named(want));
}
println!("!! Services it does expose:");
for s in peripheral.services() {
println!(" {}{}", s.uuid, named(s.uuid));
}
disconnect(&peripheral).await;
return Ok(());
};
println!("=== Zwift service {}{} ===\n", service.uuid, zwift_named(service.uuid));
for ch in &service.characteristics {
println!(
" char {}{}\n properties: {}",
ch.uuid,
zwift_named(ch.uuid),
properties(ch.properties)
);
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!();
// Subscribe to everything that can talk before writing anything, so the
// handshake reply cannot land before we are listening.
let mut notifications = peripheral.notifications().await?;
let listening: Vec<Characteristic> = service
.characteristics
.iter()
.filter(|c| {
c.properties
.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
})
.cloned()
.collect();
if listening.is_empty() {
println!("!! Nothing in this service notifies or indicates — there is nothing to listen to.");
disconnect(&peripheral).await;
return Ok(());
}
for ch in &listening {
match peripheral.subscribe(ch).await {
Ok(()) => println!("Subscribed to {}{}", ch.uuid, zwift_named(ch.uuid)),
Err(e) => println!("Could not subscribe to {}: {e}", ch.uuid),
}
}
println!();
let start = Instant::now();
let mut frames: u64 = 0;
if no_handshake {
println!("--no-handshake: writing nothing, just listening.\n");
} else if let Some(sync_rx) = writable(&service) {
if sync_rx.uuid != zwift::SYNC_RX {
println!(
"Sync RX ({}) is absent; using {} instead, which is the only writable\n\
characteristic in this service.\n",
zwift::SYNC_RX,
sync_rx.uuid
);
}
frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
} else {
println!("Nothing in this service is writable — cannot hand shake. Listening only.\n");
}
if buttons_only {
println!(
"=== BUTTON MAPPING — GO ===\n\n\
Press one button at a time, holding each for about 2 s with a gap between.\n\
Only state changes are printed, so each press is one PRESS and one RELEASE.\n\
{} s to go; Ctrl-C to stop early.\n",
duration.saturating_sub(start.elapsed()).as_secs()
);
} else {
println!(
"Listening for {} s. Press the Click's paddles and D-pad; press Ctrl-C to stop.\n",
duration.as_secs()
);
}
let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
tokio::pin!(deadline);
// Only meaningful in --buttons mode: the mask as of the previous frame, so
// the ~10 Hz repeat while a button is held collapses to one line.
let mut last_mask: Option<u32> = None;
let mut presses: u64 = 0;
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 device disconnected).");
break;
};
frames += 1;
let elapsed = start.elapsed().as_secs_f32();
if buttons_only {
if let Some(mask) = button_mask(&n.value) {
if last_mask != Some(mask.raw) {
last_mask = Some(mask.raw);
if !mask.is_idle() {
presses += 1;
}
print_transition(&mask, elapsed, presses);
}
continue;
}
}
print_zwift_frame(n.uuid, &n.value, elapsed, frames);
}
}
}
println!("\n{frames} frame(s) in {:.1} s.", start.elapsed().as_secs_f32());
if frames == 0 {
println!(
"Nothing arrived. Either the handshake is wrong, or the unlock has expired —\n\
re-pair in the Zwift app and try again within the day (§2.3)."
);
}
for ch in &listening {
let _ = peripheral.unsubscribe(ch).await;
}
disconnect(&peripheral).await;
Ok(())
}
/// Write each candidate handshake in turn, waiting briefly for a reply after
/// each. Returns how many frames arrived during the attempts.
///
/// Which two bytes follow `RideOn` is the unverified part of §2.3.1, so this
/// tries them rather than betting on one. It stops at the first `RideOn` reply
/// — that is the answer to TASK-0, and writing further handshakes after a
/// successful one would only confuse the session.
async fn handshake(
peripheral: &Peripheral,
sync_rx: &Characteristic,
notifications: &mut (impl futures::Stream<Item = btleplug::api::ValueNotification> + Unpin),
start: Instant,
) -> Result<u64> {
// WriteWithoutResponse when the characteristic allows it: the Zwift
// references use it, and a device that never sends a write response would
// otherwise stall us for the full BLE timeout.
let write_type = if sync_rx
.properties
.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
{
btleplug::api::WriteType::WithoutResponse
} else {
btleplug::api::WriteType::WithResponse
};
println!("=== Handshake ===\n");
let mut frames = 0;
for (label, suffix) in zwift::HANDSHAKE_CANDIDATES {
let frame = zwift::handshake(suffix);
println!("-> {} : {}", label, hex(&frame));
if let Err(e) = peripheral.write(sync_rx, &frame, write_type).await {
println!(" write failed: {e}");
continue;
}
// Long enough for a device that is going to answer to have answered.
let window = tokio::time::sleep(Duration::from_millis(1500));
tokio::pin!(window);
let mut answered = false;
loop {
tokio::select! {
_ = &mut window => break,
n = notifications.next() => {
let Some(n) = n else { break };
frames += 1;
print_zwift_frame(n.uuid, &n.value, start.elapsed().as_secs_f32(), frames);
if zwift::is_ride_on_reply(&n.value) {
answered = true;
}
}
}
}
if answered {
println!("\n RideOn acknowledged — this is the handshake the device wants.");
println!(" TASK-0 answered: the unencrypted path is open.\n");
return Ok(frames);
}
println!(" no RideOn reply.\n");
}
println!(
"None of the candidate handshakes drew a RideOn reply. Either the suffix is\n\
something else, or this device requires the encrypted handshake (§2.3.1).\n\
Frames may still arrive unprompted — keep watching.\n"
);
Ok(frames)
}
/// Print one Zwift frame: raw hex first, then whatever we think it means.
fn print_zwift_frame(uuid: Uuid, raw: &[u8], elapsed: f32, index: u64) {
println!(
"[{elapsed:7.2}s] #{index} {}{}: {}",
uuid,
zwift_named(uuid),
hex(raw)
);
if zwift::is_ride_on_reply(raw) {
println!(" RideOn reply, {} byte(s) total", raw.len());
return;
}
let Some(frame) = zwift::parse_frame(raw) else {
println!(" empty frame");
return;
};
println!(" type: {}", frame.kind.describe());
match frame.kind {
zwift::MessageType::ButtonBitmask => match zwift::decode_button_bitmask(frame.payload) {
Ok(m) if m.is_idle() => println!(" mask 0x{:08x} (idle)", m.raw),
Ok(m) => {
let bits: Vec<String> = m.pressed_bits().iter().map(|b| b.to_string()).collect();
println!(
" mask 0x{:08x} PRESSED: bit {}",
m.raw,
bits.join(" + bit ")
);
}
Err(e) => println!(" payload is not a varint ({e})"),
},
zwift::MessageType::ClickButtons => match zwift::decode_click_buttons(frame.payload) {
Ok(b) => println!(
" up: {} down: {}",
pressed(b.up_pressed),
pressed(b.down_pressed)
),
Err(e) => println!(" payload is not two varints ({e}) — 0x37 is not what we assume"),
},
zwift::MessageType::Battery => match zwift::decode_battery(frame.payload) {
Ok(Some(pct)) => println!(" battery: {pct}%"),
Ok(None) => println!(" battery: no field in payload"),
Err(e) => println!(" could not decode ({e})"),
},
_ => {
// Unknown and controller frames: show the protobuf structure if it
// has one, since that is the fastest route to naming the fields.
if let Ok(fields) = zwift::decode_varint_fields(frame.payload) {
if !fields.is_empty() {
let rendered: Vec<String> = fields
.iter()
.map(|(f, v)| format!("field {f} = {v}"))
.collect();
println!(" varints: {}", rendered.join(", "));
}
}
}
}
}
/// The button mask in `raw`, or `None` if this is not a button frame.
fn button_mask(raw: &[u8]) -> Option<zwift::ButtonBitmask> {
let frame = zwift::parse_frame(raw)?;
if frame.kind != zwift::MessageType::ButtonBitmask {
return None;
}
zwift::decode_button_bitmask(frame.payload).ok()
}
/// One line per button state change, for `--buttons`.
fn print_transition(mask: &zwift::ButtonBitmask, elapsed: f32, presses: u64) {
if mask.is_idle() {
println!("[{elapsed:7.2}s] RELEASE --- mask 0x{:08x}", mask.raw);
return;
}
// Name the button where we can, but always show the bit — an unmapped bit
// is exactly the thing this tool exists to surface.
let held: Vec<String> = mask
.pressed_bits()
.iter()
.map(|b| match zwift::Button::from_bit(*b) {
Some(button) => format!("{} (bit {b})", button.label()),
None => format!("UNMAPPED bit {b}"),
})
.collect();
println!(
"[{elapsed:7.2}s] PRESS #{presses:<3} {:<24} mask 0x{:08x}",
held.join(" + "),
mask.raw
);
}
/// The characteristic to write the handshake to: the documented sync RX if the
/// device has it, otherwise the first writable one. On a service whose layout
/// we have never seen, "the only thing that accepts a write" is the best
/// available guess.
fn writable(service: &btleplug::api::Service) -> Option<Characteristic> {
let writable_flags = CharPropFlags::WRITE | CharPropFlags::WRITE_WITHOUT_RESPONSE;
service
.characteristics
.iter()
.find(|c| c.uuid == zwift::SYNC_RX && c.properties.intersects(writable_flags))
.or_else(|| {
service
.characteristics
.iter()
.find(|c| c.properties.intersects(writable_flags))
})
.cloned()
}
fn pressed(b: bool) -> &'static str {
if b {
"PRESSED"
} else {
"released"
}
}
/// Name a UUID, checking Zwift's custom space as well as the SIG's.
fn zwift_named(uuid: Uuid) -> String {
zwift::well_known_name(uuid)
.map(|n| format!(" ({n})"))
.unwrap_or_else(|| named(uuid))
}
// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------
+8
View File
@@ -49,6 +49,14 @@ async fn main() -> Result<()> {
simulation,
hold,
} => commands::set(&device, target, simulation, hold, SCAN_TIMEOUT).await,
cli::Command::Zwift {
device,
duration,
no_handshake,
buttons_only,
} => {
commands::zwift_cmd(&device, duration, no_handshake, buttons_only, SCAN_TIMEOUT).await
}
}
}