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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user