//! 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.4–3.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 { 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 { 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> { 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 { 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 0–3, /// the four face buttons take 4–7, 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 9–11 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::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 { (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