From 34861e4e0443f10e0edff164933401d2a34759e3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 19:45:38 +0200 Subject: [PATCH] The pod answers the Play handshake, and declines it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--sweep` against a recovered pod, and two findings worth more than the thing it was built to test. **The v2 speaks the documented Play handshake.** Sent `RideOn 01 02` plus a raw 64-byte key — §3.5's format, no protobuf envelope, the one shape we had never tried because the offer's protobuf framing made it look irrelevant — and the pod replied `RideOn 02 03` followed by 64 **zero** bytes. That is the Play reply shape with the key zeroed: it understood the question and refused to answer it. The four protobuf variants all drew `0x3e {1: 255, 2: 5}`. **And the stream then went to all-zero frames**, button frames included, at their usual rate. We can put the pod into a state where it emits nothing but zeros. It recovers by itself. Also recorded: the stuck state is not permanent. The pod that opened three sessions already past the cliff, with the `+` paddle bit pinned in its hello, came back clean — `flag=0`, idle bitmask, `−` paddle reporting. The unit is sealed and was never opened. The sweep's attribution is not sound and the commit does not pretend otherwise: five writes inside six seconds, every reply in one burst at +11.37 s. `--variant ` now sends exactly one frame per connection, which is the only way to learn which one does what. Zero frames are counted and named rather than scrolling past as `other`. Co-Authored-By: Claude Opus 5 (1M context) --- REQUIREMENTS.md | 23 ++++++++++++++++++ crates/probe/src/cli.rs | 12 +++++++++ crates/probe/src/commands.rs | 47 +++++++++++++++++++++++++++++++++++- crates/probe/src/main.rs | 11 ++++++++- crates/probe/src/unlock.rs | 4 +++ 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index f3772e0..dbda913 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -265,6 +265,29 @@ the daily unlock is needed on this path are both answered: it is, and this is th > two fields the Play description has no room for. Implementing Makinolo's spec verbatim > would be implementing a different device's protocol. +> **The v2 answers the Play handshake — 2026-08-27.** Sent `RideOn 01 02` plus a raw +> 64-byte key (the §3.5 format, no protobuf envelope), the pod replied +> `52 69 64 65 4f 6e 02 03` followed by **64 zero bytes** — the Play reply shape, with the +> key zeroed. The four protobuf-shaped variants all drew `0x3e {1: 255, 2: 5}` instead. So +> the device understands the documented handshake and declines to complete it, which is a +> refusal rather than a misunderstanding. +> +> Immediately afterwards the notification stream went to **all-zero frames** — including the +> 7-byte button frames, at their usual ~10 Hz. Whether that is the pod encrypting against a +> key it never agreed, or a firmware path nobody meant to reach, is unknown. It is +> recoverable: the pod came back on its own by the next session. +> +> **Attribution is not yet sound.** All five variants went out inside six seconds and every +> reply arrived in one burst at +11.37 s, so which write triggered the zeros is not +> established. `probe unlock --variant ` sends exactly one per connection, which is +> how that gets settled. + +> **The stuck state is not permanent.** A pod that had opened three consecutive sessions +> already past the cliff — `flag=1` from the first status frame, the escalated 60-byte +> offer, and a hello bitmask of `ffc3ffff1f` with the `+` paddle bit pinned — came back +> clean: `flag=0`, idle bitmask, `−` paddle reporting normally. No battery pull; the unit is +> sealed and was never opened. + > **The missing half.** Every frame above is device → app. Nothing in any capture shows what > a *working* client writes back, and that is precisely what a responder has to send. It > cannot be inferred from these five frames. Getting it means capturing both directions of a diff --git a/crates/probe/src/cli.rs b/crates/probe/src/cli.rs index f00e671..74a1b4a 100644 --- a/crates/probe/src/cli.rs +++ b/crates/probe/src/cli.rs @@ -109,6 +109,8 @@ pub enum Command { /// Needs no button presses, so it works on a pod that has stopped /// reporting them. sweep: bool, + /// Send exactly one named variant, so its effect is unambiguous. + variant: Option, }, /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// it — a Click, or the trainer itself. @@ -147,6 +149,7 @@ pub fn parse>(argv: I) -> Result { let mut name: Option = None; let mut candidate: Option = None; let mut sweep = false; + let mut variant: Option = None; let mut help = false; let mut positional: Vec = Vec::new(); @@ -171,6 +174,14 @@ pub fn parse>(argv: I) -> Result { ); } "--sweep" => sweep = true, + "--variant" => { + i += 1; + variant = Some( + args.get(i) + .ok_or_else(|| anyhow!("--variant needs a value"))? + .clone(), + ); + } "--candidate" => { i += 1; candidate = Some( @@ -253,6 +264,7 @@ pub fn parse>(argv: I) -> Result { duration: Duration::from_secs(secs.unwrap_or(150)), candidate: candidate.clone(), sweep, + variant: variant.clone(), }, "zwift" => Command::Zwift { device: device(&positional, 1)?, diff --git a/crates/probe/src/commands.rs b/crates/probe/src/commands.rs index c4b7030..fb753d8 100644 --- a/crates/probe/src/commands.rs +++ b/crates/probe/src/commands.rs @@ -976,6 +976,7 @@ pub async fn unlock_cmd( duration: Duration, candidate: Option<&str>, sweep: bool, + variant: Option<&str>, scan_timeout: Duration, ) -> Result<()> { use crate::unlock; @@ -1001,6 +1002,19 @@ pub async fn unlock_cmd( ), } + let single = match variant { + None => None, + Some(name) => Some(unlock::variant(name).ok_or_else(|| { + anyhow::anyhow!( + "unknown variant {name:?}. Known: {}", + unlock::VARIANTS.iter().map(|v| v.name).collect::>().join(", ") + ) + })?), + }; + if let Some(v) = single { + println!("Sending exactly one frame this run: {}\n", v.name); + } + let peripheral = connect(device, scan_timeout).await?; if let Some(d) = scan::describe(&peripheral).await { println!("Connected to {} ({})\n", d.address, d.label()); @@ -1044,6 +1058,7 @@ pub async fn unlock_cmd( // 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 zeros: u64 = 0; let mut last_mask: Option = 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. @@ -1084,7 +1099,23 @@ pub async fn unlock_cmd( ), Err(e) => println!(" !! {e}"), } - if sweep { + if let Some(one) = single { + // One write per connection. The sweep's replies came + // back in a single burst six seconds after the first + // write, so "attributed to the last thing sent" was a + // label, not a measurement. This is how you learn which + // frame does what. + let frame = (one.build)(&local, &offer); + println!("\n -> {:<20} {}", one.name, hex(&frame)); + println!(" {}\n", one.why); + match write_frame(&peripheral, &sync_rx, &frame).await { + Ok(()) => { + answered += 1; + sent.push(one.name); + } + Err(e) => println!(" !! could not send: {e}"), + } + } else 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. @@ -1146,6 +1177,17 @@ pub async fn unlock_cmd( continue; } + if !n.value.is_empty() && n.value.iter().all(|b| *b == 0) { + zeros += 1; + if zeros == 1 { + println!( + "[{at:7.2}s] ZEROS the stream has gone to all-zero frames \ + — counting from here" + ); + } + continue; + } + if button_mask(&n.value).is_none() && unlock::parse_key_offer(&n.value).is_none() && unlock::parse_status(&n.value).is_none() @@ -1190,6 +1232,9 @@ pub async fn unlock_cmd( _ => println!(" cliff: never flipped"), } println!(" key offers seen: {offers}"); + if zeros > 0 { + println!(" all-zero frames: {zeros} <- the stream stopped carrying data"); + } println!(" answered: {answered}"); println!( " paddle edges: {} (last at {})", diff --git a/crates/probe/src/main.rs b/crates/probe/src/main.rs index 487756e..95e7480 100644 --- a/crates/probe/src/main.rs +++ b/crates/probe/src/main.rs @@ -60,8 +60,17 @@ async fn main() -> Result<()> { duration, candidate, sweep, + variant, } => { - commands::unlock_cmd(&device, duration, candidate.as_deref(), sweep, SCAN_TIMEOUT).await + commands::unlock_cmd( + &device, + duration, + candidate.as_deref(), + sweep, + variant.as_deref(), + SCAN_TIMEOUT, + ) + .await } cli::Command::Zwift { device, diff --git a/crates/probe/src/unlock.rs b/crates/probe/src/unlock.rs index 60943f3..64a3013 100644 --- a/crates/probe/src/unlock.rs +++ b/crates/probe/src/unlock.rs @@ -87,6 +87,10 @@ pub struct Variant { /// writes to the D100 because it puts resistance under a rider; a pod has no /// actuator, and the worst it can do is ignore us. The OAD characteristics stay /// untouched — those *can* brick it, and it is sealed. +pub fn variant(name: &str) -> Option<&'static Variant> { + VARIANTS.iter().find(|v| v.name == name) +} + pub const VARIANTS: &[Variant] = &[ Variant { name: "compressed+ours",