Makinolo's Zwift Ride write-up gives the client command format — `00`
then protobuf field 1 with the parameter, so `00 08 00` is the
information request and `00 08 82 06` is parameter 770. Which explains
what the pod has been telling us all along.
We wrote frames beginning `0xff`. The pod answered `3e 08 ff 01 10 05` —
`{1: 255, 2: 5}`. **255 is 0xff**: our own first byte, echoed back as the
command id, with a status. It was never rejecting a key exchange; it was
saying "command 255, unsupported". `0xff` is a device-to-app notification
type and we were writing it back as though it were a command.
The same write-up records that Zwift "got rid of the Bluetooth
communication encryption they were using for the Play and the Click" —
and the Click v2 is newer than the Ride. So the crypto gate this line of
work assumed may not exist at all, which fits the plain fact that the
cleartext buttons work for the first fifty seconds.
That reopens A-2 from a better angle. It calls the thing that removes the
daily unlock a **keep-alive**: a periodic message, not a credential. So
`--keepalive <secs>` sends a chosen frame on a timer for the whole run
and lets the paddle oracle answer, and `info` and `param770` are
variants — the two commands the write-up documents, in the shape it
documents them.
If a periodic `00 08 00` holds the paddles open past the cliff, the fix
is a heartbeat in the controller supervisor and no cryptography at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1698 lines
63 KiB
Rust
1698 lines
63 KiB
Rust
//! The four probe subcommands.
|
||
//!
|
||
//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug`
|
||
//! directly, so they never take FTMS control and can be run safely while
|
||
//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which
|
||
//! means it exercises the same rate limiting, clamping, acknowledgement
|
||
//! handling and SAF-2 shutdown that the app will use.
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
use anyhow::{anyhow, Context, Result};
|
||
use bikecontrol_ble::capabilities::{
|
||
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
|
||
};
|
||
use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent};
|
||
use bikecontrol_ble::control_point::ResultCode;
|
||
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 _, WriteType};
|
||
use btleplug::platform::Peripheral;
|
||
use futures::StreamExt;
|
||
use uuid::Uuid;
|
||
|
||
use crate::cli::Device;
|
||
|
||
impl Device {
|
||
fn selector(&self) -> TrainerSelector {
|
||
match self {
|
||
Device::Address(a) => TrainerSelector::Address(a.clone()),
|
||
Device::Name(n) => TrainerSelector::NameContains(n.clone()),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scan
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// FR-1.1: list peripherals with name, address, RSSI and advertised services.
|
||
pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> {
|
||
let adapter = scan::default_adapter()
|
||
.await
|
||
.context("no Bluetooth adapter — is the radio on?")?;
|
||
let kind = if all {
|
||
ScanKind::All
|
||
} else {
|
||
ScanKind::FitnessMachines
|
||
};
|
||
|
||
println!(
|
||
"Scanning for {} s ({})...",
|
||
duration.as_secs(),
|
||
if all {
|
||
"everything"
|
||
} else {
|
||
"fitness machines only — pass --all to see every peripheral"
|
||
}
|
||
);
|
||
|
||
let devices = scan::scan(&adapter, duration, kind).await?;
|
||
|
||
if devices.is_empty() {
|
||
println!("\nNothing found.");
|
||
println!(
|
||
"The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\
|
||
and scan again. A Zwift Click wakes on a button press."
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
println!("\n{} device(s):\n", devices.len());
|
||
for d in &devices {
|
||
print_device(d);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn print_device(d: &DiscoveredDevice) {
|
||
let kind = if d.is_fitness_machine() {
|
||
" [FTMS trainer]"
|
||
} else if d.is_zwift_device() {
|
||
" [Zwift device]"
|
||
} else {
|
||
""
|
||
};
|
||
println!("{} {}{}", d.address, d.label(), kind);
|
||
println!(
|
||
" rssi: {} tx power: {}",
|
||
d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()),
|
||
d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into())
|
||
);
|
||
if d.services.is_empty() {
|
||
println!(" services: (none advertised)");
|
||
} else {
|
||
println!(" services:");
|
||
for s in &d.services {
|
||
println!(" {}{}", s, named(*s));
|
||
}
|
||
}
|
||
for (id, data) in &d.manufacturer_data {
|
||
println!(" manufacturer 0x{id:04x} ({id}): {}", hex(data));
|
||
}
|
||
for (uuid, data) in &d.service_data {
|
||
println!(" service data {uuid}: {}", hex(data));
|
||
}
|
||
println!();
|
||
}
|
||
|
||
fn named(uuid: Uuid) -> String {
|
||
uuids::well_known_name(uuid)
|
||
.map(|n| format!(" ({n})"))
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// inspect
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// TASK-1: enumerate everything, and decode the capability characteristics.
|
||
pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> {
|
||
let peripheral = connect(device, scan_timeout).await?;
|
||
|
||
if let Some(d) = scan::describe(&peripheral).await {
|
||
println!("Connected to {} ({})\n", d.address, d.label());
|
||
}
|
||
|
||
println!("=== Services and characteristics ===\n");
|
||
let mut has_ftms = false;
|
||
for service in peripheral.services() {
|
||
if service.uuid == uuids::FITNESS_MACHINE_SERVICE {
|
||
has_ftms = true;
|
||
}
|
||
println!(
|
||
"service {}{}{}",
|
||
service.uuid,
|
||
named(service.uuid),
|
||
if service.primary { " [primary]" } else { "" }
|
||
);
|
||
for ch in &service.characteristics {
|
||
println!(
|
||
" char {}{}\n properties: {}",
|
||
ch.uuid,
|
||
named(ch.uuid),
|
||
properties(ch.properties)
|
||
);
|
||
// Reading is safe: every readable characteristic here is
|
||
// informational, and it is exactly what Phase 0 needs to see.
|
||
if ch.properties.contains(CharPropFlags::READ) {
|
||
match peripheral.read(ch).await {
|
||
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
|
||
Err(e) => println!(" value: <unreadable: {e}>"),
|
||
}
|
||
}
|
||
}
|
||
println!();
|
||
}
|
||
|
||
if !has_ftms {
|
||
println!(
|
||
"!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\
|
||
!! It is not an FTMS trainer, or it needs waking.\n"
|
||
);
|
||
}
|
||
|
||
println!("=== Fitness Machine Feature (0x2ACC) ===\n");
|
||
match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await {
|
||
Some(raw) => match FitnessMachineFeature::decode(&raw) {
|
||
Ok(f) => print_feature(&raw, f),
|
||
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
|
||
},
|
||
None => println!(" not present or unreadable\n"),
|
||
}
|
||
|
||
println!("=== Supported Resistance Level Range (0x2AD6) ===\n");
|
||
match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await {
|
||
Some(raw) => match ResistanceLevelRange::decode(&raw) {
|
||
Ok(r) => {
|
||
let (lo, hi, inc) = r.scaled();
|
||
println!(" raw bytes: {}", hex(&raw));
|
||
println!(" minimum: {}", r.min);
|
||
println!(" maximum: {}", r.max);
|
||
println!(" increment: {}", r.increment);
|
||
println!(
|
||
" if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}"
|
||
);
|
||
println!(
|
||
"\n NOTE: resistance level is a trainer-specific unit. Whether the D100\n\
|
||
means raw integers or tenths is TASK-1/TASK-3 — compare these numbers\n\
|
||
with what `set resistance=<N>` actually does, and with the resistance\n\
|
||
level reported back in Indoor Bike Data.\n"
|
||
);
|
||
}
|
||
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
|
||
},
|
||
None => println!(" not present or unreadable\n"),
|
||
}
|
||
|
||
println!("=== Supported Power Range (0x2AD8) ===\n");
|
||
match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await {
|
||
Some(raw) => match PowerRange::decode(&raw) {
|
||
Ok(p) => println!(
|
||
" raw bytes: {}\n {} .. {} W, step {} W\n",
|
||
hex(&raw),
|
||
p.min_w,
|
||
p.max_w,
|
||
p.increment_w
|
||
),
|
||
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
|
||
},
|
||
None => println!(" not present or unreadable\n"),
|
||
}
|
||
|
||
println!("=== Supported Inclination Range (0x2AD5) ===\n");
|
||
match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await {
|
||
Some(raw) => match InclinationRange::decode(&raw) {
|
||
Ok(i) => println!(
|
||
" raw bytes: {}\n {} .. {} %, step {} %\n",
|
||
hex(&raw),
|
||
i.min_percent(),
|
||
i.max_percent(),
|
||
i.increment_percent()
|
||
),
|
||
Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)),
|
||
},
|
||
None => println!(" not present or unreadable\n"),
|
||
}
|
||
|
||
disconnect(&peripheral).await;
|
||
Ok(())
|
||
}
|
||
|
||
fn print_feature(raw: &[u8], f: FitnessMachineFeature) {
|
||
println!(" raw bytes: {}", hex(raw));
|
||
println!(" machine field: 0x{:08x}", f.machine);
|
||
println!(" target field: 0x{:08x}\n", f.target);
|
||
|
||
println!(" Measures:");
|
||
let m = f.machine_feature_names();
|
||
if m.is_empty() {
|
||
println!(" (none)");
|
||
}
|
||
for name in m {
|
||
println!(" - {name}");
|
||
}
|
||
|
||
println!("\n Accepts as targets:");
|
||
let t = f.target_feature_names();
|
||
if t.is_empty() {
|
||
println!(" (none)");
|
||
}
|
||
for name in t {
|
||
println!(" - {name}");
|
||
}
|
||
|
||
println!("\n Answers to the questions Phase 0 is asking:");
|
||
println!(
|
||
" SetTargetInclination (0x03): {}",
|
||
yes_no(f.supports_inclination_target())
|
||
);
|
||
println!(
|
||
" SetTargetResistanceLevel (0x04): {}",
|
||
yes_no(f.supports_resistance_target())
|
||
);
|
||
println!(
|
||
" SetTargetPower (0x05): {}",
|
||
yes_no(f.supports_power_target())
|
||
);
|
||
println!(
|
||
" SetIndoorBikeSimulationParameters (0x11): {} <-- A-1",
|
||
yes_no(f.supports_simulation())
|
||
);
|
||
println!(
|
||
"\n The 0x11 bit is only what the trainer *claims*. Confirm it with\n\
|
||
`probe set <addr> sim=4.0`, which writes the op code regardless.\n"
|
||
);
|
||
}
|
||
|
||
fn yes_no(b: bool) -> &'static str {
|
||
if b {
|
||
"advertised"
|
||
} else {
|
||
"NOT advertised"
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// monitor
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious.
|
||
pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> {
|
||
let peripheral = connect(device, scan_timeout).await?;
|
||
|
||
let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| {
|
||
anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)")
|
||
})?;
|
||
|
||
let mut notifications = peripheral.notifications().await?;
|
||
peripheral.subscribe(&bike_data).await?;
|
||
|
||
println!(
|
||
"Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\
|
||
Pedal the trainer — most trainers send nothing at all when stationary.\n\
|
||
Press Ctrl-C to stop early.\n",
|
||
duration.as_secs()
|
||
);
|
||
|
||
let start = Instant::now();
|
||
let mut count: u64 = 0;
|
||
let mut failures: u64 = 0;
|
||
|
||
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!("\nNotification stream ended (the trainer disconnected).");
|
||
break;
|
||
};
|
||
if n.uuid != uuids::INDOOR_BIKE_DATA {
|
||
continue;
|
||
}
|
||
count += 1;
|
||
let t = start.elapsed().as_secs_f32();
|
||
println!("[{t:7.2}s] #{count} raw 2ad2: {}", hex(&n.value));
|
||
match indoor_bike_data::decode(&n.value) {
|
||
Ok(d) => print_decoded(&d, n.value.len()),
|
||
Err(e) => {
|
||
failures += 1;
|
||
println!(" DECODE FAILED: {e}");
|
||
}
|
||
}
|
||
println!();
|
||
}
|
||
}
|
||
}
|
||
|
||
println!(
|
||
"\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).",
|
||
start.elapsed().as_secs_f32(),
|
||
count as f32 / start.elapsed().as_secs_f32().max(0.001)
|
||
);
|
||
if count > 0 && failures == 0 {
|
||
println!("Decoder agrees with the trainer on every packet.");
|
||
}
|
||
|
||
let _ = peripheral.unsubscribe(&bike_data).await;
|
||
disconnect(&peripheral).await;
|
||
Ok(())
|
||
}
|
||
|
||
fn print_decoded(d: &IndoorBikeData, len: usize) {
|
||
println!(
|
||
" flags: 0x{:04x} ({})",
|
||
d.flags,
|
||
flag_names(d.flags)
|
||
);
|
||
let row = |label: &str, value: Option<String>| {
|
||
if let Some(v) = value {
|
||
println!(" {label:<10} {v}");
|
||
}
|
||
};
|
||
row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h")));
|
||
row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h")));
|
||
row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm")));
|
||
row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm")));
|
||
row("distance:", d.total_distance_m.map(|v| format!("{v} m")));
|
||
row("resist:", d.resistance_level.map(|v| v.to_string()));
|
||
row("power:", d.instant_power_w.map(|v| format!("{v} W")));
|
||
row("avg power:", d.average_power_w.map(|v| format!("{v} W")));
|
||
row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal")));
|
||
row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string()));
|
||
row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string()));
|
||
row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm")));
|
||
row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}")));
|
||
row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s")));
|
||
row("remaining:", d.remaining_time_s.map(|v| format!("{v} s")));
|
||
|
||
if d.consumed != len {
|
||
println!(
|
||
" !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for",
|
||
d.consumed,
|
||
len - d.consumed
|
||
);
|
||
}
|
||
}
|
||
|
||
fn flag_names(flags: u16) -> String {
|
||
use indoor_bike_data::flag as f;
|
||
let mut names = Vec::new();
|
||
// Bit 0 is inverted: speed is present when it is CLEAR.
|
||
if flags & f::MORE_DATA == 0 {
|
||
names.push("InstantaneousSpeed(bit0 clear)");
|
||
} else {
|
||
names.push("MoreData(bit0 set: no speed)");
|
||
}
|
||
for (bit, name) in [
|
||
(f::AVERAGE_SPEED, "AvgSpeed"),
|
||
(f::INSTANTANEOUS_CADENCE, "Cadence"),
|
||
(f::AVERAGE_CADENCE, "AvgCadence"),
|
||
(f::TOTAL_DISTANCE, "TotalDistance"),
|
||
(f::RESISTANCE_LEVEL, "Resistance"),
|
||
(f::INSTANTANEOUS_POWER, "Power"),
|
||
(f::AVERAGE_POWER, "AvgPower"),
|
||
(f::EXPENDED_ENERGY, "Energy"),
|
||
(f::HEART_RATE, "HeartRate"),
|
||
(f::METABOLIC_EQUIVALENT, "MET"),
|
||
(f::ELAPSED_TIME, "ElapsedTime"),
|
||
(f::REMAINING_TIME, "RemainingTime"),
|
||
] {
|
||
if flags & bit != 0 {
|
||
names.push(name);
|
||
}
|
||
}
|
||
if flags & 0xE000 != 0 {
|
||
names.push("<reserved bits set>");
|
||
}
|
||
names.join(" | ")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// set
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// TASK-2, and the experiment that answers A-1.
|
||
pub async fn set(
|
||
device: &Device,
|
||
target: ControlTarget,
|
||
simulation: bool,
|
||
hold: Duration,
|
||
scan_timeout: Duration,
|
||
) -> Result<()> {
|
||
let config = FtmsConfig {
|
||
use_simulation_mode: simulation,
|
||
scan_timeout,
|
||
// Discovery: write the op code even when the feature bit is clear, so
|
||
// the trainer's own response settles the question rather than our
|
||
// reading of its advertisement.
|
||
ignore_advertised_features: true,
|
||
..FtmsConfig::default()
|
||
};
|
||
|
||
println!("Connecting and requesting FTMS control...");
|
||
let client = FtmsClient::connect(device.selector(), config).await?;
|
||
println!(
|
||
"Control acquired on {} ({}).\n",
|
||
client.address(),
|
||
client.name().unwrap_or("no name")
|
||
);
|
||
|
||
let caps = client.capabilities();
|
||
if let Some(f) = caps.feature {
|
||
println!("Trainer advertises target support: {:?}\n", f.target_feature_names());
|
||
}
|
||
|
||
let mut events = client.events();
|
||
let mut telemetry = client.telemetry();
|
||
|
||
let (op, note) = describe_write(&target, simulation);
|
||
println!("Writing {op} ({note})...");
|
||
|
||
let outcome = client.set_target(target).await;
|
||
report_outcome(&outcome, simulation);
|
||
|
||
// Drain the indication that came back, so the raw result code is visible
|
||
// even when the write succeeded.
|
||
while let Ok(event) = events.try_recv() {
|
||
if let FtmsEvent::ControlResponse { op, result } = event {
|
||
println!(
|
||
" indication: op {:?}, result {} (0x{:02x})",
|
||
op,
|
||
result,
|
||
result.as_u8()
|
||
);
|
||
}
|
||
}
|
||
|
||
if outcome.is_ok() {
|
||
println!(
|
||
"\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\
|
||
(TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\
|
||
Ctrl-C to stop early.\n",
|
||
hold.as_secs()
|
||
);
|
||
|
||
let deadline = tokio::time::sleep(hold);
|
||
tokio::pin!(deadline);
|
||
loop {
|
||
tokio::select! {
|
||
_ = &mut deadline => break,
|
||
_ = tokio::signal::ctrl_c() => {
|
||
println!("\nInterrupted.");
|
||
break;
|
||
}
|
||
sample = telemetry.recv() => {
|
||
if let Ok(s) = sample {
|
||
println!(
|
||
" {:6.1}s power {:>5} cadence {:>6} speed {:>7} resistance {:>5}",
|
||
s.elapsed_ms as f32 / 1000.0,
|
||
s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()),
|
||
s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()),
|
||
s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()),
|
||
s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)...");
|
||
client.shutdown().await?;
|
||
println!("Done.");
|
||
Ok(())
|
||
}
|
||
|
||
fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) {
|
||
match target {
|
||
ControlTarget::Gradient { percent } if simulation => (
|
||
"SetIndoorBikeSimulationParameters (0x11)",
|
||
format!("grade {percent} %"),
|
||
),
|
||
ControlTarget::Gradient { percent } => (
|
||
"SetTargetInclination (0x03)",
|
||
format!("inclination {percent} %"),
|
||
),
|
||
ControlTarget::Resistance { level } => (
|
||
"SetTargetResistanceLevel (0x04)",
|
||
format!("level {level}"),
|
||
),
|
||
ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")),
|
||
}
|
||
}
|
||
|
||
fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool) {
|
||
match outcome {
|
||
Ok(ControlOutcome::Acknowledged { sent }) => {
|
||
println!(" ACCEPTED. Trainer acknowledged with Success.");
|
||
println!(" value actually transmitted (post-clamp): {sent:?}");
|
||
if simulation {
|
||
println!(
|
||
"\n >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\
|
||
>>> FR-2.3 may use 0x11 for gradient."
|
||
);
|
||
}
|
||
}
|
||
Ok(ControlOutcome::Superseded) => {
|
||
println!(" superseded before transmission (should not happen for a single write)");
|
||
}
|
||
Err(FtmsError::Rejected { op, result }) => {
|
||
println!(" REJECTED. Trainer answered {op} with: {result}");
|
||
if simulation && *result == ResultCode::OpCodeNotSupported {
|
||
println!(
|
||
"\n >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\
|
||
>>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\
|
||
>>> exactly as the MIT reference implementation does. Low impact —\n\
|
||
>>> the app owns the physics (FR-7.1)."
|
||
);
|
||
}
|
||
if *result == ResultCode::ControlNotPermitted {
|
||
println!(
|
||
" (RequestControl succeeded but the trainer withdrew control — another\n\
|
||
app may be connected. Only one BLE host may hold the trainer, per A-3.)"
|
||
);
|
||
}
|
||
}
|
||
Err(FtmsError::Unacknowledged { op, timeout_ms }) => {
|
||
println!(" NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms.");
|
||
println!(" This is the silent-failure mode FR-2.7 exists to catch.");
|
||
}
|
||
Err(FtmsError::Unsupported(e)) => {
|
||
println!(" BLOCKED BEFORE TRANSMISSION: {e}");
|
||
}
|
||
Err(e) => println!(" FAILED: {e}"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// zwift
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// TASK-0: connect to whatever advertises Zwift's custom service, try the
|
||
/// `RideOn` handshake unencrypted, and log every frame that comes back.
|
||
///
|
||
/// Points at either end of the open question. Against a **Click v2** it tests
|
||
/// A-3 — whether the unencrypted path still yields button events on a v2.
|
||
/// Against the **trainer** it asks what the D100 is doing with a Zwift service
|
||
/// at all; if it is the virtual-shifting endpoint, the Click may belong to the
|
||
/// trainer rather than to us.
|
||
///
|
||
/// Nothing here interprets a frame as a gear change or drives resistance. It
|
||
/// prints bytes. Everything the protocol module claims (§2.3.1) is a hypothesis
|
||
/// until the hex on screen agrees with it.
|
||
pub async fn zwift_cmd(
|
||
device: &Device,
|
||
duration: Duration,
|
||
no_handshake: bool,
|
||
buttons_only: bool,
|
||
scan_timeout: Duration,
|
||
) -> Result<()> {
|
||
let peripheral = connect(device, scan_timeout).await?;
|
||
|
||
if let Some(d) = scan::describe(&peripheral).await {
|
||
println!("Connected to {} ({})", d.address, d.label());
|
||
match d.zwift_kind() {
|
||
Some(kind) => println!("Advertised as: {}", kind.describe()),
|
||
None => println!(
|
||
"No Zwift manufacturer data in the advertisement — this is not a controller,\n\
|
||
or it was already connected when we found it."
|
||
),
|
||
}
|
||
println!();
|
||
}
|
||
|
||
// A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Take
|
||
// whichever is present rather than assuming, because which one a device
|
||
// speaks is itself a finding.
|
||
let service = zwift::SERVICES
|
||
.iter()
|
||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
|
||
let Some(service) = service else {
|
||
println!("!! This peripheral exposes no known Zwift service. Looked for:");
|
||
for want in zwift::SERVICES {
|
||
println!(" {want}{}", zwift_named(want));
|
||
}
|
||
println!("!! Services it does expose:");
|
||
for s in peripheral.services() {
|
||
println!(" {}{}", s.uuid, named(s.uuid));
|
||
}
|
||
disconnect(&peripheral).await;
|
||
return Ok(());
|
||
};
|
||
|
||
println!("=== Zwift service {}{} ===\n", service.uuid, zwift_named(service.uuid));
|
||
for ch in &service.characteristics {
|
||
println!(
|
||
" char {}{}\n properties: {}",
|
||
ch.uuid,
|
||
zwift_named(ch.uuid),
|
||
properties(ch.properties)
|
||
);
|
||
if ch.properties.contains(CharPropFlags::READ) {
|
||
match peripheral.read(ch).await {
|
||
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
|
||
Err(e) => println!(" value: <unreadable: {e}>"),
|
||
}
|
||
}
|
||
}
|
||
println!();
|
||
|
||
// Subscribe to everything that can talk before writing anything, so the
|
||
// handshake reply cannot land before we are listening.
|
||
let mut notifications = peripheral.notifications().await?;
|
||
let listening: Vec<Characteristic> = service
|
||
.characteristics
|
||
.iter()
|
||
.filter(|c| {
|
||
c.properties
|
||
.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
|
||
if listening.is_empty() {
|
||
println!("!! Nothing in this service notifies or indicates — there is nothing to listen to.");
|
||
disconnect(&peripheral).await;
|
||
return Ok(());
|
||
}
|
||
|
||
for ch in &listening {
|
||
match peripheral.subscribe(ch).await {
|
||
Ok(()) => println!("Subscribed to {}{}", ch.uuid, zwift_named(ch.uuid)),
|
||
Err(e) => println!("Could not subscribe to {}: {e}", ch.uuid),
|
||
}
|
||
}
|
||
println!();
|
||
|
||
let start = Instant::now();
|
||
let mut frames: u64 = 0;
|
||
|
||
if no_handshake {
|
||
println!("--no-handshake: writing nothing, just listening.\n");
|
||
} else if let Some(sync_rx) = writable(&service) {
|
||
if sync_rx.uuid != zwift::SYNC_RX {
|
||
println!(
|
||
"Sync RX ({}) is absent; using {} instead, which is the only writable\n\
|
||
characteristic in this service.\n",
|
||
zwift::SYNC_RX,
|
||
sync_rx.uuid
|
||
);
|
||
}
|
||
frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
|
||
} else {
|
||
println!("Nothing in this service is writable — cannot hand shake. Listening only.\n");
|
||
}
|
||
|
||
if buttons_only {
|
||
println!(
|
||
"=== BUTTON MAPPING — GO ===\n\n\
|
||
Press one button at a time, holding each for about 2 s with a gap between.\n\
|
||
Only state changes are printed, so each press is one PRESS and one RELEASE.\n\
|
||
{} s to go; Ctrl-C to stop early.\n",
|
||
duration.saturating_sub(start.elapsed()).as_secs()
|
||
);
|
||
} else {
|
||
println!(
|
||
"Listening for {} s. Press the Click's paddles and D-pad; press Ctrl-C to stop.\n",
|
||
duration.as_secs()
|
||
);
|
||
}
|
||
|
||
let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
|
||
tokio::pin!(deadline);
|
||
|
||
// Only meaningful in --buttons mode: the mask as of the previous frame, so
|
||
// the ~10 Hz repeat while a button is held collapses to one line.
|
||
let mut last_mask: Option<u32> = None;
|
||
|
||
let mut presses: u64 = 0;
|
||
|
||
loop {
|
||
tokio::select! {
|
||
_ = &mut deadline => break,
|
||
_ = tokio::signal::ctrl_c() => {
|
||
println!("\nInterrupted.");
|
||
break;
|
||
}
|
||
n = notifications.next() => {
|
||
let Some(n) = n else {
|
||
println!("\nNotification stream ended (the device disconnected).");
|
||
break;
|
||
};
|
||
frames += 1;
|
||
let elapsed = start.elapsed().as_secs_f32();
|
||
|
||
if buttons_only {
|
||
if let Some(mask) = button_mask(&n.value) {
|
||
if last_mask != Some(mask.raw) {
|
||
last_mask = Some(mask.raw);
|
||
if !mask.is_idle() {
|
||
presses += 1;
|
||
}
|
||
print_transition(&mask, elapsed, presses);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
print_zwift_frame(n.uuid, &n.value, elapsed, frames);
|
||
}
|
||
}
|
||
}
|
||
|
||
println!("\n{frames} frame(s) in {:.1} s.", start.elapsed().as_secs_f32());
|
||
if frames == 0 {
|
||
println!(
|
||
"Nothing arrived. Either the handshake is wrong, or the unlock has expired —\n\
|
||
re-pair in the Zwift app and try again within the day (§2.3)."
|
||
);
|
||
}
|
||
|
||
for ch in &listening {
|
||
let _ = peripheral.unsubscribe(ch).await;
|
||
}
|
||
disconnect(&peripheral).await;
|
||
Ok(())
|
||
}
|
||
|
||
/// Write each candidate handshake in turn, waiting briefly for a reply after
|
||
/// each. Returns how many frames arrived during the attempts.
|
||
///
|
||
/// Which two bytes follow `RideOn` is the unverified part of §2.3.1, so this
|
||
/// tries them rather than betting on one. It stops at the first `RideOn` reply
|
||
/// — that is the answer to TASK-0, and writing further handshakes after a
|
||
/// successful one would only confuse the session.
|
||
async fn handshake(
|
||
peripheral: &Peripheral,
|
||
sync_rx: &Characteristic,
|
||
notifications: &mut (impl futures::Stream<Item = btleplug::api::ValueNotification> + Unpin),
|
||
start: Instant,
|
||
) -> Result<u64> {
|
||
// WriteWithoutResponse when the characteristic allows it: the Zwift
|
||
// references use it, and a device that never sends a write response would
|
||
// otherwise stall us for the full BLE timeout.
|
||
let write_type = if sync_rx
|
||
.properties
|
||
.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
|
||
{
|
||
btleplug::api::WriteType::WithoutResponse
|
||
} else {
|
||
btleplug::api::WriteType::WithResponse
|
||
};
|
||
|
||
println!("=== Handshake ===\n");
|
||
let mut frames = 0;
|
||
|
||
for (label, suffix) in zwift::HANDSHAKE_CANDIDATES {
|
||
let frame = zwift::handshake(suffix);
|
||
println!("-> {} : {}", label, hex(&frame));
|
||
|
||
if let Err(e) = peripheral.write(sync_rx, &frame, write_type).await {
|
||
println!(" write failed: {e}");
|
||
continue;
|
||
}
|
||
|
||
// Long enough for a device that is going to answer to have answered.
|
||
let window = tokio::time::sleep(Duration::from_millis(1500));
|
||
tokio::pin!(window);
|
||
let mut answered = false;
|
||
loop {
|
||
tokio::select! {
|
||
_ = &mut window => break,
|
||
n = notifications.next() => {
|
||
let Some(n) = n else { break };
|
||
frames += 1;
|
||
print_zwift_frame(n.uuid, &n.value, start.elapsed().as_secs_f32(), frames);
|
||
if zwift::is_ride_on_reply(&n.value) {
|
||
answered = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if answered {
|
||
println!("\n RideOn acknowledged — this is the handshake the device wants.");
|
||
println!(" TASK-0 answered: the unencrypted path is open.\n");
|
||
return Ok(frames);
|
||
}
|
||
println!(" no RideOn reply.\n");
|
||
}
|
||
|
||
println!(
|
||
"None of the candidate handshakes drew a RideOn reply. Either the suffix is\n\
|
||
something else, or this device requires the encrypted handshake (§2.3.1).\n\
|
||
Frames may still arrive unprompted — keep watching.\n"
|
||
);
|
||
Ok(frames)
|
||
}
|
||
|
||
/// Print one Zwift frame: raw hex first, then whatever we think it means.
|
||
fn print_zwift_frame(uuid: Uuid, raw: &[u8], elapsed: f32, index: u64) {
|
||
println!(
|
||
"[{elapsed:7.2}s] #{index} {}{}: {}",
|
||
uuid,
|
||
zwift_named(uuid),
|
||
hex(raw)
|
||
);
|
||
|
||
if zwift::is_ride_on_reply(raw) {
|
||
println!(" RideOn reply, {} byte(s) total", raw.len());
|
||
return;
|
||
}
|
||
|
||
let Some(frame) = zwift::parse_frame(raw) else {
|
||
println!(" empty frame");
|
||
return;
|
||
};
|
||
println!(" type: {}", frame.kind.describe());
|
||
|
||
match frame.kind {
|
||
zwift::MessageType::ButtonBitmask => match zwift::decode_button_bitmask(frame.payload) {
|
||
Ok(m) if m.is_idle() => println!(" mask 0x{:08x} (idle)", m.raw),
|
||
Ok(m) => {
|
||
let bits: Vec<String> = m.pressed_bits().iter().map(|b| b.to_string()).collect();
|
||
println!(
|
||
" mask 0x{:08x} PRESSED: bit {}",
|
||
m.raw,
|
||
bits.join(" + bit ")
|
||
);
|
||
}
|
||
Err(e) => println!(" payload is not a varint ({e})"),
|
||
},
|
||
zwift::MessageType::ClickButtons => match zwift::decode_click_buttons(frame.payload) {
|
||
Ok(b) => println!(
|
||
" up: {} down: {}",
|
||
pressed(b.up_pressed),
|
||
pressed(b.down_pressed)
|
||
),
|
||
Err(e) => println!(" payload is not two varints ({e}) — 0x37 is not what we assume"),
|
||
},
|
||
zwift::MessageType::Battery => match zwift::decode_battery(frame.payload) {
|
||
Ok(Some(pct)) => println!(" battery: {pct}%"),
|
||
Ok(None) => println!(" battery: no field in payload"),
|
||
Err(e) => println!(" could not decode ({e})"),
|
||
},
|
||
_ => {
|
||
// Unknown and controller frames: show the protobuf structure if it
|
||
// has one, since that is the fastest route to naming the fields.
|
||
if let Ok(fields) = zwift::decode_varint_fields(frame.payload) {
|
||
if !fields.is_empty() {
|
||
let rendered: Vec<String> = fields
|
||
.iter()
|
||
.map(|(f, v)| format!("field {f} = {v}"))
|
||
.collect();
|
||
println!(" varints: {}", rendered.join(", "));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The button mask in `raw`, or `None` if this is not a button frame.
|
||
fn button_mask(raw: &[u8]) -> Option<zwift::ButtonBitmask> {
|
||
let frame = zwift::parse_frame(raw)?;
|
||
if frame.kind != zwift::MessageType::ButtonBitmask {
|
||
return None;
|
||
}
|
||
zwift::decode_button_bitmask(frame.payload).ok()
|
||
}
|
||
|
||
/// One line per button state change, for `--buttons`.
|
||
fn print_transition(mask: &zwift::ButtonBitmask, elapsed: f32, presses: u64) {
|
||
if mask.is_idle() {
|
||
println!("[{elapsed:7.2}s] RELEASE --- mask 0x{:08x}", mask.raw);
|
||
return;
|
||
}
|
||
// Name the button where we can, but always show the bit — an unmapped bit
|
||
// is exactly the thing this tool exists to surface.
|
||
let held: Vec<String> = mask
|
||
.pressed_bits()
|
||
.iter()
|
||
.map(|b| match zwift::Button::from_bit(*b) {
|
||
Some(button) => format!("{} (bit {b})", button.label()),
|
||
None => format!("UNMAPPED bit {b}"),
|
||
})
|
||
.collect();
|
||
println!(
|
||
"[{elapsed:7.2}s] PRESS #{presses:<3} {:<24} mask 0x{:08x}",
|
||
held.join(" + "),
|
||
mask.raw
|
||
);
|
||
}
|
||
|
||
/// The characteristic to write the handshake to: the documented sync RX if the
|
||
/// device has it, otherwise the first writable one. On a service whose layout
|
||
/// we have never seen, "the only thing that accepts a write" is the best
|
||
/// available guess.
|
||
fn writable(service: &btleplug::api::Service) -> Option<Characteristic> {
|
||
let writable_flags = CharPropFlags::WRITE | CharPropFlags::WRITE_WITHOUT_RESPONSE;
|
||
service
|
||
.characteristics
|
||
.iter()
|
||
.find(|c| c.uuid == zwift::SYNC_RX && c.properties.intersects(writable_flags))
|
||
.or_else(|| {
|
||
service
|
||
.characteristics
|
||
.iter()
|
||
.find(|c| c.properties.intersects(writable_flags))
|
||
})
|
||
.cloned()
|
||
}
|
||
|
||
fn pressed(b: bool) -> &'static str {
|
||
if b {
|
||
"PRESSED"
|
||
} else {
|
||
"released"
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
sweep: bool,
|
||
variant: Option<&str>,
|
||
keepalive: Option<Duration>,
|
||
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 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(", ")
|
||
)
|
||
})?),
|
||
};
|
||
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?;
|
||
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;
|
||
// 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.
|
||
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<u32> = 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.
|
||
let mut flip_at: Option<Duration> = None;
|
||
|
||
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,
|
||
_ = tokio::signal::ctrl_c() => {
|
||
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.");
|
||
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 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
|
||
// 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
|
||
// back a reason for each. Spaced so a late reply cannot
|
||
// be attributed to the next thing we sent.
|
||
for v in unlock::VARIANTS {
|
||
let frame = (v.build)(&local, &offer);
|
||
println!("\n -> {:<20} {}", v.name, hex(&frame));
|
||
println!(" {}", v.why);
|
||
if let Err(e) = write_frame(&peripheral, &sync_rx, &frame).await {
|
||
println!(" !! could not send: {e}");
|
||
continue;
|
||
}
|
||
answered += 1;
|
||
sent.push(v.name);
|
||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||
}
|
||
println!();
|
||
} else 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
|
||
);
|
||
// The pod telling us, in its own words, that whatever
|
||
// grace it was extending has ended. Everything before
|
||
// this is preamble; the run is only evidence from here.
|
||
// A *transition*, not merely a first sighting. These
|
||
// runs opened with flag already 1 — the pod remembers
|
||
// being past the cliff across reconnects — and calling
|
||
// that "the cliff at 2.3s" is a reading, not a fact.
|
||
let was_zero = last_status.is_some_and(|s| s.flag == 0);
|
||
if status.flag == 1 && was_zero && flip_at.is_none() {
|
||
flip_at = Some(start.elapsed());
|
||
println!(
|
||
"\n >>> THE CLIFF. Keep pressing both paddles and the D-pad for\n >>> another 60 s — everything before this line proves nothing.\n"
|
||
);
|
||
}
|
||
last_status = Some(status);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if let Some(r) = unlock::parse_response(&n.value) {
|
||
let to = sent.last().copied().unwrap_or("(unsolicited)");
|
||
println!("[{at:7.2}s] REPLY code={} detail={} <- {to}", r.code, r.detail);
|
||
responses.push((to, r));
|
||
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()
|
||
&& unlock::parse_key_offer(&n.value).is_none()
|
||
&& unlock::parse_status(&n.value).is_none()
|
||
&& unlock::parse_response(&n.value).is_none()
|
||
{
|
||
println!("[{at:7.2}s] other {}", hex(&n.value));
|
||
}
|
||
|
||
if let Some(mask) = button_mask(&n.value) {
|
||
// Printed, not merely counted. An operator pressing buttons
|
||
// into a silent terminal cannot tell a working run from a
|
||
// dead pod, and will reasonably conclude the latter.
|
||
if last_mask != Some(mask.raw) {
|
||
last_mask = Some(mask.raw);
|
||
let paddles = mask.raw & ((1 << 8) | (1 << 12));
|
||
let which = match paddles {
|
||
p if p == (1 << 8) | (1 << 12) => "",
|
||
p if p & (1 << 8) == 0 => " <- − PADDLE",
|
||
_ => " <- + PADDLE",
|
||
};
|
||
println!(
|
||
"[{at:7.2}s] buttons 0x{:08x}{}{}",
|
||
mask.raw,
|
||
if mask.is_idle() { " (idle)" } else { "" },
|
||
which
|
||
);
|
||
}
|
||
verdict.observe(mask.raw);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The pod's own flip where we saw it; otherwise the ~50 s the captures show.
|
||
let cliff = flip_at.unwrap_or(Duration::from_secs(50));
|
||
println!("\n=== verdict ===");
|
||
match (flip_at, last_status) {
|
||
(Some(t), _) => println!(" cliff (flag 0->1): {:.1}s", t.as_secs_f32()),
|
||
(None, Some(s)) if s.flag == 1 => println!(
|
||
" cliff: already past it when we connected — the pod kept\n \x20 that state across the reconnect"
|
||
),
|
||
_ => 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");
|
||
}
|
||
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 sweep {
|
||
println!("\n variant reply");
|
||
for (name, r) in &responses {
|
||
println!(" {name:<22} code={} detail={}", r.code, r.detail);
|
||
}
|
||
let distinct: std::collections::BTreeSet<_> =
|
||
responses.iter().map(|(_, r)| (r.code, r.detail)).collect();
|
||
if responses.is_empty() {
|
||
println!("\n The pod answered none of them, which is itself a change from\n the runs where it answered `ff 03 00` frames.");
|
||
} else if distinct.len() == 1 {
|
||
println!(
|
||
"\n Every variant drew the same reply, so none of the things varied —\n the marker, the trailer, the key encoding, the envelope — is what\n it is objecting to."
|
||
);
|
||
} else {
|
||
println!(
|
||
"\n The reply MOVED. Whichever variant differs is the thread to pull:\n that is the first time this device has told us we got warmer."
|
||
);
|
||
}
|
||
disconnect(&peripheral).await;
|
||
return Ok(());
|
||
}
|
||
|
||
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 {:.1}s past the cliff.\n\
|
||
Worth repeating before believing: run it again, and run the control.",
|
||
(verdict.last_paddle.unwrap() - cliff).as_secs_f32()
|
||
);
|
||
} else {
|
||
println!(
|
||
"\n INCONCLUSIVE — nothing was pressed after the cliff at {:.1}s.\n\
|
||
The run has to keep going, with fingers on the buttons, well past it.",
|
||
cliff.as_secs_f32()
|
||
);
|
||
}
|
||
|
||
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})"))
|
||
.unwrap_or_else(|| named(uuid))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared plumbing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async fn connect(device: &Device, scan_timeout: Duration) -> Result<Peripheral> {
|
||
let adapter = scan::default_adapter()
|
||
.await
|
||
.context("no Bluetooth adapter — is the radio on?")?;
|
||
let selector = device.selector();
|
||
|
||
println!("Looking for {}...", selector.describe());
|
||
let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout)
|
||
.await
|
||
.with_context(|| {
|
||
format!(
|
||
"could not find {}. The trainer may be asleep — pedal it and try again (A-4)",
|
||
selector.describe()
|
||
)
|
||
})?;
|
||
|
||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||
peripheral.connect().await.context("connect failed")?;
|
||
}
|
||
peripheral
|
||
.discover_services()
|
||
.await
|
||
.context("service discovery failed")?;
|
||
Ok(peripheral)
|
||
}
|
||
|
||
async fn disconnect(peripheral: &Peripheral) {
|
||
if let Err(e) = peripheral.disconnect().await {
|
||
tracing::debug!(error = %e, "disconnect failed");
|
||
}
|
||
}
|
||
|
||
fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option<Characteristic> {
|
||
peripheral.characteristics().into_iter().find(|c| c.uuid == uuid)
|
||
}
|
||
|
||
async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option<Vec<u8>> {
|
||
let ch = find_characteristic(peripheral, uuid)?;
|
||
peripheral.read(&ch).await.ok()
|
||
}
|
||
|
||
/// Render a characteristic's bytes as text when they look like a string —
|
||
/// Device Information holds model and firmware numbers this way.
|
||
fn as_text(v: &[u8]) -> String {
|
||
if !v.is_empty()
|
||
&& v.iter()
|
||
.all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r')
|
||
{
|
||
format!("\"{}\"", String::from_utf8_lossy(v).trim())
|
||
} else {
|
||
String::new()
|
||
}
|
||
}
|
||
|
||
fn properties(p: CharPropFlags) -> String {
|
||
let mut out = Vec::new();
|
||
for (flag, name) in [
|
||
(CharPropFlags::BROADCAST, "broadcast"),
|
||
(CharPropFlags::READ, "read"),
|
||
(CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"),
|
||
(CharPropFlags::WRITE, "write"),
|
||
(CharPropFlags::NOTIFY, "notify"),
|
||
(CharPropFlags::INDICATE, "indicate"),
|
||
(
|
||
CharPropFlags::AUTHENTICATED_SIGNED_WRITES,
|
||
"authenticated-signed-writes",
|
||
),
|
||
(CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"),
|
||
] {
|
||
if p.contains(flag) {
|
||
out.push(name);
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
"(none)".to_string()
|
||
} else {
|
||
out.join(", ")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// listen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Raw GATT interrogation, with no idea what any of it means.
|
||
///
|
||
/// Every other subcommand decodes something. This one deliberately does not:
|
||
/// it prints the tree, subscribes to everything that will have it, and reports
|
||
/// each notification as a characteristic, a length and some bytes.
|
||
///
|
||
/// That combination is what a cross-platform disagreement needs. When a device
|
||
/// behaves on one backend and not another, the useful questions are *which*
|
||
/// characteristic carried the data and *how many bytes arrived* — and a decoder
|
||
/// answers neither, because its failure mode is to drop the frame silently and
|
||
/// leave you looking at nothing. A length printed next to a truncated payload
|
||
/// says more than a parse error ever does.
|
||
pub async fn listen(
|
||
device: &Device,
|
||
duration: Duration,
|
||
do_handshake: bool,
|
||
scan_timeout: Duration,
|
||
) -> Result<()> {
|
||
let peripheral = connect(device, scan_timeout).await?;
|
||
|
||
if let Some(d) = scan::describe(&peripheral).await {
|
||
println!("Connected to {} ({})", d.address, d.label());
|
||
if let Some(kind) = d.zwift_kind() {
|
||
println!("Advertised as: {}", kind.describe());
|
||
}
|
||
println!();
|
||
}
|
||
|
||
// The whole tree first, so the capture below can be read months later
|
||
// without the device to hand.
|
||
println!("=== GATT tree ===\n");
|
||
let mut notifying: Vec<Characteristic> = Vec::new();
|
||
for service in peripheral.services() {
|
||
println!(
|
||
"service {}{}{}",
|
||
service.uuid,
|
||
named(service.uuid),
|
||
if service.primary { " [primary]" } else { "" }
|
||
);
|
||
for ch in &service.characteristics {
|
||
let subscribable = ch
|
||
.properties
|
||
.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE);
|
||
println!(
|
||
" char {}{}\n properties: {}{}",
|
||
ch.uuid,
|
||
named(ch.uuid),
|
||
properties(ch.properties),
|
||
if subscribable { " <- will subscribe" } else { "" }
|
||
);
|
||
// Descriptors are the part `inspect` omits, and the CCCD is exactly
|
||
// where a subscribe goes wrong: its value says whether the platform
|
||
// enabled notification, indication, or nothing at all.
|
||
for d in &ch.descriptors {
|
||
match peripheral.read_descriptor(d).await {
|
||
Ok(v) => println!(
|
||
" descriptor {}{} = {}",
|
||
d.uuid,
|
||
named(d.uuid),
|
||
hex(&v)
|
||
),
|
||
Err(e) => println!(
|
||
" descriptor {}{} <unreadable: {e}>",
|
||
d.uuid,
|
||
named(d.uuid)
|
||
),
|
||
}
|
||
}
|
||
if subscribable {
|
||
notifying.push(ch.clone());
|
||
}
|
||
}
|
||
println!();
|
||
}
|
||
|
||
if notifying.is_empty() {
|
||
println!("!! Nothing here notifies or indicates. There is nothing to listen to.");
|
||
disconnect(&peripheral).await;
|
||
return Ok(());
|
||
}
|
||
|
||
let mut notifications = peripheral.notifications().await?;
|
||
let start = Instant::now();
|
||
|
||
println!("=== Subscribing ===\n");
|
||
let mut live = 0usize;
|
||
for ch in ¬ifying {
|
||
match peripheral.subscribe(ch).await {
|
||
Ok(()) => {
|
||
live += 1;
|
||
println!(" ok {}{}", ch.uuid, named(ch.uuid));
|
||
}
|
||
// Reported rather than fatal: one characteristic refusing is itself
|
||
// the finding, and the others may still carry what we came for.
|
||
Err(e) => println!(" FAILED {}{} — {e}", ch.uuid, named(ch.uuid)),
|
||
}
|
||
}
|
||
println!("\n{live} of {} subscribed.\n", notifying.len());
|
||
|
||
let mut frames: u64 = 0;
|
||
if do_handshake {
|
||
let service = zwift::SERVICES
|
||
.iter()
|
||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
|
||
match service.as_ref().and_then(writable) {
|
||
Some(sync_rx) => {
|
||
frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
|
||
}
|
||
None => println!(
|
||
"No writable Zwift characteristic — greeting skipped. A Click will stay\n\
|
||
silent; anything that streams unprompted will not care.\n"
|
||
),
|
||
}
|
||
} else {
|
||
println!("--no-handshake: writing nothing.\n");
|
||
}
|
||
|
||
println!(
|
||
"Listening for {} s. Press buttons; Ctrl-C to stop early.\n",
|
||
duration.saturating_sub(start.elapsed()).as_secs()
|
||
);
|
||
println!(" time # characteristic len bytes");
|
||
|
||
let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
|
||
tokio::pin!(deadline);
|
||
|
||
// Per characteristic, so a summary can show which ones ever spoke — the
|
||
// difference between "the pod is silent" and "we were listening in the
|
||
// wrong place".
|
||
let mut counts: std::collections::BTreeMap<Uuid, (u64, usize)> = Default::default();
|
||
|
||
loop {
|
||
tokio::select! {
|
||
_ = &mut deadline => break,
|
||
_ = tokio::signal::ctrl_c() => {
|
||
println!("\nInterrupted.");
|
||
break;
|
||
}
|
||
n = notifications.next() => {
|
||
let Some(n) = n else {
|
||
println!("\nNotification stream ended (the device disconnected).");
|
||
break;
|
||
};
|
||
frames += 1;
|
||
let entry = counts.entry(n.uuid).or_insert((0, 0));
|
||
entry.0 += 1;
|
||
entry.1 = entry.1.max(n.value.len());
|
||
println!(
|
||
" {:6.2}s #{:<4} {}{:<8} {:>3} {} {}",
|
||
start.elapsed().as_secs_f32(),
|
||
frames,
|
||
n.uuid,
|
||
named(n.uuid),
|
||
n.value.len(),
|
||
hex(&n.value),
|
||
as_text(&n.value),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
println!("\n=== {frames} frames ===\n");
|
||
for (uuid, (count, longest)) in &counts {
|
||
println!(" {uuid}{} {count} frames, longest {longest} bytes", named(*uuid));
|
||
}
|
||
for ch in ¬ifying {
|
||
if !counts.contains_key(&ch.uuid) {
|
||
println!(" {}{} silent", ch.uuid, named(ch.uuid));
|
||
}
|
||
}
|
||
|
||
for ch in ¬ifying {
|
||
let _ = peripheral.unsubscribe(ch).await;
|
||
}
|
||
disconnect(&peripheral).await;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bikecontrol_ble::indoor_bike_data::flag;
|
||
|
||
#[test]
|
||
fn flag_names_call_out_the_inverted_bit_zero() {
|
||
// Bit 0 clear means speed IS present.
|
||
assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)"));
|
||
// Bit 0 set means it is not.
|
||
assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)"));
|
||
}
|
||
|
||
#[test]
|
||
fn flag_names_list_every_present_field() {
|
||
let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER);
|
||
assert!(names.contains("Cadence"));
|
||
assert!(names.contains("Power"));
|
||
assert!(!names.contains("HeartRate"));
|
||
}
|
||
|
||
#[test]
|
||
fn flag_names_flag_reserved_bits() {
|
||
assert!(flag_names(0x8000).contains("<reserved bits set>"));
|
||
assert!(!flag_names(0x0001).contains("<reserved bits set>"));
|
||
}
|
||
|
||
#[test]
|
||
fn device_selector_mapping() {
|
||
assert_eq!(
|
||
Device::Address("AA:BB".into()).selector(),
|
||
TrainerSelector::Address("AA:BB".into())
|
||
);
|
||
assert_eq!(
|
||
Device::Name("D100".into()).selector(),
|
||
TrainerSelector::NameContains("D100".into())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn describe_write_names_the_op_code() {
|
||
assert_eq!(
|
||
describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0,
|
||
"SetTargetInclination (0x03)"
|
||
);
|
||
assert_eq!(
|
||
describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0,
|
||
"SetIndoorBikeSimulationParameters (0x11)"
|
||
);
|
||
assert_eq!(
|
||
describe_write(&ControlTarget::Resistance { level: 10 }, false).0,
|
||
"SetTargetResistanceLevel (0x04)"
|
||
);
|
||
assert_eq!(
|
||
describe_write(&ControlTarget::Power { watts: 100 }, false).0,
|
||
"SetTargetPower (0x05)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn as_text_only_renders_printable_payloads() {
|
||
assert_eq!(as_text(b"D100"), "\"D100\"");
|
||
assert_eq!(as_text(&[0x00, 0x01, 0xff]), "");
|
||
assert_eq!(as_text(&[]), "");
|
||
}
|
||
|
||
#[test]
|
||
fn properties_are_listed_in_order() {
|
||
assert_eq!(
|
||
properties(CharPropFlags::READ | CharPropFlags::INDICATE),
|
||
"read, indicate"
|
||
);
|
||
assert_eq!(properties(CharPropFlags::empty()), "(none)");
|
||
}
|
||
}
|