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
+113 -5
View File
@@ -724,6 +724,7 @@ pub async fn zwift_cmd(
// 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 {
@@ -974,6 +975,7 @@ pub async fn unlock_cmd(
device: &Device,
duration: Duration,
candidate: Option<&str>,
sweep: bool,
scan_timeout: Duration,
) -> Result<()> {
use crate::unlock;
@@ -1038,6 +1040,14 @@ pub async fn unlock_cmd(
let mut answered = 0u32;
let mut offers = 0u32;
let mut last_status: Option<unlock::Status> = None;
// When the pod flipped its status flag, which is the cliff this run is
// measured against — better than a constant, because the pod says so.
let mut sent: Vec<&'static str> = Vec::new();
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
let mut last_mask: Option<u32> = None;
// When the pod flipped its status flag, which is the cliff this run is
// measured against — better than a constant, because the pod says so.
let mut flip_at: Option<Duration> = None;
let deadline = tokio::time::sleep(duration);
tokio::pin!(deadline);
@@ -1074,7 +1084,24 @@ pub async fn unlock_cmd(
),
Err(e) => println!(" !! {e}"),
}
if let Some(c) = candidate {
if sweep {
// One connection, every variant, because the pod hands
// back a reason for each. Spaced so a late reply cannot
// be attributed to the next thing we sent.
for v in unlock::VARIANTS {
let frame = (v.build)(&local, &offer);
println!("\n -> {:<20} {}", v.name, hex(&frame));
println!(" {}", v.why);
if let Err(e) = write_frame(&peripheral, &sync_rx, &frame).await {
println!(" !! could not send: {e}");
continue;
}
answered += 1;
sent.push(v.name);
tokio::time::sleep(Duration::from_millis(1200)).await;
}
println!();
} else if let Some(c) = candidate {
let frame = unlock::reply_frame(&local.compressed, c.marker);
match write_frame(&peripheral, &sync_rx, &frame).await {
Ok(()) => {
@@ -1093,20 +1120,75 @@ pub async fn unlock_cmd(
"[{at:7.2}s] STATUS flag={} timer={}",
status.flag, status.timer
);
// The pod telling us, in its own words, that whatever
// grace it was extending has ended. Everything before
// this is preamble; the run is only evidence from here.
// A *transition*, not merely a first sighting. These
// runs opened with flag already 1 — the pod remembers
// being past the cliff across reconnects — and calling
// that "the cliff at 2.3s" is a reading, not a fact.
let was_zero = last_status.is_some_and(|s| s.flag == 0);
if status.flag == 1 && was_zero && flip_at.is_none() {
flip_at = Some(start.elapsed());
println!(
"\n >>> THE CLIFF. Keep pressing both paddles and the D-pad for\n >>> another 60 s — everything before this line proves nothing.\n"
);
}
last_status = Some(status);
}
continue;
}
if let Some(r) = unlock::parse_response(&n.value) {
let to = sent.last().copied().unwrap_or("(unsolicited)");
println!("[{at:7.2}s] REPLY code={} detail={} <- {to}", r.code, r.detail);
responses.push((to, r));
continue;
}
if button_mask(&n.value).is_none()
&& unlock::parse_key_offer(&n.value).is_none()
&& unlock::parse_status(&n.value).is_none()
&& unlock::parse_response(&n.value).is_none()
{
println!("[{at:7.2}s] other {}", hex(&n.value));
}
if let Some(mask) = button_mask(&n.value) {
// Printed, not merely counted. An operator pressing buttons
// into a silent terminal cannot tell a working run from a
// dead pod, and will reasonably conclude the latter.
if last_mask != Some(mask.raw) {
last_mask = Some(mask.raw);
let paddles = mask.raw & ((1 << 8) | (1 << 12));
let which = match paddles {
p if p == (1 << 8) | (1 << 12) => "",
p if p & (1 << 8) == 0 => " <- − PADDLE",
_ => " <- + PADDLE",
};
println!(
"[{at:7.2}s] buttons 0x{:08x}{}{}",
mask.raw,
if mask.is_idle() { " (idle)" } else { "" },
which
);
}
verdict.observe(mask.raw);
}
}
}
}
let cliff = Duration::from_secs(60);
// The pod's own flip where we saw it; otherwise the ~50 s the captures show.
let cliff = flip_at.unwrap_or(Duration::from_secs(50));
println!("\n=== verdict ===");
match (flip_at, last_status) {
(Some(t), _) => println!(" cliff (flag 0->1): {:.1}s", t.as_secs_f32()),
(None, Some(s)) if s.flag == 1 => println!(
" cliff: already past it when we connected — the pod kept\n \x20 that state across the reconnect"
),
_ => println!(" cliff: never flipped"),
}
println!(" key offers seen: {offers}");
println!(" answered: {answered}");
println!(
@@ -1120,6 +1202,28 @@ pub async fn unlock_cmd(
verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
);
if sweep {
println!("\n variant reply");
for (name, r) in &responses {
println!(" {name:<22} code={} detail={}", r.code, r.detail);
}
let distinct: std::collections::BTreeSet<_> =
responses.iter().map(|(_, r)| (r.code, r.detail)).collect();
if responses.is_empty() {
println!("\n The pod answered none of them, which is itself a change from\n the runs where it answered `ff 03 00` frames.");
} else if distinct.len() == 1 {
println!(
"\n Every variant drew the same reply, so none of the things varied —\n the marker, the trailer, the key encoding, the envelope — is what\n it is objecting to."
);
} else {
println!(
"\n The reply MOVED. Whichever variant differs is the thread to pull:\n that is the first time this device has told us we got warmer."
);
}
disconnect(&peripheral).await;
return Ok(());
}
if verdict.paddle_edges == 0 && verdict.other_edges == 0 {
println!(
"\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\
@@ -1132,12 +1236,16 @@ pub async fn unlock_cmd(
);
} else if verdict.last_paddle.is_some_and(|t| t > cliff) {
println!(
"\n HELD — a paddle edge arrived after {}s, past the cliff.\n\
"\n HELD — a paddle edge arrived {:.1}s past the cliff.\n\
Worth repeating before believing: run it again, and run the control.",
cliff.as_secs()
(verdict.last_paddle.unwrap() - cliff).as_secs_f32()
);
} else {
println!("\n INCONCLUSIVE — the run ended before the cliff, or the paddles were idle.");
println!(
"\n INCONCLUSIVE — nothing was pressed after the cliff at {:.1}s.\n\
The run has to keep going, with fingers on the buttons, well past it.",
cliff.as_secs_f32()
);
}
disconnect(&peripheral).await;