diff --git a/crates/probe/src/cli.rs b/crates/probe/src/cli.rs index 74a1b4a..81a957b 100644 --- a/crates/probe/src/cli.rs +++ b/crates/probe/src/cli.rs @@ -24,7 +24,10 @@ SUBCOMMANDS: past the ~50 s cliff (--candidate ours|play|echo; omit for a control run that answers nothing). --sweep sends every known variant in one connection and compares the pod's replies — - no button presses needed + no button presses needed. --variant sends exactly one + frame; --keepalive sends it on a timer all run + (variants: info, param770, compressed+ours, compressed+none, + compressed+trailer, uncompressed+ours, play-rideon) zwift Talk to Zwift's custom service: handshake, then log every frame listen Raw GATT: dump services, characteristics and descriptors, then subscribe to everything and print each notification's @@ -111,6 +114,8 @@ pub enum Command { sweep: bool, /// Send exactly one named variant, so its effect is unambiguous. variant: Option, + /// Send that variant on a timer for the whole run, rather than once. + keepalive: Option, }, /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// it — a Click, or the trainer itself. @@ -150,6 +155,7 @@ pub fn parse>(argv: I) -> Result { let mut candidate: Option = None; let mut sweep = false; let mut variant: Option = None; + let mut keepalive: Option = None; let mut help = false; let mut positional: Vec = Vec::new(); @@ -174,6 +180,17 @@ pub fn parse>(argv: I) -> Result { ); } "--sweep" => sweep = true, + "--keepalive" => { + i += 1; + let v = args + .get(i) + .ok_or_else(|| anyhow!("--keepalive needs a value in seconds"))? + .clone(); + keepalive = Some( + v.parse() + .map_err(|_| anyhow!("--keepalive expects whole seconds, got {v:?}"))?, + ); + } "--variant" => { i += 1; variant = Some( @@ -265,6 +282,7 @@ pub fn parse>(argv: I) -> Result { candidate: candidate.clone(), sweep, variant: variant.clone(), + keepalive: keepalive.map(Duration::from_secs), }, "zwift" => Command::Zwift { device: device(&positional, 1)?, diff --git a/crates/probe/src/commands.rs b/crates/probe/src/commands.rs index fb753d8..a18a044 100644 --- a/crates/probe/src/commands.rs +++ b/crates/probe/src/commands.rs @@ -977,6 +977,7 @@ pub async fn unlock_cmd( candidate: Option<&str>, sweep: bool, variant: Option<&str>, + keepalive: Option, scan_timeout: Duration, ) -> Result<()> { use crate::unlock; @@ -1011,8 +1012,16 @@ pub async fn unlock_cmd( ) })?), }; - if let Some(v) = single { - println!("Sending exactly one frame this run: {}\n", v.name); + match (single, keepalive) { + (Some(v), Some(every)) => println!( + "Keep-alive run: sending {} every {}s for the whole run.\n\ + If the paddles are still reporting at the end, that is the fix.\n", + v.name, + every.as_secs() + ), + (Some(v), None) => println!("Sending exactly one frame this run: {}\n", v.name), + (None, Some(_)) => anyhow::bail!("--keepalive needs --variant to say what to send"), + (None, None) => {} } let peripheral = connect(device, scan_timeout).await?; @@ -1059,6 +1068,7 @@ pub async fn unlock_cmd( 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 beats: 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. @@ -1067,6 +1077,15 @@ pub async fn unlock_cmd( let deadline = tokio::time::sleep(duration); tokio::pin!(deadline); + // A-2 calls the thing that removes the daily unlock a *keep-alive*. That is + // a periodic message, not a credential, and the Ride write-up gives the + // shape of one. So: send it on a timer and let the paddles answer. + let mut heartbeat = keepalive.map(|every| { + let mut t = tokio::time::interval(every); + t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + t + }); + loop { tokio::select! { _ = &mut deadline => break, @@ -1074,6 +1093,18 @@ pub async fn unlock_cmd( println!("\nInterrupted."); break; } + _ = async { heartbeat.as_mut().unwrap().tick().await }, if heartbeat.is_some() => { + let Some(v) = single else { continue }; + let frame = (v.build)(&local, &unlock::KeyOffer::default()); + let at = start.elapsed().as_secs_f32(); + match write_frame(&peripheral, &sync_rx, &frame).await { + Ok(()) => { + beats += 1; + println!("[{at:7.2}s] KEEPALIVE #{beats} {} {}", v.name, hex(&frame)); + } + Err(e) => println!("[{at:7.2}s] KEEPALIVE !! {e}"), + } + } n = notifications.next() => { let Some(n) = n else { println!("\nThe device disconnected — the run is void, not a failure."); @@ -1099,7 +1130,10 @@ pub async fn unlock_cmd( ), Err(e) => println!(" !! {e}"), } - if let Some(one) = single { + if keepalive.is_some() { + // The heartbeat is the experiment; firing again here + // would confound which write did what. + } else 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 @@ -1232,6 +1266,9 @@ pub async fn unlock_cmd( _ => println!(" cliff: never flipped"), } println!(" key offers seen: {offers}"); + if beats > 0 { + println!(" keep-alives sent: {beats}"); + } if zeros > 0 { println!(" all-zero frames: {zeros} <- the stream stopped carrying data"); } diff --git a/crates/probe/src/main.rs b/crates/probe/src/main.rs index 95e7480..ff2255f 100644 --- a/crates/probe/src/main.rs +++ b/crates/probe/src/main.rs @@ -61,6 +61,7 @@ async fn main() -> Result<()> { candidate, sweep, variant, + keepalive, } => { commands::unlock_cmd( &device, @@ -68,6 +69,7 @@ async fn main() -> Result<()> { candidate.as_deref(), sweep, variant.as_deref(), + keepalive, SCAN_TIMEOUT, ) .await diff --git a/crates/probe/src/unlock.rs b/crates/probe/src/unlock.rs index 64a3013..44c44a7 100644 --- a/crates/probe/src/unlock.rs +++ b/crates/probe/src/unlock.rs @@ -91,7 +91,31 @@ pub fn variant(name: &str) -> Option<&'static Variant> { VARIANTS.iter().find(|v| v.name == name) } +/// A command, in the form the Zwift Ride protocol write-up documents: the byte +/// `0x00`, then protobuf field 1 carrying the parameter. +/// +/// This is the shape we should have been writing all along. Our `0xff …` frames +/// were being read as *command 255*, and `0x3e {1: 255, 2: 5}` was the device +/// saying so — the command id echoed back with a status, not a rejected key. +pub fn command(param: u64) -> Vec { + let mut frame = vec![0x00]; + field_varint(&mut frame, 1, param); + frame +} + pub const VARIANTS: &[Variant] = &[ + Variant { + name: "info", + why: "the documented information request, `00 08 00` — if the command channel \ + works at all, this is what proves it", + build: |_, _| command(0), + }, + Variant { + name: "param770", + why: "`00 08 82 06`, the other documented command; 770 is 0x0302, which is the \ + pod's own RideOn marker read as a number", + build: |_, _| command(770), + }, Variant { name: "compressed+ours", why: "what we have already sent twice — the control for the sweep", @@ -242,7 +266,7 @@ fn read_varint(b: &[u8], i: &mut usize) -> Option { } /// The pod's key offer, as much of it as we can name. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct KeyOffer { /// Field 1 — 33 bytes, a compressed P-256 point. pub public_key: Vec,