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",