Files
BikeControl/crates/ble/src/zwift.rs
T
dtourolleandClaude Opus 5 57eb5e809b Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:28 +02:00

1077 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Zwift's proprietary BLE protocol — UUIDs, framing and message decoding
//! (REQUIREMENTS.md §2.3.1).
//!
//! This is the protocol the Zwift Click v2 speaks, and — per `probe inspect` —
//! the same custom service the Van Rysel D100 advertises alongside FTMS. It is
//! not a Bluetooth SIG standard: everything here comes from the public
//! documentation of three independent open-source implementations (§3.43.6),
//! **not** from copied code.
//!
//! # Confidence
//!
//! The UUIDs and the `RideOn` handshake prefix are corroborated by all three
//! references and are safe to rely on. Everything marked *unverified* below is
//! a hypothesis for `probe zwift` to test against the hardware — the tool
//! prints raw bytes precisely so these constants can be confirmed or replaced.
//!
//! Encryption (ECDH P-256 → HKDF → AES-256-CCM) is deliberately absent. A-3
//! records that at least one implementation talks to a Click *without* it and
//! still receives button and battery events; TASK-0 is to find out whether that
//! holds for a v2, because it would remove the crypto layer entirely.
use uuid::Uuid;
// ---------------------------------------------------------------------------
// UUIDs
// ---------------------------------------------------------------------------
/// Tail of Zwift's custom UUID space, `0000000x-19CA-4651-86E5-FA29DCDD09D1`.
const ZWIFT_D2: u16 = 0x19CA;
const ZWIFT_D3: u16 = 0x4651;
const ZWIFT_D4: [u8; 8] = [0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1];
/// Expand a Zwift assigned number onto Zwift's custom UUID base.
pub const fn zwift_uuid(assigned: u32) -> Uuid {
Uuid::from_fields(assigned, ZWIFT_D2, ZWIFT_D3, &ZWIFT_D4)
}
/// Zwift's custom service, `00000001-…`.
///
/// **Confirmed on hardware (2026-08-05):** the Van Rysel D100 advertises this,
/// with service data `01`. The Click v2 does **not** — see [`SERVICE_FC82`].
pub const SERVICE: Uuid = zwift_uuid(0x0000_0001);
/// Zwift's SIG-allocated 16-bit service, `0xFC82`.
///
/// **Confirmed on hardware (2026-08-05):** this is the *only* vendor service a
/// Click v2 exposes. It advertises `0xFC82` with service data `00`, and after
/// connecting exposes Generic Access, Generic Attribute, Device Information,
/// Battery and `0xFC82` — and nothing in the `…-19CA-…` space at all.
///
/// This is why the trainer's Zwift characteristics are not a route to the
/// Click: they are two different services. Whether the *framing* inside
/// `0xFC82` is the same `RideOn` protocol is the open question, and the reason
/// `probe zwift` treats either service as a candidate.
pub const SERVICE_FC82: Uuid = crate::uuids::uuid16(0xFC82);
/// Every service known to carry Zwift's protocol, newest first. A device is
/// expected to expose exactly one.
pub const SERVICES: [Uuid; 2] = [SERVICE_FC82, SERVICE];
/// Async characteristic, `00000002-…` — notify. Unsolicited device events:
/// button state, battery, keepalives.
pub const ASYNC: Uuid = zwift_uuid(0x0000_0002);
/// Sync RX, `00000003-…` — write. Commands *to* the device, including the
/// `RideOn` handshake.
pub const SYNC_RX: Uuid = zwift_uuid(0x0000_0003);
/// Sync TX, `00000004-…` — indicate. Responses to whatever was written to
/// [`SYNC_RX`].
pub const SYNC_TX: Uuid = zwift_uuid(0x0000_0004);
/// `00000006-…` — indicate/read/write, purpose undetermined (§2.3.1). Present
/// on some devices; `probe zwift` subscribes to it just to see if it ever
/// speaks.
pub const UNKNOWN_6: Uuid = zwift_uuid(0x0000_0006);
/// Zwift's Bluetooth SIG manufacturer ID (2378).
pub const MANUFACTURER_ID: u16 = 0x094A;
/// Human-readable name for a Zwift UUID, for logging and the probe CLI.
pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
if uuid == SERVICE_FC82 {
return Some("Zwift service 0xFC82");
}
Some(match uuid {
SERVICE => "Zwift custom service",
ASYNC => "Zwift async (notify)",
SYNC_RX => "Zwift sync RX (write)",
SYNC_TX => "Zwift sync TX (indicate)",
UNKNOWN_6 => "Zwift 0006 (purpose unknown)",
_ => return None,
})
}
/// True when `uuid` sits in Zwift's custom UUID space.
pub fn is_zwift_uuid(uuid: Uuid) -> bool {
let (_, d2, d3, d4) = uuid.as_fields();
d2 == ZWIFT_D2 && d3 == ZWIFT_D3 && *d4 == ZWIFT_D4
}
// ---------------------------------------------------------------------------
// Device discrimination
// ---------------------------------------------------------------------------
/// What kind of Zwift peripheral is advertising, from the first byte of its
/// manufacturer data (§2.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
/// `0x09` — Click v1. Unencrypted; the easy case.
ClickV1,
/// `0x0A` / `0x0B` — Click v2. The target hardware (§2.3).
ClickV2,
/// `0x03` — Play, left pod. *Unverified.*
PlayLeft,
/// `0x02` — Play, right pod. *Unverified.*
PlayRight,
/// A Zwift device we do not have a byte for. Carries the raw value so the
/// probe can report it rather than swallow it.
Unknown(u8),
}
impl DeviceKind {
/// Classify from the device type byte.
pub fn from_type_byte(b: u8) -> Self {
match b {
0x09 => DeviceKind::ClickV1,
0x0A | 0x0B => DeviceKind::ClickV2,
0x03 => DeviceKind::PlayLeft,
0x02 => DeviceKind::PlayRight,
other => DeviceKind::Unknown(other),
}
}
/// Classify from a peripheral's Zwift manufacturer data. The device type is
/// the first byte; an empty payload tells us nothing.
pub fn from_manufacturer_data(data: &[u8]) -> Option<Self> {
data.first().copied().map(Self::from_type_byte)
}
/// True for a Click of either generation — the devices this app drives.
pub fn is_click(self) -> bool {
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2)
}
pub fn describe(self) -> String {
match self {
DeviceKind::ClickV1 => "Zwift Click v1".into(),
DeviceKind::ClickV2 => "Zwift Click v2".into(),
DeviceKind::PlayLeft => "Zwift Play (left)".into(),
DeviceKind::PlayRight => "Zwift Play (right)".into(),
DeviceKind::Unknown(b) => format!("unrecognised Zwift device (type byte 0x{b:02x})"),
}
}
}
// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------
/// The `RideOn` magic that opens every Zwift session — ASCII, no terminator.
/// Corroborated by all three references.
pub const RIDE_ON: [u8; 6] = *b"RideOn";
/// The two bytes that follow `RideOn` on the way *in*.
///
/// **Confirmed on a Click v2 (2026-08-05):** writing `526964654f6e0009` to
/// [`SYNC_RX`] draws an immediate reply on [`SYNC_TX`], with no key exchange
/// and no encryption. This is the answer to TASK-0.
pub const REQUEST_START: [u8; 2] = [0x00, 0x09];
/// The two bytes that follow `RideOn` in the device's reply.
///
/// **Confirmed on a Click v2 (2026-08-05):** the pod answers `526964654f6e0203`
/// — `02 03`, not the `01 03` the public write-ups describe. Replies are
/// therefore matched on the `RideOn` prefix ([`is_ride_on_reply`]) rather than
/// on these two bytes, which evidently vary.
pub const RESPONSE_START: [u8; 2] = [0x02, 0x03];
/// Build a handshake frame: `RideOn` followed by `suffix`.
///
/// `suffix` is a parameter rather than a constant because which two bytes the
/// v2 wants is exactly what is unknown. A Play-style device additionally
/// appends a P-256 public key here; a Click is documented as not needing one on
/// the unencrypted path (A-3).
pub fn handshake(suffix: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(RIDE_ON.len() + suffix.len());
frame.extend_from_slice(&RIDE_ON);
frame.extend_from_slice(suffix);
frame
}
/// Handshake suffixes to try, in order. The first is the confirmed one and
/// answers on the first attempt; the rest remain as fallbacks for firmware we
/// have not met.
pub const HANDSHAKE_CANDIDATES: &[(&str, &[u8])] = &[
("RideOn + 00 09 (confirmed on Click v2)", &REQUEST_START),
("RideOn + 01 02", &[0x01, 0x02]),
("RideOn alone (no suffix)", &[]),
];
/// True when `frame` is a `RideOn` reply from the device.
pub fn is_ride_on_reply(frame: &[u8]) -> bool {
frame.starts_with(&RIDE_ON)
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
/// The leading byte of an async frame (§2.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageType {
/// `0x07` — controller notification (Play-style analogue/button payload).
ControllerNotification,
/// `0x15` — empty frame / keepalive.
KeepAlive,
/// `0x19` — battery level. **Confirmed:** `191064` decodes to 100%.
Battery,
/// `0x23` — Click v2 button state: one varint holding a 32-bit active-low
/// bitmask. **Confirmed on hardware (2026-08-05)**; this, not `0x37`, is
/// what a v2 pod actually sends.
ButtonBitmask,
/// `0x37` — Click button state as two protobuf varints. Documented in the
/// public write-ups (§2.3.1) but **never observed on our v2**, which uses
/// [`MessageType::ButtonBitmask`]. Presumably v1.
ClickButtons,
/// Anything else. Kept rather than rejected — an unknown frame from a
/// half-documented protocol is data, not an error.
Unknown(u8),
}
impl MessageType {
pub fn from_byte(b: u8) -> Self {
match b {
0x07 => MessageType::ControllerNotification,
0x15 => MessageType::KeepAlive,
0x19 => MessageType::Battery,
0x23 => MessageType::ButtonBitmask,
0x37 => MessageType::ClickButtons,
other => MessageType::Unknown(other),
}
}
pub fn describe(self) -> String {
match self {
MessageType::ControllerNotification => "controller notification (0x07)".into(),
MessageType::KeepAlive => "keepalive / empty (0x15)".into(),
MessageType::Battery => "battery (0x19)".into(),
MessageType::ButtonBitmask => "Click v2 button bitmask (0x23)".into(),
MessageType::ClickButtons => "Click button state (0x37)".into(),
MessageType::Unknown(b) => format!("unknown (0x{b:02x})"),
}
}
}
/// An async frame split into its type byte and payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame<'a> {
pub kind: MessageType,
pub payload: &'a [u8],
}
/// Split a raw notification into type byte and payload. Returns `None` for an
/// empty frame, which carries no type at all.
pub fn parse_frame(raw: &[u8]) -> Option<Frame<'_>> {
let (&first, rest) = raw.split_first()?;
Some(Frame {
kind: MessageType::from_byte(first),
payload: rest,
})
}
/// Button state from a `0x37` frame.
///
/// The payload is a two-field protobuf message. Zwift encodes **0 = pressed,
/// 1 = released** — inverted from the obvious reading, which is the kind of
/// detail that has to be confirmed against the hardware before it is trusted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClickButtons {
/// Field 1 — the up / plus paddle.
pub up_pressed: bool,
/// Field 2 — the down / minus paddle.
pub down_pressed: bool,
}
/// Decode a `0x37` payload. Absent fields default to released, because protobuf
/// omits fields at their default value and the wire cannot distinguish
/// "unchanged" from "not sent".
pub fn decode_click_buttons(payload: &[u8]) -> Result<ClickButtons, ProtobufError> {
let fields = decode_varint_fields(payload)?;
let value_of = |field: u32| fields.iter().find(|(f, _)| *f == field).map(|(_, v)| *v);
Ok(ClickButtons {
up_pressed: value_of(1) == Some(0),
down_pressed: value_of(2) == Some(0),
})
}
/// A physical button on the Click v2, and the bit it owns in the `0x23` mask.
///
/// **Mapped on hardware (2026-08-05)** by pressing each button in a known order
/// and correlating. The layout is orderly once seen: the D-pad takes bits 03,
/// the four face buttons take 47, and the two paddles sit further up at 8 and
/// 12.
///
/// `A` and `Z` initially disagreed between two captures and were settled by a
/// dedicated two-button test: `A` → bit 4, `Z` → bit 7, reproduced twice. The
/// first capture had simply been pressed out of order.
///
/// Bits 9, 10 and 11 belong to nothing we have found, so the paddles are not
/// contiguous with the rest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Button {
Left,
Up,
Right,
Down,
A,
B,
Y,
Z,
/// The `` paddle.
Minus,
/// The `+` paddle. Note the gap: bits 911 belong to nothing we have found.
Plus,
}
impl Button {
/// Every button, in bit order.
pub const ALL: [Button; 10] = [
Button::Left,
Button::Up,
Button::Right,
Button::Down,
Button::A,
Button::B,
Button::Y,
Button::Z,
Button::Minus,
Button::Plus,
];
/// The bit this button clears when held.
pub fn bit(self) -> u8 {
match self {
Button::Left => 0,
Button::Up => 1,
Button::Right => 2,
Button::Down => 3,
Button::A => 4,
Button::B => 5,
Button::Y => 6,
Button::Z => 7,
Button::Minus => 8,
Button::Plus => 12,
}
}
/// The button owning `bit`, or `None` for the bits nothing claims.
pub fn from_bit(bit: u8) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.bit() == bit)
}
pub fn label(self) -> &'static str {
match self {
Button::Left => "left",
Button::Up => "up",
Button::Right => "right",
Button::Down => "down",
Button::A => "A",
Button::B => "B",
Button::Y => "Y",
Button::Z => "Z",
Button::Minus => "-",
Button::Plus => "+",
}
}
}
/// The button state a Click v2 sends in a `0x23` frame.
///
/// One varint field carrying a 32-bit mask in which **a clear bit means
/// pressed**. Idle is `0xFFFFFFFF`; the pod streams this at roughly 10 Hz
/// whenever anything is held, and sends the all-ones frame on release.
///
/// Observed bits so far: 0, 1, 3, 8 and 12, including 0 and 1 clear in the same
/// frame — so the mask is genuinely simultaneous state, not an event code.
/// **Which physical button each bit belongs to is not yet mapped**, which is
/// why this type exposes the raw mask rather than named buttons it cannot
/// honestly fill in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonBitmask {
/// The mask exactly as received, active-low.
pub raw: u32,
}
impl ButtonBitmask {
/// True when the button on `bit` is held.
pub fn is_pressed(self, bit: u8) -> bool {
bit < 32 && self.raw & (1u32 << bit) == 0
}
/// Every held button, lowest bit first.
pub fn pressed_bits(self) -> Vec<u8> {
(0..32).filter(|b| self.is_pressed(*b)).collect()
}
/// True when nothing at all is held.
pub fn is_idle(self) -> bool {
self.raw == u32::MAX
}
/// True when `button` is held.
pub fn holds(self, button: Button) -> bool {
self.is_pressed(button.bit())
}
/// Every held button we have a name for, in bit order. A clear bit with no
/// known button is reported by [`Self::pressed_bits`] but not here.
pub fn pressed_buttons(self) -> Vec<Button> {
Button::ALL
.into_iter()
.filter(|b| self.holds(*b))
.collect()
}
}
/// A press or release, derived from consecutive [`ButtonBitmask`] frames.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonEvent {
pub button: Button,
/// True on the press edge, false on the release edge.
pub pressed: bool,
}
/// Turns the pod's ~10 Hz state stream into press and release *edges*.
///
/// The pod repeats the same mask for as long as a button is held, so a consumer
/// that acted on every frame would shift ten gears per second. This holds the
/// previous mask and reports only what changed.
///
/// A disconnect must call [`ButtonTracker::reset`]: without it, a button held
/// as the link drops would never produce its release edge, and the next
/// connection would open with a phantom press still latched.
#[derive(Debug, Clone, Copy, Default)]
pub struct ButtonTracker {
previous: Option<ButtonBitmask>,
}
impl ButtonTracker {
pub fn new() -> Self {
Self::default()
}
/// Feed the next mask; get back the edges it implies, in bit order.
///
/// The very first mask after construction or [`reset`](Self::reset) reports
/// any already-held button as a press, which is what a rider holding a
/// paddle through a reconnect would expect.
pub fn update(&mut self, mask: ButtonBitmask) -> Vec<ButtonEvent> {
let was = self.previous.unwrap_or(ButtonBitmask { raw: u32::MAX });
self.previous = Some(mask);
Button::ALL
.into_iter()
.filter_map(|button| {
let now = mask.holds(button);
(now != was.holds(button)).then_some(ButtonEvent {
button,
pressed: now,
})
})
.collect()
}
/// Forget the last mask. Call on disconnect so a held button cannot latch
/// across the gap.
pub fn reset(&mut self) {
self.previous = None;
}
/// Buttons currently held, as far as this tracker has seen.
pub fn held(&self) -> Vec<Button> {
self.previous.map(|m| m.pressed_buttons()).unwrap_or_default()
}
}
/// Decode a `0x23` payload into the active-low button mask.
///
/// The varint is wider than 32 bits on the wire (protobuf sign-extends), so the
/// value is truncated to the low 32 bits — the upper bits are sign extension,
/// not buttons.
pub fn decode_button_bitmask(payload: &[u8]) -> Result<ButtonBitmask, ProtobufError> {
let fields = decode_varint_fields(payload)?;
let raw = fields
.iter()
.find(|(f, _)| *f == 1)
.map(|(_, v)| *v as u32)
.unwrap_or(u32::MAX);
Ok(ButtonBitmask { raw })
}
/// Battery percentage from a `0x19` frame — a single varint field.
pub fn decode_battery(payload: &[u8]) -> Result<Option<u8>, ProtobufError> {
let fields = decode_varint_fields(payload)?;
Ok(fields.first().map(|(_, v)| (*v).min(255) as u8))
}
// ---------------------------------------------------------------------------
// Minimal protobuf
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProtobufError {
#[error("truncated varint at byte {offset}")]
TruncatedVarint { offset: usize },
#[error("varint at byte {offset} is longer than 10 bytes")]
OverlongVarint { offset: usize },
#[error("wire type {wire_type} at byte {offset} is not a varint")]
UnsupportedWireType { wire_type: u8, offset: usize },
}
/// Decode a protobuf message consisting entirely of varint fields, returning
/// `(field number, value)` pairs in wire order.
///
/// Deliberately not a protobuf library: the two messages this app cares about
/// are a handful of varints each, and pulling in `prost` plus a `.proto` we
/// would have to reverse-engineer anyway buys nothing. A non-varint wire type
/// is an error rather than something to skip — it would mean the message is not
/// the shape we think it is, and silently returning a partial decode is how a
/// wrong assumption survives.
pub fn decode_varint_fields(mut data: &[u8]) -> Result<Vec<(u32, u64)>, ProtobufError> {
let mut out = Vec::new();
let mut offset = 0;
while !data.is_empty() {
let (tag, consumed) = decode_varint(data, offset)?;
data = &data[consumed..];
offset += consumed;
let wire_type = (tag & 0x07) as u8;
if wire_type != 0 {
return Err(ProtobufError::UnsupportedWireType {
wire_type,
offset: offset - consumed,
});
}
let field = (tag >> 3) as u32;
let (value, consumed) = decode_varint(data, offset)?;
data = &data[consumed..];
offset += consumed;
out.push((field, value));
}
Ok(out)
}
/// Decode one base-128 varint. `offset` is only used for error reporting.
fn decode_varint(data: &[u8], offset: usize) -> Result<(u64, usize), ProtobufError> {
let mut value: u64 = 0;
for (i, &b) in data.iter().enumerate() {
if i >= 10 {
return Err(ProtobufError::OverlongVarint { offset });
}
value |= u64::from(b & 0x7f) << (7 * i);
if b & 0x80 == 0 {
return Ok((value, i + 1));
}
}
Err(ProtobufError::TruncatedVarint { offset })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_uuid_matches_the_documented_value() {
assert_eq!(
SERVICE.to_string(),
"00000001-19ca-4651-86e5-fa29dcdd09d1"
);
assert_eq!(ASYNC.to_string(), "00000002-19ca-4651-86e5-fa29dcdd09d1");
assert_eq!(SYNC_RX.to_string(), "00000003-19ca-4651-86e5-fa29dcdd09d1");
assert_eq!(SYNC_TX.to_string(), "00000004-19ca-4651-86e5-fa29dcdd09d1");
assert_eq!(
UNKNOWN_6.to_string(),
"00000006-19ca-4651-86e5-fa29dcdd09d1"
);
}
#[test]
fn the_click_v2_service_is_the_sig_short_uuid() {
assert_eq!(
SERVICE_FC82.to_string(),
"0000fc82-0000-1000-8000-00805f9b34fb"
);
// It is a SIG-base UUID, so it is deliberately *not* in Zwift's custom
// space — that difference is the whole finding.
assert!(!is_zwift_uuid(SERVICE_FC82));
assert_eq!(crate::uuids::short_id(SERVICE_FC82), Some(0xFC82));
assert_eq!(well_known_name(SERVICE_FC82), Some("Zwift service 0xFC82"));
assert!(SERVICES.contains(&SERVICE_FC82) && SERVICES.contains(&SERVICE));
}
#[test]
fn zwift_uuids_are_recognised_and_ftms_ones_are_not() {
assert!(is_zwift_uuid(SERVICE));
assert!(is_zwift_uuid(SYNC_TX));
assert!(!is_zwift_uuid(crate::uuids::FITNESS_MACHINE_SERVICE));
assert!(!is_zwift_uuid(crate::uuids::INDOOR_BIKE_DATA));
}
#[test]
fn characteristics_have_names() {
assert_eq!(well_known_name(SYNC_RX), Some("Zwift sync RX (write)"));
assert_eq!(well_known_name(crate::uuids::INDOOR_BIKE_DATA), None);
}
#[test]
fn device_type_bytes_classify() {
assert_eq!(DeviceKind::from_type_byte(0x09), DeviceKind::ClickV1);
assert_eq!(DeviceKind::from_type_byte(0x0A), DeviceKind::ClickV2);
assert_eq!(DeviceKind::from_type_byte(0x0B), DeviceKind::ClickV2);
assert_eq!(DeviceKind::from_type_byte(0x02), DeviceKind::PlayRight);
assert_eq!(DeviceKind::from_type_byte(0xFE), DeviceKind::Unknown(0xFE));
assert!(DeviceKind::from_type_byte(0x0B).is_click());
assert!(!DeviceKind::from_type_byte(0x03).is_click());
}
#[test]
fn manufacturer_data_needs_at_least_one_byte() {
assert_eq!(
DeviceKind::from_manufacturer_data(&[0x0A, 0x00]),
Some(DeviceKind::ClickV2)
);
assert_eq!(DeviceKind::from_manufacturer_data(&[]), None);
}
#[test]
fn handshake_is_ride_on_plus_suffix() {
assert_eq!(
handshake(&REQUEST_START),
vec![0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e, 0x00, 0x09]
);
assert_eq!(handshake(&[]), b"RideOn".to_vec());
// Every candidate is a well-formed frame we could actually write.
for (_, suffix) in HANDSHAKE_CANDIDATES {
assert!(handshake(suffix).starts_with(b"RideOn"));
}
}
#[test]
fn ride_on_replies_are_recognised_by_prefix() {
assert!(is_ride_on_reply(b"RideOn\x01\x03"));
assert!(!is_ride_on_reply(b"\x37\x08\x01"));
assert!(!is_ride_on_reply(b""));
}
#[test]
fn frames_split_into_type_and_payload() {
let f = parse_frame(&[0x37, 0x08, 0x01]).unwrap();
assert_eq!(f.kind, MessageType::ClickButtons);
assert_eq!(f.payload, &[0x08, 0x01]);
assert_eq!(parse_frame(&[0x15]).unwrap().kind, MessageType::KeepAlive);
assert_eq!(
parse_frame(&[0xAB]).unwrap().kind,
MessageType::Unknown(0xAB)
);
assert!(parse_frame(&[]).is_none());
}
#[test]
fn varints_decode() {
// field 1, varint, value 1 -> tag 0x08, value 0x01
assert_eq!(decode_varint_fields(&[0x08, 0x01]).unwrap(), vec![(1, 1)]);
// two fields
assert_eq!(
decode_varint_fields(&[0x08, 0x00, 0x10, 0x01]).unwrap(),
vec![(1, 0), (2, 1)]
);
// multi-byte value: 300 = 0xAC 0x02
assert_eq!(
decode_varint_fields(&[0x08, 0xAC, 0x02]).unwrap(),
vec![(1, 300)]
);
assert_eq!(decode_varint_fields(&[]).unwrap(), vec![]);
}
#[test]
fn malformed_protobuf_is_an_error_not_a_partial_decode() {
// Tag present, value missing.
assert!(matches!(
decode_varint_fields(&[0x08]),
Err(ProtobufError::TruncatedVarint { .. })
));
// Continuation bit set on the last byte.
assert!(matches!(
decode_varint_fields(&[0x08, 0x80]),
Err(ProtobufError::TruncatedVarint { .. })
));
// Wire type 2 (length-delimited) is not something we handle.
assert!(matches!(
decode_varint_fields(&[0x0A, 0x01, 0x00]),
Err(ProtobufError::UnsupportedWireType { wire_type: 2, .. })
));
}
#[test]
fn click_buttons_decode_with_zero_meaning_pressed() {
let both_released = decode_click_buttons(&[0x08, 0x01, 0x10, 0x01]).unwrap();
assert!(!both_released.up_pressed && !both_released.down_pressed);
let up = decode_click_buttons(&[0x08, 0x00, 0x10, 0x01]).unwrap();
assert!(up.up_pressed && !up.down_pressed);
let down = decode_click_buttons(&[0x08, 0x01, 0x10, 0x00]).unwrap();
assert!(!down.up_pressed && down.down_pressed);
}
#[test]
fn missing_button_fields_read_as_released() {
let empty = decode_click_buttons(&[]).unwrap();
assert!(!empty.up_pressed && !empty.down_pressed);
}
/// Bytes captured from a Click v2 pod on 2026-08-05. If a refactor ever
/// breaks these, the decoder has stopped agreeing with the hardware.
#[test]
fn captured_button_frames_decode_to_the_observed_bits() {
let mask = |raw: &[u8]| {
let frame = parse_frame(raw).unwrap();
assert_eq!(frame.kind, MessageType::ButtonBitmask);
decode_button_bitmask(frame.payload).unwrap()
};
let idle = mask(&[0x23, 0x08, 0xff, 0xff, 0xff, 0xff, 0x0f]);
assert_eq!(idle.raw, 0xFFFF_FFFF);
assert!(idle.is_idle());
assert_eq!(idle.pressed_bits(), Vec::<u8>::new());
assert_eq!(
mask(&[0x23, 0x08, 0xfe, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![0]
);
// Two buttons held at once — the mask is state, not an event code.
assert_eq!(
mask(&[0x23, 0x08, 0xfc, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![0, 1]
);
assert_eq!(
mask(&[0x23, 0x08, 0xf7, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![3]
);
assert_eq!(
mask(&[0x23, 0x08, 0xf6, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![0, 3]
);
assert_eq!(
mask(&[0x23, 0x08, 0xff, 0xfd, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![8]
);
assert_eq!(
mask(&[0x23, 0x08, 0xf7, 0xfd, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![3, 8]
);
assert_eq!(
mask(&[0x23, 0x08, 0xff, 0xdf, 0xff, 0xff, 0x0f]).pressed_bits(),
vec![12]
);
}
#[test]
fn every_button_owns_a_distinct_bit_that_round_trips() {
let mut seen = std::collections::HashSet::new();
for b in Button::ALL {
assert!(seen.insert(b.bit()), "{} reuses a bit", b.label());
assert_eq!(Button::from_bit(b.bit()), Some(b));
}
// Bits 9-11 belong to nothing we have found.
assert_eq!(Button::from_bit(9), None);
assert_eq!(Button::from_bit(11), None);
assert_eq!(Button::from_bit(31), None);
}
/// The A/Z pair that two captures disagreed on, settled by a dedicated
/// alternating test that returned 4, 7, 4, 7, 4, 7.
#[test]
fn face_buttons_match_the_hardware_test() {
assert_eq!(Button::A.bit(), 4);
assert_eq!(Button::Z.bit(), 7);
let a = decode_button_bitmask(&[0x08, 0xef, 0xff, 0xff, 0xff, 0x0f]).unwrap();
assert_eq!(a.pressed_buttons(), vec![Button::A]);
let z = decode_button_bitmask(&[0x08, 0xff, 0xfe, 0xff, 0xff, 0x0f]).unwrap();
assert_eq!(z.pressed_buttons(), vec![Button::Z]);
}
/// Every button as captured on 2026-08-05, mask by mask.
#[test]
fn captured_masks_name_the_right_button() {
for (raw, expected) in [
(0xffff_fffe_u32, Button::Left),
(0xffff_fffd, Button::Up),
(0xffff_fffb, Button::Right),
(0xffff_fff7, Button::Down),
(0xffff_ffef, Button::A),
(0xffff_ffdf, Button::B),
(0xffff_ffbf, Button::Y),
(0xffff_ff7f, Button::Z),
(0xffff_feff, Button::Minus),
(0xffff_efff, Button::Plus),
] {
let mask = ButtonBitmask { raw };
assert_eq!(
mask.pressed_buttons(),
vec![expected],
"mask 0x{raw:08x} should be {}",
expected.label()
);
assert!(mask.holds(expected));
}
}
#[test]
fn simultaneous_presses_name_every_button_held() {
// Bits 0 and 1 clear together, as seen in the first capture.
let mask = ButtonBitmask { raw: 0xffff_fffc };
assert_eq!(mask.pressed_buttons(), vec![Button::Left, Button::Up]);
assert!(ButtonBitmask { raw: u32::MAX }.pressed_buttons().is_empty());
}
fn mask(raw: u32) -> ButtonBitmask {
ButtonBitmask { raw }
}
#[test]
fn a_held_button_produces_one_press_not_ten() {
let mut t = ButtonTracker::new();
// The press edge.
assert_eq!(
t.update(mask(0xffff_efff)),
vec![ButtonEvent { button: Button::Plus, pressed: true }]
);
// Held: the pod repeats the same mask at ~10 Hz and we stay silent.
for _ in 0..10 {
assert!(t.update(mask(0xffff_efff)).is_empty());
}
// The release edge.
assert_eq!(
t.update(mask(u32::MAX)),
vec![ButtonEvent { button: Button::Plus, pressed: false }]
);
assert!(t.update(mask(u32::MAX)).is_empty());
}
#[test]
fn one_frame_can_carry_several_edges() {
let mut t = ButtonTracker::new();
t.update(mask(0xffff_fffe)); // left down
// left releases and up presses in the same frame.
assert_eq!(
t.update(mask(0xffff_fffd)),
vec![
ButtonEvent { button: Button::Left, pressed: false },
ButtonEvent { button: Button::Up, pressed: true },
]
);
assert_eq!(t.held(), vec![Button::Up]);
}
#[test]
fn reset_prevents_a_phantom_press_latching_across_a_disconnect() {
let mut t = ButtonTracker::new();
t.update(mask(0xffff_efff)); // `+` held as the link drops
assert_eq!(t.held(), vec![Button::Plus]);
t.reset();
assert!(t.held().is_empty());
// Reconnecting to an idle pod must not emit a stale release.
assert!(t.update(mask(u32::MAX)).is_empty());
}
#[test]
fn a_button_already_held_at_the_first_frame_reports_as_pressed() {
let mut t = ButtonTracker::new();
assert_eq!(
t.update(mask(0xffff_ff7f)),
vec![ButtonEvent { button: Button::Z, pressed: true }]
);
}
#[test]
fn a_button_frame_with_no_field_reads_as_idle() {
assert!(decode_button_bitmask(&[]).unwrap().is_idle());
// Out-of-range bits are not pressed rather than a panic.
assert!(!ButtonBitmask { raw: 0 }.is_pressed(32));
assert!(ButtonBitmask { raw: 0 }.is_pressed(31));
}
/// The battery frame exactly as the pod sent it.
#[test]
fn captured_battery_frame_decodes() {
let frame = parse_frame(&[0x19, 0x10, 0x64]).unwrap();
assert_eq!(frame.kind, MessageType::Battery);
assert_eq!(decode_battery(frame.payload).unwrap(), Some(100));
}
/// The handshake reply exactly as the pod sent it — note `02 03`, not the
/// `01 03` the public write-ups give.
#[test]
fn captured_ride_on_reply_is_recognised_despite_unexpected_suffix() {
let reply = [0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e, 0x02, 0x03];
assert!(is_ride_on_reply(&reply));
assert_eq!(&reply[6..], RESPONSE_START);
}
#[test]
fn battery_reads_the_first_field() {
assert_eq!(decode_battery(&[0x08, 0x55]).unwrap(), Some(85));
assert_eq!(decode_battery(&[]).unwrap(), None);
}
}
/// Riding data from a *trainer* on the Zwift channel — message type `0x03`.
///
/// The D100 advertises no cadence over FTMS (its Fitness Machine Feature bits
/// claim average speed and power only), and the undeclared trailing bytes in
/// its Indoor Bike Data turned out to be wheel RPM restated — a fixed 73.8×
/// the declared speed, carrying no new information. Cadence is nevertheless
/// available: it is here, on the trainer's Zwift service.
///
/// Established by capture against `VANRYSEL-HT-2876` on 2026-08-05, holding two
/// known cadences. Five protobuf varints arrive at ~1 Hz, of which only two are
/// independent:
///
/// | field | meaning |
/// |-------|--------------------------------------------|
/// | 1 | instantaneous power, watts |
/// | 2 | heart rate — always 0 with no strap paired |
/// | 3 | cadence, 0.1 rpm |
/// | 4 | duplicate of field 1 |
/// | 5 | speed; a fixed 28.6× field 3 |
///
/// Field 5 being a constant multiple of field 3 is not a coincidence to be
/// decoded away: with a Zwift Cog there is one sprocket, so cadence and wheel
/// speed are mechanically locked and the ratio *is* the gearing. Observing that
/// constant is what identifies the pair.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct RidingData {
pub power_w: u32,
pub heart_rate_bpm: u32,
/// Tenths of an rpm, as sent.
pub cadence_drpm: u32,
pub speed_raw: u32,
}
impl RidingData {
pub fn cadence_rpm(&self) -> f32 {
self.cadence_drpm as f32 / 10.0
}
/// Cadence-to-speed ratio, which on a single-cog drivetrain is fixed by the
/// gearing. Useful for deriving the effective gear ratio empirically rather
/// than asking the rider for tooth counts.
pub fn speed_per_cadence(&self) -> Option<f32> {
(self.cadence_drpm > 0).then(|| self.speed_raw as f32 / self.cadence_drpm as f32)
}
}
/// Decode a type-`0x03` riding-data frame. Returns `None` if the payload is not
/// riding data or is malformed.
pub fn decode_riding_data(frame: &[u8]) -> Option<RidingData> {
let (&kind, mut rest) = frame.split_first()?;
if kind != 0x03 {
return None;
}
let mut out = RidingData::default();
while let Some((&tag, tail)) = rest.split_first() {
// Every field here is varint-encoded (wire type 0).
if tag & 0x07 != 0 {
return None;
}
let (value, tail) = read_varint(tail)?;
match tag >> 3 {
1 => out.power_w = value,
2 => out.heart_rate_bpm = value,
3 => out.cadence_drpm = value,
4 => {} // duplicate of field 1; nothing to learn from it
5 => out.speed_raw = value,
_ => {} // forward-compatible: ignore fields we do not know
}
rest = tail;
}
Some(out)
}
fn read_varint(bytes: &[u8]) -> Option<(u32, &[u8])> {
let mut value: u64 = 0;
for (i, &b) in bytes.iter().enumerate() {
// A u32 field cannot need more than five groups of seven bits.
if i >= 5 {
return None;
}
value |= u64::from(b & 0x7f) << (7 * i);
if b & 0x80 == 0 {
return Some((u32::try_from(value).ok()?, &bytes[i + 1..]));
}
}
None
}
#[cfg(test)]
mod riding_data_tests {
use super::*;
/// Frames captured from the real trainer, with the values `probe` decoded.
const CAPTURED: &[(&str, u32, u32, u32)] = &[
// hex, power, cadence_drpm, speed
("030815100018ab01201528ad26", 21, 171, 4909),
("03080c1000186b200c28f917", 12, 107, 3065),
("0308041000184320042886 0f", 4, 67, 1926),
("0308021000182f200228cf0a", 2, 47, 1359),
("0308001000180020002800", 0, 0, 0),
];
#[test]
fn decodes_frames_captured_from_the_trainer() {
for (hex, power, cadence, speed) in CAPTURED {
let bytes: Vec<u8> = (0..hex.replace(' ', "").len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex.replace(' ', "")[i..i + 2], 16).unwrap())
.collect();
let d = decode_riding_data(&bytes).expect("should decode");
assert_eq!(d.power_w, *power, "power in {hex}");
assert_eq!(d.cadence_drpm, *cadence, "cadence in {hex}");
assert_eq!(d.speed_raw, *speed, "speed in {hex}");
}
}
#[test]
fn the_cog_locks_cadence_to_speed_at_a_constant_ratio() {
// The signature that identifies the cadence/speed pair: one sprocket
// means the ratio cannot vary, whatever the rider does.
let ratios: Vec<f32> = CAPTURED
.iter()
.filter(|(_, _, c, _)| *c > 0)
.map(|(_, _, c, s)| *s as f32 / *c as f32)
.collect();
let first = ratios[0];
for r in &ratios {
assert!((r - first).abs() / first < 0.02, "ratio drifted: {ratios:?}");
}
assert!((first - 28.6).abs() < 0.5, "expected ~28.6, got {first}");
}
#[test]
fn cadence_reads_in_rpm() {
let d = decode_riding_data(&[0x03, 0x18, 0xb4, 0x04]).unwrap();
assert_eq!(d.cadence_drpm, 564);
assert!((d.cadence_rpm() - 56.4).abs() < 0.001);
}
#[test]
fn a_non_riding_frame_is_rejected_not_misread() {
assert!(decode_riding_data(&[0x23, 0x08, 0x01]).is_none());
assert!(decode_riding_data(&[]).is_none());
}
#[test]
fn a_truncated_varint_does_not_panic() {
// NFR-4: malformed input must never take the app down.
assert!(decode_riding_data(&[0x03, 0x08, 0xff]).is_none());
}
}