Ask the pod whether answering its key offer keeps the paddles alive

`probe unlock` — the experiment §2.3.3 ends on, not an implementation.

The pod offers a compressed P-256 point and gives up on us when we do not
answer; the paddle bits freeze while the D-pad keeps reporting. What a
working client writes back is the half no capture has, and the public
descriptions are all of the older Play hardware — different message
types, an uncompressed key, a different channel.

But the offer looks like the handshake we already know, moved into a
protobuf envelope: its field 2 is `0x02030000`, and `RESPONSE_START` —
the pod's confirmed cleartext reply marker — is `[0x02, 0x03]`. That
makes the client side a short list rather than a search, and the device
is a perfect oracle: either a paddle edge arrives after the cliff or one
does not, every run, in two minutes.

So the command sends one candidate per run — `ours` (00 09, what we
already write), `play` (01 02, the 2023 client marker), `echo` (02 03,
in case field 2 names the suite rather than the speaker) — and reports
HELD, FAILED or INCONCLUSIVE. Omitting `--candidate` answers nothing and
measures the cliff this pod actually has, which is the control every
result needs.

The verdict deliberately refuses to call a failure from silence: it needs
the D-pad still reporting while the paddles do not, because a pod nobody
touched proves nothing and a dropped link is void rather than negative.

Field 3 of the offer — 40 or 60 bytes, unexplained — is omitted from the
reply. If it is load-bearing no candidate will hold, and that is a
finding too.

p256 is a dependency of the probe alone. The app takes no crypto
dependency on a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:19:29 +02:00
co-authored by Claude Opus 5
parent 135da73e92
commit 4e400cdd3b
6 changed files with 783 additions and 2 deletions
+32 -1
View File
@@ -20,6 +20,9 @@ SUBCOMMANDS:
inspect <ADDR> Connect and dump every service, characteristic and capability
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
unlock <ADDR> Answer the pod's key offer and see whether its paddles survive
past the ~50 s cliff (--candidate ours|play|echo; omit for a
control run that answers nothing)
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
subscribe to everything and print each notification's
@@ -33,7 +36,7 @@ TARGET (for `set`):
OPTIONS:
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
scan 6, monitor 30, set 15, zwift 60)
scan 6, monitor 30, set 15, zwift 60, unlock 150)
--all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
@@ -89,6 +92,18 @@ pub enum Command {
/// until it is greeted, so listening alone hears silence from one.
handshake: bool,
},
/// Does answering the pod's key offer keep its paddles alive?
///
/// One candidate reply per run, then two minutes of watching the paddle
/// bits. See `unlock.rs` for what is being tested and why a guess is
/// affordable here.
Unlock {
device: Device,
duration: Duration,
/// Which field-2 marker to answer with; `None` sends nothing and is the
/// control run.
candidate: Option<String>,
},
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
/// it — a Click, or the trainer itself.
Zwift {
@@ -124,6 +139,7 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut no_handshake = false;
let mut buttons_only = false;
let mut name: Option<String> = None;
let mut candidate: Option<String> = None;
let mut help = false;
let mut positional: Vec<String> = Vec::new();
@@ -147,6 +163,14 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
);
}
"--candidate" => {
i += 1;
candidate = Some(
args.get(i)
.ok_or_else(|| anyhow!("--candidate needs a value"))?
.clone(),
);
}
"--name" => {
i += 1;
name = Some(
@@ -214,6 +238,13 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
duration: Duration::from_secs(secs.unwrap_or(60)),
handshake: !no_handshake,
},
"unlock" => Command::Unlock {
device: device(&positional, 1)?,
// Long enough to cross the ~50 s cliff twice over: a candidate that
// merely delays the failure must not read as one that fixed it.
duration: Duration::from_secs(secs.unwrap_or(150)),
candidate: candidate.clone(),
},
"zwift" => Command::Zwift {
device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(60)),