The pod answers the Play handshake, and declines it

`--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 <name>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:45:38 +02:00
co-authored by Claude Opus 5
parent 9ab5b5530b
commit 34861e4e04
5 changed files with 95 additions and 2 deletions
+12
View File
@@ -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<String>,
},
/// 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<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut name: Option<String> = None;
let mut candidate: Option<String> = None;
let mut sweep = false;
let mut variant: Option<String> = None;
let mut help = false;
let mut positional: Vec<String> = Vec::new();
@@ -171,6 +174,14 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
);
}
"--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<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
duration: Duration::from_secs(secs.unwrap_or(150)),
candidate: candidate.clone(),
sweep,
variant: variant.clone(),
},
"zwift" => Command::Zwift {
device: device(&positional, 1)?,