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:
@@ -18,7 +18,7 @@ use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
|
||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
||||
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
||||
use bikecontrol_core::types::ControlTarget;
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
|
||||
use btleplug::platform::Peripheral;
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
@@ -966,6 +966,198 @@ fn pressed(b: bool) -> &'static str {
|
||||
}
|
||||
|
||||
/// Name a UUID, checking Zwift's custom space as well as the SIG's.
|
||||
/// `probe unlock` — answer the pod's key offer and see whether the paddles live.
|
||||
///
|
||||
/// The experiment, not an implementation: see `crate::unlock` for the
|
||||
/// hypothesis and why one guess per run is affordable.
|
||||
pub async fn unlock_cmd(
|
||||
device: &Device,
|
||||
duration: Duration,
|
||||
candidate: Option<&str>,
|
||||
scan_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
use crate::unlock;
|
||||
|
||||
let candidate = match candidate {
|
||||
None => None,
|
||||
Some(name) => Some(unlock::candidate(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown candidate {name:?}. Known: {}",
|
||||
unlock::CANDIDATES
|
||||
.iter()
|
||||
.map(|c| c.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?),
|
||||
};
|
||||
|
||||
match candidate {
|
||||
Some(c) => println!("Candidate {:?}: field 2 = 0x{:08x}\n {}\n", c.name, c.marker, c.why),
|
||||
None => println!(
|
||||
"Control run: answering nothing, to measure the cliff this pod actually has.\n"
|
||||
),
|
||||
}
|
||||
|
||||
let peripheral = connect(device, scan_timeout).await?;
|
||||
if let Some(d) = scan::describe(&peripheral).await {
|
||||
println!("Connected to {} ({})\n", d.address, d.label());
|
||||
}
|
||||
|
||||
let service = zwift::SERVICES
|
||||
.iter()
|
||||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want))
|
||||
.ok_or_else(|| anyhow::anyhow!("this peripheral exposes no known Zwift service"))?;
|
||||
|
||||
let mut notifications = peripheral.notifications().await?;
|
||||
for ch in service
|
||||
.characteristics
|
||||
.iter()
|
||||
.filter(|c| c.properties.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE))
|
||||
{
|
||||
if let Err(e) = peripheral.subscribe(ch).await {
|
||||
println!("Could not subscribe to {}: {e}", ch.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
let sync_rx = writable(&service)
|
||||
.ok_or_else(|| anyhow::anyhow!("nothing in this service is writable — cannot answer"))?;
|
||||
|
||||
let start = Instant::now();
|
||||
handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
|
||||
|
||||
let local = unlock::local_key();
|
||||
println!(
|
||||
"Our P-256 point: {}\n\nWatching for {} s. Work the paddles and the D-pad throughout —\n\
|
||||
the question is whether the paddles are still reporting at the end.\n",
|
||||
hex(&local.compressed),
|
||||
duration.as_secs()
|
||||
);
|
||||
|
||||
let mut verdict = unlock::Verdict::new(start);
|
||||
let mut answered = 0u32;
|
||||
let mut offers = 0u32;
|
||||
let mut last_status: Option<unlock::Status> = None;
|
||||
|
||||
let deadline = tokio::time::sleep(duration);
|
||||
tokio::pin!(deadline);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut deadline => break,
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
println!("\nInterrupted.");
|
||||
break;
|
||||
}
|
||||
n = notifications.next() => {
|
||||
let Some(n) = n else {
|
||||
println!("\nThe device disconnected — the run is void, not a failure.");
|
||||
break;
|
||||
};
|
||||
let at = start.elapsed().as_secs_f32();
|
||||
|
||||
if let Some(offer) = unlock::parse_key_offer(&n.value) {
|
||||
offers += 1;
|
||||
println!(
|
||||
"[{at:7.2}s] KEY OFFER #{offers} key={} marker=0x{:08x} trailer={}B",
|
||||
hex(&offer.public_key),
|
||||
offer.marker,
|
||||
offer.trailer.len()
|
||||
);
|
||||
match unlock::shared_secret(&local, &offer.public_key) {
|
||||
// Logged, not used: agreeing a secret proves the point
|
||||
// is real P-256, which is worth knowing before anyone
|
||||
// writes a responder around it.
|
||||
Ok(secret) => println!(
|
||||
" ECDH agrees, shared secret starts {}",
|
||||
hex(&secret[..8.min(secret.len())])
|
||||
),
|
||||
Err(e) => println!(" !! {e}"),
|
||||
}
|
||||
if let Some(c) = candidate {
|
||||
let frame = unlock::reply_frame(&local.compressed, c.marker);
|
||||
match write_frame(&peripheral, &sync_rx, &frame).await {
|
||||
Ok(()) => {
|
||||
answered += 1;
|
||||
println!(" answered with {}", hex(&frame));
|
||||
}
|
||||
Err(e) => println!(" !! could not answer: {e}"),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(status) = unlock::parse_status(&n.value) {
|
||||
if last_status != Some(status) {
|
||||
println!(
|
||||
"[{at:7.2}s] STATUS flag={} timer={}",
|
||||
status.flag, status.timer
|
||||
);
|
||||
last_status = Some(status);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(mask) = button_mask(&n.value) {
|
||||
verdict.observe(mask.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cliff = Duration::from_secs(60);
|
||||
println!("\n=== verdict ===");
|
||||
println!(" key offers seen: {offers}");
|
||||
println!(" answered: {answered}");
|
||||
println!(
|
||||
" paddle edges: {} (last at {})",
|
||||
verdict.paddle_edges,
|
||||
verdict.last_paddle.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
|
||||
);
|
||||
println!(
|
||||
" other edges: {} (last at {})",
|
||||
verdict.other_edges,
|
||||
verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
|
||||
);
|
||||
|
||||
if verdict.paddle_edges == 0 && verdict.other_edges == 0 {
|
||||
println!(
|
||||
"\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\
|
||||
a pod nobody touched proves nothing."
|
||||
);
|
||||
} else if verdict.paddles_look_dead(cliff) {
|
||||
println!(
|
||||
"\n FAILED — the D-pad still reports and the paddles stopped.\n\
|
||||
That is the §2.3.3 signature, so this candidate did not hold them open."
|
||||
);
|
||||
} else if verdict.last_paddle.is_some_and(|t| t > cliff) {
|
||||
println!(
|
||||
"\n HELD — a paddle edge arrived after {}s, past the cliff.\n\
|
||||
Worth repeating before believing: run it again, and run the control.",
|
||||
cliff.as_secs()
|
||||
);
|
||||
} else {
|
||||
println!("\n INCONCLUSIVE — the run ended before the cliff, or the paddles were idle.");
|
||||
}
|
||||
|
||||
disconnect(&peripheral).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write to the pod, preferring write-without-response where offered.
|
||||
async fn write_frame(
|
||||
peripheral: &Peripheral,
|
||||
ch: &Characteristic,
|
||||
frame: &[u8],
|
||||
) -> Result<(), btleplug::Error> {
|
||||
let kind = if ch.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) {
|
||||
WriteType::WithoutResponse
|
||||
} else {
|
||||
WriteType::WithResponse
|
||||
};
|
||||
peripheral.write(ch, frame, kind).await
|
||||
}
|
||||
|
||||
fn zwift_named(uuid: Uuid) -> String {
|
||||
zwift::well_known_name(uuid)
|
||||
.map(|n| format!(" ({n})"))
|
||||
|
||||
Reference in New Issue
Block a user