//! 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: "), } } } 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=` 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 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| { 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(""); } 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, 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: "), } } } 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 = 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 = 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 + Unpin), start: Instant, ) -> Result { // 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 = 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 = 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 { 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 = 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 { 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, 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::>() .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::>().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 = 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 = 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 = 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 { 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 { peripheral.characteristics().into_iter().find(|c| c.uuid == uuid) } async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option> { 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 = 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 {}{} ", 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 = 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("")); assert!(!flag_names(0x0001).contains("")); } #[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)"); } }