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:
@@ -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
|
> two fields the Play description has no room for. Implementing Makinolo's spec verbatim
|
||||||
> would be implementing a different device's protocol.
|
> 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 <name>` 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
|
> **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
|
> 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
|
> cannot be inferred from these five frames. Getting it means capturing both directions of a
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ pub enum Command {
|
|||||||
/// Needs no button presses, so it works on a pod that has stopped
|
/// Needs no button presses, so it works on a pod that has stopped
|
||||||
/// reporting them.
|
/// reporting them.
|
||||||
sweep: bool,
|
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
|
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||||
/// it — a Click, or the trainer itself.
|
/// 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 name: Option<String> = None;
|
||||||
let mut candidate: Option<String> = None;
|
let mut candidate: Option<String> = None;
|
||||||
let mut sweep = false;
|
let mut sweep = false;
|
||||||
|
let mut variant: Option<String> = None;
|
||||||
let mut help = false;
|
let mut help = false;
|
||||||
let mut positional: Vec<String> = Vec::new();
|
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,
|
"--sweep" => sweep = true,
|
||||||
|
"--variant" => {
|
||||||
|
i += 1;
|
||||||
|
variant = Some(
|
||||||
|
args.get(i)
|
||||||
|
.ok_or_else(|| anyhow!("--variant needs a value"))?
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
"--candidate" => {
|
"--candidate" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
candidate = Some(
|
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)),
|
duration: Duration::from_secs(secs.unwrap_or(150)),
|
||||||
candidate: candidate.clone(),
|
candidate: candidate.clone(),
|
||||||
sweep,
|
sweep,
|
||||||
|
variant: variant.clone(),
|
||||||
},
|
},
|
||||||
"zwift" => Command::Zwift {
|
"zwift" => Command::Zwift {
|
||||||
device: device(&positional, 1)?,
|
device: device(&positional, 1)?,
|
||||||
|
|||||||
@@ -976,6 +976,7 @@ pub async fn unlock_cmd(
|
|||||||
duration: Duration,
|
duration: Duration,
|
||||||
candidate: Option<&str>,
|
candidate: Option<&str>,
|
||||||
sweep: bool,
|
sweep: bool,
|
||||||
|
variant: Option<&str>,
|
||||||
scan_timeout: Duration,
|
scan_timeout: Duration,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use crate::unlock;
|
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::<Vec<_>>().join(", ")
|
||||||
|
)
|
||||||
|
})?),
|
||||||
|
};
|
||||||
|
if let Some(v) = single {
|
||||||
|
println!("Sending exactly one frame this run: {}\n", v.name);
|
||||||
|
}
|
||||||
|
|
||||||
let peripheral = connect(device, scan_timeout).await?;
|
let peripheral = connect(device, scan_timeout).await?;
|
||||||
if let Some(d) = scan::describe(&peripheral).await {
|
if let Some(d) = scan::describe(&peripheral).await {
|
||||||
println!("Connected to {} ({})\n", d.address, d.label());
|
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.
|
// measured against — better than a constant, because the pod says so.
|
||||||
let mut sent: Vec<&'static str> = Vec::new();
|
let mut sent: Vec<&'static str> = Vec::new();
|
||||||
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
|
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
|
||||||
|
let mut zeros: u64 = 0;
|
||||||
let mut last_mask: Option<u32> = None;
|
let mut last_mask: Option<u32> = None;
|
||||||
// When the pod flipped its status flag, which is the cliff this run is
|
// 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.
|
// measured against — better than a constant, because the pod says so.
|
||||||
@@ -1084,7 +1099,23 @@ pub async fn unlock_cmd(
|
|||||||
),
|
),
|
||||||
Err(e) => println!(" !! {e}"),
|
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
|
// One connection, every variant, because the pod hands
|
||||||
// back a reason for each. Spaced so a late reply cannot
|
// back a reason for each. Spaced so a late reply cannot
|
||||||
// be attributed to the next thing we sent.
|
// be attributed to the next thing we sent.
|
||||||
@@ -1146,6 +1177,17 @@ pub async fn unlock_cmd(
|
|||||||
continue;
|
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()
|
if button_mask(&n.value).is_none()
|
||||||
&& unlock::parse_key_offer(&n.value).is_none()
|
&& unlock::parse_key_offer(&n.value).is_none()
|
||||||
&& unlock::parse_status(&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!(" cliff: never flipped"),
|
||||||
}
|
}
|
||||||
println!(" key offers seen: {offers}");
|
println!(" key offers seen: {offers}");
|
||||||
|
if zeros > 0 {
|
||||||
|
println!(" all-zero frames: {zeros} <- the stream stopped carrying data");
|
||||||
|
}
|
||||||
println!(" answered: {answered}");
|
println!(" answered: {answered}");
|
||||||
println!(
|
println!(
|
||||||
" paddle edges: {} (last at {})",
|
" paddle edges: {} (last at {})",
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ async fn main() -> Result<()> {
|
|||||||
duration,
|
duration,
|
||||||
candidate,
|
candidate,
|
||||||
sweep,
|
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 {
|
cli::Command::Zwift {
|
||||||
device,
|
device,
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ pub struct Variant {
|
|||||||
/// writes to the D100 because it puts resistance under a rider; a pod has no
|
/// 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
|
/// actuator, and the worst it can do is ignore us. The OAD characteristics stay
|
||||||
/// untouched — those *can* brick it, and it is sealed.
|
/// 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] = &[
|
pub const VARIANTS: &[Variant] = &[
|
||||||
Variant {
|
Variant {
|
||||||
name: "compressed+ours",
|
name: "compressed+ours",
|
||||||
|
|||||||
Reference in New Issue
Block a user