Sweep the handshake variants, since the pod answers back

The first two candidate runs looked like failures and were not. Buried in
them: a `0x3e` frame arriving 90 ms after our write, in both runs, never
otherwise — `{1: 255, 2: 5}`. The pod parsed what we sent and rejected it
with a reason. That is a feedback channel, and it turns this from
guessing into navigating.

Both candidates drew the *same* reason, so the field-2 marker is not what
it objects to. `--sweep` therefore sends every variant down one
connection and prints the reply to each: field 1 alone, the pod's own
trailer echoed back, an uncompressed 65-byte point, and the documented
2023 Play handshake verbatim (`RideOn 01 02` + a raw 64-byte key, no
protobuf at all) — which we had never actually tried, having assumed the
protobuf shape from the offer.

It needs no button presses. That matters now: this pod has stopped
reporting buttons entirely, so the paddle oracle the rest of the command
depends on is unavailable, and a sweep that reads only the reply code
still works.

Fuzzing a pod is not fuzzing a trainer. §2.3 refused unknown writes to
the D100 because it puts resistance under a rider; a Click has no
actuator and the worst it can do is ignore us. The OAD characteristics
stay untouched — those can brick a sealed unit.

Two corrections to the tool while here. Button frames were counted but
never printed, so an operator pressing into a silent terminal could not
tell a working run from a dead pod and reasonably concluded the latter.
And the cliff is now taken from an actual `flag 0 -> 1` transition rather
than the first sighting of a 1 — these runs opened with the flag already
set, the pod having kept that state across the reconnect, and reporting
"cliff at 2.3s" for it was a reading dressed as a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:39:17 +02:00
co-authored by Claude Opus 5
parent 4e400cdd3b
commit 9ab5b5530b
4 changed files with 239 additions and 13 deletions
+10 -1
View File
@@ -22,7 +22,9 @@ SUBCOMMANDS:
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)
control run that answers nothing). --sweep sends every known
variant in one connection and compares the pod's replies —
no button presses needed
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
@@ -103,6 +105,10 @@ pub enum Command {
/// 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,
},
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
/// it — a Click, or the trainer itself.
@@ -140,6 +146,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut buttons_only = false;
let mut name: Option<String> = None;
let mut candidate: Option<String> = None;
let mut sweep = false;
let mut help = false;
let mut positional: Vec<String> = Vec::new();
@@ -163,6 +170,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
);
}
"--sweep" => sweep = true,
"--candidate" => {
i += 1;
candidate = Some(
@@ -244,6 +252,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
// merely delays the failure must not read as one that fixed it.
duration: Duration::from_secs(secs.unwrap_or(150)),
candidate: candidate.clone(),
sweep,
},
"zwift" => Command::Zwift {
device: device(&positional, 1)?,