diff --git a/.gitignore b/.gitignore index 88e9522..d001257 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,10 @@ src-tauri/gen/ # Ride data *.fit /rides/ + +# Arch packaging (makepkg work dirs + built packages) +/packaging/arch/pkg/ +/packaging/arch/src/ +/packaging/arch/*.pkg.tar.zst +/.cargo-arch/ +bikecontrol.log diff --git a/Cargo.lock b/Cargo.lock index 3ef6558..46ef893 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,7 @@ name = "bikecontrol-app" version = "0.1.0" dependencies = [ "anyhow", + "bikecontrol-ble", "bikecontrol-core", "roxmltree", "serde", @@ -131,6 +132,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/README.md b/README.md index b8a47ab..5208604 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,19 @@ See [REQUIREMENTS.md](REQUIREMENTS.md) for the full specification. | Component | State | |-----------|-------| | `crates/core` — physics, profiles, GPX import, ride engine | Implemented, 103 tests | -| `crates/ble` — FTMS client for the D100 | Implemented, 96 tests. **Not yet wired into the app** | +| `crates/ble` — FTMS client for the D100, Zwift Click protocol | Implemented, 108 tests. **FTMS and the Click are both wired into the app** | | `crates/fit` — FIT activity encoder | Implemented, 106 tests. **Not yet wired into the app** | | `crates/probe` — hardware discovery CLI | **Works against the real trainer** | -| `src-tauri` + `ui` — desktop app | Runs on a **mock rider**, not the real trainer | +| `src-tauri` + `ui` — desktop app | **Rides the real trainer**: scan → connect → control → telemetry | -The GUI and the trainer are both working, but **not yet connected to each other** — the -app ticks a synthetic rider while `probe` talks to the real hardware. Joining them is the -next step. +The app drives the hardware end to end: it scans over BlueZ, connects, acquires FTMS +control, feeds Indoor Bike Data into the ride engine, and writes control targets back at +4 Hz. Recording to FIT is the remaining gap. + +The synthetic rider is still available for GUI work with no hardware on the desk — +`BIKECONTROL_MOCK=1`, or `BIKECONTROL_DEMO=1` which also loads the sample route and starts +riding. Both need the `mock-ride` feature, which is on by default; +`--no-default-features` produces a binary that can only ever show real trainer data. --- @@ -79,6 +84,27 @@ cargo tauri dev Every shortcut is mirrored by an on-screen control. +### Zwift Click + +Connect from the device screen — **press a button on the pod first**, since a Click only +advertises while awake. Each button routes to the same intent as the equivalent key, so the +two can never drift apart: + +| Button | Action | +|--------|--------| +| `+` / `−` | Shift a gear: ±10 W in ERG, one level in resistance mode, else gradient ±0.5% | +| D-pad `↑` / `↓` | Gradient +0.5% / −0.5% | +| D-pad `←` / `→` | Device screen / ride screen | +| `A` | Pause / resume | +| `B` | Insert lap marker | +| `Y` | Cycle control mode | +| `Z` | Profiles and routes | + +Shifting is **emulated app-side**. The D100 exposes no virtual-shifting command surface — +its Zwift service only streams telemetry (REQUIREMENTS.md §2.3.2) — so a "gear" is a step +in whatever target the active control mode drives. The keyboard remains the fallback if a +pod's battery dies mid-ride. + --- ## Talking to the trainer @@ -94,6 +120,7 @@ cargo build -p bikecontrol-probe ./target/debug/probe inspect --name VANRYSEL # services, characteristics, capabilities ./target/debug/probe monitor --name VANRYSEL # live telemetry: raw hex + decoded ./target/debug/probe set --name VANRYSEL sim=4.0 # apply a target, then auto-reset +./target/debug/probe zwift # Zwift Click: handshake, then log frames ``` `set` accepts `gradient=`, `sim=`, `resistance=` or `power=`, and @@ -123,6 +150,45 @@ Note `SetTargetInclination (0x03)` is **not** supported, and its range character reports only 0–6% with no negatives — so simulation mode is the only usable path for gradient. +### What the Zwift Click v2 reports + +Confirmed against the hardware — the pods talk to us **unencrypted**: + +``` +Zwift Click (two pods, one BLE peripheral each) + advertises 0xFC82, manufacturer 0x094a: 0a… and 0b… <-- type byte per pod + service 0xFC82 wraps the familiar Zwift characteristics: + 00000002-19ca-… notify device events + 00000003-19ca-… write-without-response commands in + 00000004-19ca-… read, indicate responses out + 00000100/0101/0102-19ca-… undocumented, silent so far + + -> 526964654f6e0009 ("RideOn" + 00 09) + <- 526964654f6e0203 ("RideOn" + 02 03) no key exchange, no encryption + <- 191064 battery 100% + <- 2308f7ffffff0f buttons: mask 0xfffffff7, bit 3 held +``` + +Two things differ from the public write-ups: the v2 puts everything under `0xFC82` rather +than the trainer's `00000001-19ca-…` service, and it reports buttons as a **32-bit +active-low bitmask** (`0x23`) rather than the documented two-varint `0x37` message. Idle is +`0xffffffff`; a clear bit means pressed. + +The bit map is confirmed against the hardware: + +| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | +|-----|---|---|---|---|---|---|---|---|---|----| +| Button | `left` | `up` | `right` | `down` | `A` | `B` | `Y` | `Z` | `−` | `+` | + +D-pad on 0–3, face buttons on 4–7, paddles at 8 and 12; bits 9–11 are unclaimed. Use +`probe zwift --buttons` to see named presses one line at a time: + +``` +[ 3.51s] PRESS #1 A (bit 4) mask 0xffffffef +[ 5.44s] RELEASE --- mask 0xffffffff +[ 6.16s] PRESS #2 Z (bit 7) mask 0xffffff7f +``` + --- ## Profiles diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index cb275ec..a49131c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -72,6 +72,33 @@ this trainer (§3.2). > `SetIndoorBikeSimulationParameters` (`0x11`). TASK-1 resolves whether `0x11` works. Low > impact either way, because the app owns the physics (FR-7.1). +### 2.1.1 Cadence is on the Zwift channel, not FTMS ✅ *(measured 2026-08-05)* + +The D100's Fitness Machine Feature bits (`014000000ca00000`) advertise average +speed and power only — **no cadence** — and that is accurate for FTMS. The ten +undeclared trailing bytes in its Indoor Bike Data were investigated and carry no +cadence either: the only varying field is a fixed **73.8x the declared speed** +(wheel RPM restated), constant to 0.5% from 1.7 to 29.7 km/h. + +Cadence is nevertheless available, on the **trainer's Zwift service**. Message +type `0x03` at ~1 Hz, five protobuf varints, of which two are independent: + +| field | meaning | note | +|-------|---------|------| +| 1 | instantaneous power, W | verified: ~120 W then ~350 W across two effort phases | +| 2 | heart rate | always 0 with no strap paired | +| 3 | **cadence, 0.1 rpm** | verified: 620 -> 62.0 rpm, 1030 -> 103.0 rpm against held cadences | +| 4 | duplicate of field 1 | ratio exactly 1.00 on every frame | +| 5 | speed | a fixed **28.6x field 3** | + +Field 5 being a constant multiple of field 3 is the *signature of the Cog*: one +sprocket means cadence and wheel speed are mechanically locked, so the ratio is +the gearing. Observing that constant is what identifies the pair. + +Decoded by `bikecontrol_ble::zwift::decode_riding_data`, tested against captured +frames. **This unlocks torque-based virtual gearing** (tau = P/omega), replacing the +provisional gradient-offset table in `crates/core/src/gearing.rs`. + ### 2.2 Zwift Cog A single 14T cog — **no mechanical gears**. This is why virtual shifting (§5.4) is a core @@ -100,22 +127,91 @@ The rider's procedure — **free, no paid subscription required**: > implementation lives in a **private submodule**. We therefore assume the manual daily > unlock. Replicating the keep-alive is explicitly out of scope; see §1.2. -### 2.3.1 Zwift BLE protocol +### 2.3.1 Zwift BLE protocol ✅ **TASK-0 answered** -Established from three independent open-source implementations (§3.4–3.6). +Originally established from three open-source implementations (§3.4–3.6), then **confirmed +against our own pods on 2026-08-05** with `probe zwift`. Where the two disagree, the +hardware wins and the difference is called out below. -| Item | Value | -|------|-------| -| Custom service | `00000001-19CA-4651-86E5-FA29DCDD09D1` | -| Async (notify) | `00000002-…` — button notifications | -| Sync RX (write) | `00000003-…` — commands to device | -| Sync TX (indicate) | `00000004-…` — responses | -| Unknown (indicate/read/write) | `00000006-…` — purpose undetermined | -| Manufacturer ID | 2378 (`0x094A`); device byte `0x09` = Click v1, `0x0A`/`0x0B` = v2 | -| Handshake | Write `RideOn` (`52 69 64 65 4F 6E`) + 2 bytes to Sync RX; device replies on Sync TX | -| Message types | `0x07` controller notification · `0x15` empty/keepalive · `0x19` battery · `0x37` Click button state (two protobuf varints) | +| Item | Value | Confidence | +|------|-------|------------| +| Service **on the Click v2** | `0000FC82-0000-1000-8000-00805F9B34FB` | **Confirmed** | +| Service **on the D100 trainer** | `00000001-19CA-4651-86E5-FA29DCDD09D1` | **Confirmed** | +| Async (notify) | `00000002-19CA-…` — device events | **Confirmed** | +| Sync RX (write-without-response) | `00000003-19CA-…` — commands to device | **Confirmed** | +| Sync TX (read/indicate) | `00000004-19CA-…` — responses | **Confirmed** | +| `00000100-…`, `00000101-…` (write/notify), `00000102-…` (write-without-response/notify) | undocumented, silent so far | **Confirmed present** | +| `00000006-…` | **absent** on our v2 | — | +| Manufacturer ID | 2378 (`0x094A`) | **Confirmed** | +| Device type byte | `0x0A` and `0x0B` — **one per pod, not a version marker** | **Confirmed** | +| Handshake | Write `52 69 64 65 4F 6E 00 09` to Sync RX | **Confirmed** | +| Handshake reply | `52 69 64 65 4F 6E 02 03` on Sync TX — `02 03`, **not** the documented `01 03` | **Confirmed** | +| Button state | `0x23` + one protobuf varint: a **32-bit active-low bitmask** | **Confirmed** | +| Battery | `0x19`; `19 10 64` = 100% | **Confirmed** | +| `0x37` two-varint button message | **never seen** on our v2 — presumably v1 | Superseded | -**Encryption.** The handshake performs a key exchange and messages are then encrypted: +> **The two services are not interchangeable.** The Click v2 wraps the *same* `…-19CA-…` +> characteristics in the SIG-assigned service `0xFC82`; the trainer uses the original custom +> service UUID. Discovery must accept either. + +**Button bitmask.** Idle is `0xFFFFFFFF`; a **clear** bit means pressed, and the pod streams +at roughly 10 Hz while anything is held. Two bits can be clear at once, so it is live state, +not an event code. + +**Bit map — confirmed 2026-08-05.** Established by pressing each button in a known order, +reproduced across two full runs plus targeted spot checks: + +| Bit | Button | Mask when held | | Bit | Button | Mask when held | +|-----|--------|----------------|---|-----|--------|----------------| +| 0 | `left` | `0xFFFFFFFE` | | 5 | `B` | `0xFFFFFFDF` | +| 1 | `up` | `0xFFFFFFFD` | | 6 | `Y` | `0xFFFFFFBF` | +| 2 | `right` | `0xFFFFFFFB` | | 7 | `Z` | `0xFFFFFF7F` | +| 3 | `down` | `0xFFFFFFF7` | | 8 | `−` paddle | `0xFFFFFEFF` | +| 4 | `A` | `0xFFFFFFEF` | | 12 | `+` paddle | `0xFFFFEFFF` | + +D-pad on 0–3, face buttons on 4–7, paddles at 8 and 12. **Bits 9–11 are unclaimed** — no +button we have found drives them. + +> `A` and `Z` disagreed between the first two captures and were settled by an alternating +> two-button test returning 4, 7, 4, 7, 4, 7. The first capture had been pressed out of +> order. + +> **Open:** all ten buttons arrived over the **single** pod at `f4:c4:59:03:a1:8e` +> (type byte `0x0B`). What the second pod (`c0:4a:0e:f9:a8:78`, `0x0A`) contributes is +> unknown — it may mirror the same state, or carry nothing we need. + +### 2.3.2 The D100's own Zwift service — telemetry, not shifting + +The trainer answers the same handshake (`RideOn 00 09` → `RideOn 02 00`) on the original +`00000001-19CA-…` service, and then streams a `0x03` message at ~1 Hz: + +| Field | Pedalling | Coasting | Reading | +|-------|-----------|----------|---------| +| 1 | 177 → 180 → 120 | 6 | **Instantaneous power, watts** | +| 2 | 0 | 0 | always zero | +| 3 | 558 → 601 → 621 | 174 | monotonic counter | +| 4 | 177 → 180 → 120 | 6 | mirrors field 1 exactly | +| 5 | 15968 → 17184 → 17748 | 4976 | monotonic counter, constant 28.6× ratio to field 3 | + +Fields 3 and 5 rise monotonically at a fixed ratio, so they are cumulative counters +(distance and revolutions, in unidentified units), not gears. + +**Conclusion: this is a read channel.** Nothing here is a virtual-shifting *command* +surface, and no gear field appears. Establishing whether the trainer would *accept* a gear +command means fuzzing unknown writes to a device that controls resistance under a rider — +open-ended and not obviously safe. **Virtual shifting is therefore emulated app-side** +(§5.4), which the physics model was always designed for. + +> Corroboration: `qdomyos-zwift` [#3678](https://github.com/cagnulein/qdomyos-zwift/issues/3678) +> reports Click shifting on a D100 dying after 30–60 s with the pod's LED dropping out — +> the §2.3 unlock-timeout signature. Labelled `wontfix`. Our unencrypted sessions ran past +> **three minutes** with button edges still arriving, so this path does not appear to hit +> that timeout — but a long ride is the real test. + +**Encryption — not required.** ✅ **A-3 holds for the v2.** The pod completed the handshake +and streamed button and battery events with **no key exchange and no encryption at all**. +The ECDH P-256 → HKDF → AES-256-CCM path documented below was therefore *not* needed, and +no crypto crate has been added. | Stage | Mechanism | |-------|-----------| @@ -124,12 +220,14 @@ Established from three independent open-source implementations (§3.4–3.6). | Cipher | **AES-256-CCM**, 4-byte MAC | | Framing | 4-byte big-endian counter prepended per message; nonce = IV ‖ counter | -All of this maps onto pure-Rust RustCrypto crates (`p256`, `hkdf`, `sha2`, `aes`, `ccm`), -so no C dependency is needed. +Retained only in case a firmware update closes the unencrypted path. It maps onto pure-Rust +RustCrypto crates (`p256`, `hkdf`, `sha2`, `aes`, `ccm`), so reinstating it needs no C +dependency. -> **A-3:** At least one implementation connects to a Click **without** encryption and still -> receives button and battery events (§3.6). Whether that holds for a v2 is unknown — -> TASK-0 tests the unencrypted path first, since it would be far simpler. +> **Open:** whether the daily Zwift-app unlock (§2.3) is needed at all on this path is +> **untested** — the successful session followed recent Zwift use, so it cannot yet be +> distinguished from an unlock that was simply still valid. Re-test after 24 hours away +> from the Zwift app. ### 2.4 Environmental assumptions @@ -280,6 +378,16 @@ bikecontrol/ | FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must | | FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must | | FR-1.9 | Allow riding with the trainer alone; on-screen and keyboard controls substitute | Must | +| FR-1.10 | A connect or reconnect attempt in flight is cancellable: disconnect and app exit abandon it rather than queue behind it, and any half-open GATT link is closed on the way out | Must | +| FR-1.11 | Auto-reconnect is bounded. On giving up, the trainer settles in `Lost` carrying the reason — never silently back to `Idle` | Must | +| FR-1.12 | Scanning resumes on its own whenever no trainer link is held or being attempted, so a disconnect or a failed connect does not leave a frozen device list | Should | + +**Cancellation note (FR-1.10):** a connect is a long operation — up to `scan_timeout` before the +peripheral is even found, then connect, service discovery and the control handshake. Waiting for +it to finish before honouring a disconnect or a quit is what makes SAF-2 miss its budget, so the +attempt must be abandoned instead. Abandoning is not free: the D100 accepts one BLE host (A-3), +so an attempt that had already opened a link must close it, or the trainer stays unreachable +until it times out on its own. ### 5.2 Trainer control (FR-2) @@ -500,6 +608,7 @@ tests, and removes dependence on the trainer's internal mass assumptions. | FR-9.10 | On-screen and keyboard equivalents for all controller actions | Must | | FR-9.11 | Instantaneous power is noisy — show a rolling average alongside or instead | Should | | FR-9.12 | Dark theme suitable for indoor training | Should | +| FR-9.16 | Estimated energy expenditure in kcal, from measured work and rider mass. One model shared by the live readout and the exported FIT, so the two agree; the trainer's own energy field is not used, because FTMS does not define what it means | Should | **Post-ride** @@ -522,6 +631,8 @@ tests, and removes dependence on the trainer's internal mass assumptions. | NFR-6 | **Offline** — full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet | | NFR-7 | **Startup** — launch to scanning in under 3 seconds | | NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis | +| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit | +| NFR-10 | **No busy-waiting** — a supervisor whose event source has closed drops it. No loop may spin on a permanently-ready future, and no arm of a `biased` select may starve the one that carries the shutdown command | --- @@ -536,6 +647,8 @@ tests, and removes dependence on the trainer's internal mass assumptions. | SAF-5 | Never issue a step larger than one configured increment per input event | | SAF-6 | Waveform parameter errors must not command an unsafe target (enforced by SAF-3) | | SAF-7 | A stale or replayed Click frame must not re-trigger an action — enforce the session counter and reject out-of-order frames | +| SAF-8 | The shutdown sequence preempts in-flight BLE work. A connect or reconnect is abandoned, and every step of the reset is individually bounded, so SAF-2 finishes inside the NFR-9 budget rather than timing out | +| SAF-9 | Every BLE link the app owns — trainer *and* controller — is explicitly closed on exit. A link the process merely abandons can leave the peripheral held and unreachable on the next launch (A-3) | --- diff --git a/crates/ble/src/click.rs b/crates/ble/src/click.rs new file mode 100644 index 0000000..af622a0 --- /dev/null +++ b/crates/ble/src/click.rs @@ -0,0 +1,499 @@ +//! The Zwift Click client: an async actor that owns a controller peripheral. +//! +//! Structurally a smaller sibling of [`crate::client`]. One task owns the +//! peripheral; callers hold a cheap handle and receive [`ClickEvent`]s on a +//! broadcast channel. +//! +//! ```text +//! caller ◄──broadcast── ClickEvent ◄── actor ◄─notify── Click pod +//! ``` +//! +//! The protocol itself lives in [`crate::zwift`] as pure functions over bytes; +//! this module is only the radio and the reconnect loop. See REQUIREMENTS.md +//! §2.3.1 for what was confirmed against the hardware — notably that a Click v2 +//! needs **no encryption**, so there is no key exchange here to get wrong. +//! +//! Unlike the trainer, a controller has no safety story: it never commands +//! load, and the only bytes ever written to it are the `RideOn` handshake. + +use std::future::Future; +use std::time::Duration; + +use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType}; +use btleplug::platform::{Adapter, Peripheral}; +use futures::{Stream, StreamExt}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT}; +use crate::error::FtmsError; +use crate::scan::{self, TrainerSelector}; +use crate::zwift::{self, Button, ButtonTracker}; + +/// Tunables for [`ClickClient`]. +#[derive(Debug, Clone)] +pub struct ClickConfig { + /// How long to look for the pod before giving up. A Click sleeps quickly + /// and only advertises after a button press (A-4), so this is generous. + pub scan_timeout: Duration, + pub backoff: Backoff, + pub channel_capacity: usize, +} + +impl Default for ClickConfig { + fn default() -> Self { + Self { + scan_timeout: Duration::from_secs(20), + backoff: Backoff::default(), + channel_capacity: 64, + } + } +} + +/// Everything the app learns from a controller. +#[derive(Debug, Clone, PartialEq)] +pub enum ClickEvent { + Connected { + address: String, + name: Option, + }, + /// The link dropped. Any held button has already been reported as released. + Disconnected, + /// A press or release edge. Repeats while held are filtered out here, not + /// by the consumer (see [`ButtonTracker`]). + Button { button: Button, pressed: bool }, + Battery { percent: u8 }, + /// A frame we could not interpret. Surfaced rather than dropped: the + /// protocol is only partly documented, and silence would hide the parts we + /// have not met yet. + Unknown { kind: u8, raw: Vec }, +} + +enum Cmd { + Shutdown { reply: oneshot::Sender<()> }, +} + +/// Cheap, cloneable handle to a controller session. +#[derive(Clone)] +pub struct ClickClient { + cmd_tx: mpsc::Sender, + events_tx: broadcast::Sender, +} + +impl ClickClient { + /// Connect to a Click and start streaming events. + /// + /// Returns once the pod has answered the handshake, so a caller that gets + /// an `Ok` knows the controller is genuinely talking — not merely that a + /// BLE link exists. + pub async fn connect( + selector: TrainerSelector, + config: ClickConfig, + ) -> Result { + let adapter = scan::default_adapter().await?; + Self::connect_with_adapter(adapter, selector, config).await + } + + /// As [`ClickClient::connect`], but abandoned as soon as `cancel` resolves. + /// + /// Returns `Ok(None)` when it was cancelled. A Click only advertises after a + /// button press (A-4), so a connect routinely runs the full `scan_timeout` — + /// twenty seconds, which is long enough that a quit waiting for it reads as + /// a hang (FR-1.10). Any link the abandoned attempt had opened is closed + /// before this returns (SAF-9). + pub async fn connect_cancellable( + selector: TrainerSelector, + config: ClickConfig, + cancel: impl Future, + ) -> Result, FtmsError> { + let in_flight = InFlight::default(); + let outcome = { + let attempt = async { + let adapter = scan::default_adapter().await?; + Self::connect_on(adapter, selector, config, &in_flight).await + }; + tokio::pin!(attempt, cancel); + tokio::select! { + result = &mut attempt => Some(result), + () = &mut cancel => None, + } + }; + match outcome { + Some(result) => result.map(Some), + None => { + tracing::info!("click: connect attempt cancelled"); + in_flight.abandon().await; + Ok(None) + } + } + } + + /// As [`ClickClient::connect`], but on a caller-supplied adapter. + pub async fn connect_with_adapter( + adapter: Adapter, + selector: TrainerSelector, + config: ClickConfig, + ) -> Result { + Self::connect_on(adapter, selector, config, &InFlight::default()).await + } + + async fn connect_on( + adapter: Adapter, + selector: TrainerSelector, + config: ClickConfig, + in_flight: &InFlight, + ) -> Result { + let (events_tx, _) = broadcast::channel(config.channel_capacity); + let (cmd_tx, cmd_rx) = mpsc::channel(8); + + let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?; + let _ = events_tx.send(ClickEvent::Connected { + address: session.address.clone(), + name: session.name.clone(), + }); + + let actor = Actor { + adapter, + selector, + config, + events_tx: events_tx.clone(), + tracker: ButtonTracker::new(), + }; + tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications))); + + Ok(Self { cmd_tx, events_tx }) + } + + /// Subscribe to controller events. Late subscribers see only what arrives + /// after they subscribe. + pub fn events(&self) -> broadcast::Receiver { + self.events_tx.subscribe() + } + + /// Close the link, and wait for it to actually be closed. + /// + /// SAF-9: a pod whose link the process merely abandons can stay held by + /// BlueZ and be unreachable on the next launch. Fire-and-forget would return + /// before the disconnect had even been issued, which at app exit means it + /// never is. Idempotent — shutting down a stopped client is a no-op. + pub async fn shutdown(&self) { + let (reply, done) = oneshot::channel(); + if self.cmd_tx.send(Cmd::Shutdown { reply }).await.is_err() { + // The actor is already gone; it tore the link down on its way out. + return; + } + let _ = done.await; + } +} + +/// Boxed so that the initial connection and every reconnect share one type — +/// `impl Stream` would mint a fresh opaque type per call site and the two could +/// not be assigned to the same variable. +type Notifications = std::pin::Pin< + Box + Send>, +>; + +/// A live connection: the peripheral plus the characteristics we care about. +struct Session { + peripheral: Peripheral, + address: String, + name: Option, + subscribed: Vec, +} + +/// Find the pod, connect, subscribe, and complete the `RideOn` handshake. +async fn open_session( + adapter: &Adapter, + selector: &TrainerSelector, + config: &ClickConfig, + in_flight: &InFlight, +) -> Result<(Session, Notifications), FtmsError> { + let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?; + // Cancelling past this point would otherwise strand the link (FR-1.10). + in_flight.hold(peripheral.clone()); + + match setup_session(peripheral.clone()).await { + Ok(session) => { + in_flight.released(); + Ok(session) + } + Err(e) => { + // Every failure after `connect()` leaves a live GATT link behind, + // and a pod that is still held will not advertise for the next + // attempt — so the retry loop would never succeed. + tracing::debug!(error = %e, "click: session setup failed; disconnecting"); + let _ = peripheral.disconnect().await; + in_flight.released(); + Err(e) + } + } +} + +async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> { + if !peripheral.is_connected().await.unwrap_or(false) { + peripheral.connect().await?; + } + peripheral.discover_services().await?; + + // A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Both hold + // the same characteristics, so take whichever is present. + let service = zwift::SERVICES + .iter() + .find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want)) + .ok_or(FtmsError::MissingCharacteristic("Zwift custom service"))?; + + let notifications = peripheral.notifications().await?; + + // Subscribe before writing, so the handshake reply cannot outrun us. + let mut subscribed = Vec::new(); + for ch in &service.characteristics { + if ch + .properties + .intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE) + { + match peripheral.subscribe(ch).await { + Ok(()) => subscribed.push(ch.clone()), + Err(e) => tracing::debug!(uuid = %ch.uuid, error = %e, "click: subscribe failed"), + } + } + } + if subscribed.is_empty() { + return Err(FtmsError::MissingCharacteristic( + "a notifying Zwift characteristic", + )); + } + + let sync_rx = service + .characteristics + .iter() + .find(|c| c.uuid == zwift::SYNC_RX) + .ok_or(FtmsError::MissingCharacteristic("Zwift sync RX"))?; + let write_type = if sync_rx + .properties + .contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) + { + WriteType::WithoutResponse + } else { + WriteType::WithResponse + }; + peripheral + .write(sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type) + .await?; + + let described = scan::describe(&peripheral).await; + Ok(( + Session { + address: described + .as_ref() + .map(|d| d.address.clone()) + .unwrap_or_default(), + name: described.and_then(|d| d.name), + peripheral, + subscribed, + }, + Box::pin(notifications), + )) +} + +struct Actor { + adapter: Adapter, + selector: TrainerSelector, + config: ClickConfig, + events_tx: broadcast::Sender, + tracker: ButtonTracker, +} + +impl Actor { + async fn run( + mut self, + mut cmd_rx: mpsc::Receiver, + mut current: Session, + mut notifications: Notifications, + ) { + let mut attempt = 0u32; + + loop { + let dropped = tokio::select! { + cmd = cmd_rx.recv() => { + match cmd { + Some(Cmd::Shutdown { reply }) => { + self.teardown(¤t).await; + // Answered only once the link is genuinely closed, + // so a caller blocking the app's exit on this knows + // what it waited for (SAF-9). + let _ = reply.send(()); + return; + } + None => { + self.teardown(¤t).await; + return; + } + } + } + frame = notifications.next() => match frame { + Some(n) => { self.handle(&n.value); false } + // The stream ending is how btleplug reports a dropped link. + None => true, + }, + }; + + if !dropped { + continue; + } + + // Release anything still held, so a paddle held through a dropout + // cannot latch (see ButtonTracker::reset). + self.release_held(); + let _ = self.events_tx.send(ClickEvent::Disconnected); + + // FR-1.6: reconnect with backoff. A Click sleeps aggressively, so + // "not found" is the normal case, not an error worth giving up on. + loop { + if self.config.backoff.exhausted(attempt) { + tracing::warn!("click: giving up after {attempt} reconnect attempts"); + return; + } + let delay = self.config.backoff.delay(attempt); + attempt += 1; + + // Both the backoff *and* the attempt stay answerable to + // shutdown. `open_session` runs for as long as `scan_timeout` + // — twenty seconds against a pod that only advertises after a + // button press — and a quit that waits for that is a quit that + // leaves the link open (FR-1.10, SAF-9). + let in_flight = InFlight::default(); + let outcome = { + let attempting = async { + tokio::time::sleep(delay).await; + open_session(&self.adapter, &self.selector, &self.config, &in_flight).await + }; + tokio::pin!(attempting); + tokio::select! { + biased; + cmd = cmd_rx.recv() => Err(cmd), + result = &mut attempting => Ok(result), + } + }; + + let result = match outcome { + Ok(result) => result, + Err(cmd) => { + // There is no session to tear down — the attempt owns + // whatever link exists, so closing that is the whole job. + in_flight.abandon().await; + if let Some(Cmd::Shutdown { reply }) = cmd { + let _ = reply.send(()); + } + return; + } + }; + + match result { + Ok((session, stream)) => { + let _ = self.events_tx.send(ClickEvent::Connected { + address: session.address.clone(), + name: session.name.clone(), + }); + current = session; + notifications = stream; + attempt = 0; + break; + } + Err(e) => tracing::debug!(error = %e, "click: reconnect failed"), + } + } + } + } + + /// Decode one notification into events. + fn handle(&mut self, raw: &[u8]) { + if zwift::is_ride_on_reply(raw) { + tracing::debug!("click: RideOn acknowledged"); + return; + } + let Some(frame) = zwift::parse_frame(raw) else { + return; + }; + + match frame.kind { + zwift::MessageType::ButtonBitmask => { + match zwift::decode_button_bitmask(frame.payload) { + Ok(mask) => { + for edge in self.tracker.update(mask) { + let _ = self.events_tx.send(ClickEvent::Button { + button: edge.button, + pressed: edge.pressed, + }); + } + } + Err(e) => tracing::debug!(error = %e, "click: bad button frame"), + } + } + zwift::MessageType::Battery => { + if let Ok(Some(percent)) = zwift::decode_battery(frame.payload) { + let _ = self.events_tx.send(ClickEvent::Battery { percent }); + } + } + zwift::MessageType::KeepAlive => {} + zwift::MessageType::Unknown(kind) => { + let _ = self.events_tx.send(ClickEvent::Unknown { + kind, + raw: raw.to_vec(), + }); + } + _ => {} + } + } + + /// Emit a release for every button the tracker still believes is held. + fn release_held(&mut self) { + for button in self.tracker.held() { + let _ = self.events_tx.send(ClickEvent::Button { + button, + pressed: false, + }); + } + self.tracker.reset(); + } + + /// Close the link. Every step is bounded: this runs on the app's exit path, + /// where a radio operation that never returns is a window that never shuts + /// (NFR-9). + async fn teardown(&mut self, session: &Session) { + self.release_held(); + for ch in &session.subscribed { + let unsubscribe = session.peripheral.unsubscribe(ch); + let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, unsubscribe).await; + } + match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { + Ok(Ok(())) => tracing::info!("click: disconnected"), + Ok(Err(e)) => tracing::debug!(error = %e, "click: disconnect failed"), + Err(_) => tracing::warn!("click: disconnect timed out"), + } + let _ = self.events_tx.send(ClickEvent::Disconnected); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_are_patient_enough_for_a_sleeping_pod() { + let c = ClickConfig::default(); + assert!(c.scan_timeout >= Duration::from_secs(10)); + // Retry forever: a Click that has gone to sleep is the normal case. + assert_eq!(c.backoff.max_attempts, None); + } + + #[test] + fn events_compare_by_value() { + assert_eq!( + ClickEvent::Button { button: Button::Plus, pressed: true }, + ClickEvent::Button { button: Button::Plus, pressed: true } + ); + assert_ne!( + ClickEvent::Button { button: Button::Plus, pressed: true }, + ClickEvent::Button { button: Button::Plus, pressed: false } + ); + } +} diff --git a/crates/ble/src/client.rs b/crates/ble/src/client.rs index a700a7c..c1a6881 100644 --- a/crates/ble/src/client.rs +++ b/crates/ble/src/client.rs @@ -17,6 +17,8 @@ //! MIT licensed, Copyright (c) 2025 Ogi. See REQUIREMENTS.md §3.2. use std::collections::VecDeque; +use std::future::Future; +use std::sync::Arc; use std::time::{Duration, Instant}; use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry}; @@ -232,11 +234,55 @@ impl FtmsClient { Self::connect_with_adapter(adapter, selector, config).await } + /// As [`FtmsClient::connect`], but abandoned as soon as `cancel` resolves. + /// + /// Returns `Ok(None)` when it was cancelled. A connect runs for up to + /// [`FtmsConfig::scan_timeout`] before the trainer has even been found, and + /// a caller that needs to stop — the app is quitting, the rider pressed + /// disconnect — cannot wait that out (FR-1.10, SAF-8). Any link the + /// abandoned attempt had already opened is closed before this returns, so + /// the trainer is free for the next launch (A-3). + pub async fn connect_cancellable( + selector: TrainerSelector, + config: FtmsConfig, + cancel: impl Future, + ) -> Result, FtmsError> { + let in_flight = InFlight::default(); + let outcome = { + let attempt = async { + let adapter = scan::default_adapter().await?; + Self::connect_on(adapter, selector, config, &in_flight).await + }; + tokio::pin!(attempt, cancel); + tokio::select! { + result = &mut attempt => Some(result), + () = &mut cancel => None, + } + }; + match outcome { + Some(result) => result.map(Some), + None => { + tracing::info!("connect attempt cancelled"); + in_flight.abandon().await; + Ok(None) + } + } + } + /// As [`FtmsClient::connect`], but on a caller-supplied adapter. pub async fn connect_with_adapter( adapter: Adapter, selector: TrainerSelector, config: FtmsConfig, + ) -> Result { + Self::connect_on(adapter, selector, config, &InFlight::default()).await + } + + async fn connect_on( + adapter: Adapter, + selector: TrainerSelector, + config: FtmsConfig, + in_flight: &InFlight, ) -> Result { let (state_tx, state_rx) = watch::channel(ConnectionState::Scanning); let (caps_tx, caps_rx) = watch::channel(TrainerCapabilities::default()); @@ -244,7 +290,7 @@ impl FtmsClient { let (events_tx, _) = broadcast::channel(config.channel_capacity); let (cmd_tx, cmd_rx) = mpsc::channel(64); - let connected = connect_session(&adapter, &selector, &config, &state_tx).await?; + let connected = connect_session(&adapter, &selector, &config, &state_tx, in_flight).await?; let address = connected.address.clone(); let name = connected.name.clone(); @@ -385,6 +431,16 @@ impl FtmsClient { /// on application exit: it is the difference between "the trainer is left /// at zero" and "probably". pub async fn shutdown(self) -> Result<(), FtmsError> { + self.shutdown_ref().await + } + + /// As [`FtmsClient::shutdown`], but by reference. + /// + /// A caller that shares the client — an `Arc` behind a + /// supervisor, so control writes can be spawned without stalling the + /// telemetry pump — cannot consume it, and SAF-2 must not depend on being + /// able to. Idempotent: shutting an already-stopped client down is a no-op. + pub async fn shutdown_ref(&self) -> Result<(), FtmsError> { let (reply, rx) = oneshot::channel(); if self .cmd_tx @@ -419,6 +475,60 @@ enum Command { }, } +/// Upper bound on one write in the SAF-2 reset sequence. +const SAFETY_WRITE_TIMEOUT: Duration = Duration::from_millis(750); +/// Total budget for the SAF-2 *writes*, leaving room inside NFR-9's eight +/// seconds for the disconnect that follows. Individually bounded writes are not +/// enough on their own: five of them plus their pacing can still outlast a +/// caller holding the app's exit open. The sequence zeroes the active targets +/// first (see [`safety_reset_commands`]), so a truncated run has still left the +/// trainer at minimum load — which is the part SAF-2 actually cares about. +const SAFETY_SEQUENCE_BUDGET: Duration = Duration::from_secs(4); +/// Upper bound on closing a link, whether deliberately or after a cancelled +/// attempt. A disconnect that hangs must not be the reason the app will not +/// quit (NFR-9). +pub(crate) const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2); + +/// The peripheral a connect attempt is working on right now. +/// +/// A cancelled attempt (FR-1.10) may already have opened a GATT link, and a +/// cancelled future cannot hand anything back to its caller — hence the shared +/// slot. Walking away from that link without closing it matters because the +/// D100 accepts one host (A-3): the difference is between "quit and relaunch +/// fixed it" and "power-cycle the trainer". +#[derive(Clone, Default)] +pub(crate) struct InFlight(Arc>>); + +impl InFlight { + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Take responsibility for a link the attempt is about to open. + pub(crate) fn hold(&self, peripheral: Peripheral) { + *self.lock() = Some(peripheral); + } + + /// The attempt finished and its outcome owns the link now — successfully, + /// or by having disconnected on its own error path. + pub(crate) fn released(&self) { + let _ = self.lock().take(); + } + + /// Close a link left half-open by a cancelled attempt (SAF-8). + pub(crate) async fn abandon(&self) { + let Some(peripheral) = self.lock().take() else { + return; + }; + tracing::info!("closing the half-open link from a cancelled connect attempt"); + match tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!(error = %e, "abandoned link would not close"), + Err(_) => tracing::warn!("abandoned link did not close in time"), + } + } +} + /// The characteristics we need on a connected peripheral. struct Session { peripheral: Peripheral, @@ -884,8 +994,49 @@ impl Actor { attempt += 1; self.set_state(ConnectionState::Connecting); - match connect_session(&self.adapter, &self.selector, &self.config, &self.state_tx).await - { + + // The *attempt* has to be interruptible too, not just the backoff + // sleep before it. It runs for up to `scan_timeout` plus connect, + // discovery and handshake — far longer than the caller's shutdown + // budget — and a shutdown that waits for it to finish is a shutdown + // that skips SAF-2 (FR-1.10, SAF-8). + let in_flight = InFlight::default(); + let outcome = { + let connect = connect_session( + &self.adapter, + &self.selector, + &self.config, + &self.state_tx, + &in_flight, + ); + tokio::pin!(connect); + loop { + tokio::select! { + biased; + cmd = cmd_rx.recv() => match cmd { + Some(Command::Shutdown { reply }) => break Attempt::Abandoned(Some(reply)), + Some(Command::SetTarget { reply, .. }) => { + let _ = reply.send(Err(FtmsError::NotConnected)); + } + Some(Command::Procedure { reply, .. }) => { + let _ = reply.send(Err(FtmsError::NotConnected)); + } + None => break Attempt::Abandoned(None), + }, + result = &mut connect => break Attempt::Finished(Box::new(result)), + } + } + }; + + let result = match outcome { + Attempt::Finished(result) => *result, + Attempt::Abandoned(reply) => { + in_flight.abandon().await; + return Reconnected::Shutdown(reply); + } + }; + + match result { Ok(connected) => { self.session = Some(connected.session); self.capabilities = connected.capabilities; @@ -918,23 +1069,36 @@ impl Actor { } let Some(session) = self.session.take() else { - self.set_state(ConnectionState::Idle); + // FR-1.11: a give-up has already published `Lost` with the reason + // it gave up for. `Idle` here would erase that and read as "never + // connected", which is the one thing the rider must not be told + // after the app spent minutes trying to get back. + if !matches!(*self.state_tx.borrow(), ConnectionState::Lost { .. }) { + self.set_state(ConnectionState::Idle); + } return; }; tracing::info!("running the SAF-2 shutdown sequence"); + let deadline = Instant::now() + SAFETY_SEQUENCE_BUDGET; for (op, bytes) in safety_reset_commands( &self.config.limits, &self.capabilities, self.config.use_simulation_mode, self.config.simulation_template, ) { + if Instant::now() >= deadline { + // The zeroing writes go first, so what is being skipped here is + // `Reset`/`Stop` on a trainer that is already at minimum load. + tracing::warn!(%op, "SAF-2 budget spent; skipping the rest of the reset sequence"); + break; + } let write = session.peripheral.write( &session.control_point, &bytes, WriteType::WithResponse, ); - match tokio::time::timeout(Duration::from_millis(750), write).await { + match tokio::time::timeout(SAFETY_WRITE_TIMEOUT, write).await { Ok(Ok(())) => tracing::debug!(%op, raw = %hex(&bytes), "shutdown write"), Ok(Err(e)) => tracing::warn!(%op, error = %e, "shutdown write failed"), Err(_) => tracing::warn!(%op, "shutdown write timed out"), @@ -943,7 +1107,7 @@ impl Actor { tokio::time::sleep(Duration::from_millis(60)).await; } - match tokio::time::timeout(Duration::from_secs(2), session.peripheral.disconnect()).await { + match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { Ok(Ok(())) => tracing::info!("disconnected from the trainer"), Ok(Err(e)) => tracing::warn!(error = %e, "disconnect failed"), Err(_) => tracing::warn!("disconnect timed out"), @@ -981,6 +1145,15 @@ enum Reconnected { GaveUp, } +/// How one reconnect attempt ended: on its own, or because the caller stopped +/// waiting for it (FR-1.10). +enum Attempt { + /// Boxed only to keep the two variants a similar size; a `ConnectedTrainer` + /// carries the whole session. + Finished(Box>), + Abandoned(Option>), +} + fn complete(reply: Reply, result: Result<(), FtmsError>) { match reply { Reply::Target { sent, tx } => { @@ -1071,6 +1244,7 @@ async fn connect_session( selector: &TrainerSelector, config: &FtmsConfig, state_tx: &watch::Sender, + in_flight: &InFlight, ) -> Result { let _ = state_tx.send_if_modified(|s| { if *s == ConnectionState::Scanning { @@ -1082,6 +1256,11 @@ async fn connect_session( }); let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?; + // From here until this function returns, cancelling the caller is the only + // thing that can leave a link open with nobody to close it. Hand the + // peripheral over now, before `connect()` — a cancellation lands wherever it + // lands, including halfway through the connect itself (FR-1.10). + in_flight.hold(peripheral.clone()); let described = scan::describe(&peripheral).await; let address = described .as_ref() @@ -1099,16 +1278,20 @@ async fn connect_session( // that accepts one connection at a time (A-3) would stay unavailable to the // next attempt. Set it up separately so every error path disconnects. match setup_session(peripheral.clone(), config, state_tx).await { - Ok((session, capabilities, notifications)) => Ok(ConnectedTrainer { - session, - capabilities, - notifications, - address, - name, - }), + Ok((session, capabilities, notifications)) => { + in_flight.released(); + Ok(ConnectedTrainer { + session, + capabilities, + notifications, + address, + name, + }) + } Err(e) => { tracing::warn!(error = %e, "connection setup failed; disconnecting"); let _ = peripheral.disconnect().await; + in_flight.released(); Err(e) } } @@ -1604,6 +1787,49 @@ mod tests { } } + #[test] + fn the_shutdown_sequence_cannot_outlast_the_apps_exit() { + // SAF-8 / NFR-9. Per-write timeouts alone are not a bound: five of them + // plus their pacing and the disconnect can still outlast the caller + // holding the window open, and a caller that gives up waiting leaves the + // trainer loaded. Hence a deadline across the whole sequence. + assert!(SAFETY_WRITE_TIMEOUT < SAFETY_SEQUENCE_BUDGET); + assert!(SAFETY_SEQUENCE_BUDGET + DISCONNECT_TIMEOUT < Duration::from_secs(8)); + } + + #[test] + fn a_truncated_shutdown_sequence_has_still_zeroed_the_load() { + // What the budget above may cut short is the tail. That is only + // acceptable because the zeroing writes come first — the trainer is at + // minimum load before `Reset` and `Stop` are even attempted, which is + // the part SAF-2 exists for. + for use_sim in [false, true] { + let cmds = safety_reset_commands( + &SafetyLimits::default(), + &TrainerCapabilities::default(), + use_sim, + SimulationParameters::default(), + ); + let ops: Vec = cmds.iter().map(|(op, _)| *op).collect(); + let first_zeroing = ops + .iter() + .position(|op| { + matches!( + op, + OpCode::SetTargetInclination + | OpCode::SetTargetResistanceLevel + | OpCode::SetIndoorBikeSimulationParameters + ) + }) + .expect("the sequence must zero something"); + let reset = ops + .iter() + .position(|op| *op == OpCode::Reset) + .expect("the sequence must reset"); + assert!(first_zeroing < reset, "{ops:?}"); + } + } + #[test] fn safety_reset_in_simulation_mode_sends_zero_grade_and_zero_wind() { let caps = TrainerCapabilities { diff --git a/crates/ble/src/lib.rs b/crates/ble/src/lib.rs index e3989ab..17ddee6 100644 --- a/crates/ble/src/lib.rs +++ b/crates/ble/src/lib.rs @@ -12,6 +12,7 @@ //! | [`indoor_bike_data`] | `0x2AD2` decoder | no | //! | [`control_point`] | `0x2AD9` encoders and response decoding | no | //! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no | +//! | [`zwift`] | Zwift's proprietary protocol (§2.3.1) | no | //! | [`scan`] | discovery | yes | //! | [`client`] | the connection actor | yes | //! @@ -47,13 +48,16 @@ //! (REQUIREMENTS.md §3.2). Per-module attribution notes mark where. pub mod capabilities; +pub mod click; pub mod client; pub mod control_point; pub mod error; pub mod indoor_bike_data; pub mod scan; pub mod uuids; +pub mod zwift; +pub use click::{ClickClient, ClickConfig, ClickEvent}; pub use capabilities::{ FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities, UnsupportedTarget, @@ -68,3 +72,7 @@ pub use scan::{ default_adapter, scan, scan_trainers, DiscoveredDevice, ScanKind, TrainerSelector, }; pub use uuids::FITNESS_MACHINE_SERVICE; +pub use zwift::{ + Button, ButtonBitmask, ClickButtons, DeviceKind as ZwiftDeviceKind, + MessageType as ZwiftMessageType, +}; diff --git a/crates/ble/src/scan.rs b/crates/ble/src/scan.rs index 0022bb4..c7b89e5 100644 --- a/crates/ble/src/scan.rs +++ b/crates/ble/src/scan.rs @@ -8,20 +8,14 @@ use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; use uuid::Uuid; use crate::error::FtmsError; -use crate::uuids; +use crate::{uuids, zwift}; /// Zwift's custom service UUID, used to recognise Click pods during a scan -/// (FR-1.2). The Click *client* is Phase 3 and lives elsewhere; discovery only -/// needs the UUID so `probe scan` can label them. -pub const ZWIFT_SERVICE: Uuid = Uuid::from_fields( - 0x0000_0001, - 0x19CA, - 0x4651, - &[0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1], -); +/// (FR-1.2). Re-exported from [`crate::zwift`], which owns the protocol. +pub use crate::zwift::SERVICE as ZWIFT_SERVICE; /// Zwift's Bluetooth SIG manufacturer ID (2378). -pub const ZWIFT_MANUFACTURER_ID: u16 = 0x094A; +pub use crate::zwift::MANUFACTURER_ID as ZWIFT_MANUFACTURER_ID; /// A peripheral seen during a scan. #[derive(Debug, Clone)] @@ -48,11 +42,23 @@ impl DiscoveredDevice { } /// True when the peripheral looks like a Zwift controller. + /// + /// Note that the Van Rysel D100 also advertises Zwift's custom service, so + /// this is "speaks the Zwift protocol", not "is a Click". pub fn is_zwift_device(&self) -> bool { self.services.contains(&ZWIFT_SERVICE) || self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID) } + /// Which Zwift device this is, from the type byte in its manufacturer data + /// (§2.3.1). `None` when it advertises no Zwift manufacturer data at all — + /// which is the case for a trainer that merely exposes the service. + pub fn zwift_kind(&self) -> Option { + self.manufacturer_data + .get(&ZWIFT_MANUFACTURER_ID) + .and_then(|d| zwift::DeviceKind::from_manufacturer_data(d)) + } + /// Best-effort human label. pub fn label(&self) -> String { match &self.name { diff --git a/crates/ble/src/zwift.rs b/crates/ble/src/zwift.rs new file mode 100644 index 0000000..1e4ede6 --- /dev/null +++ b/crates/ble/src/zwift.rs @@ -0,0 +1,1076 @@ +//! 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 + {:else} + + {/if} + + {#if !trainerReady}
diff --git a/ui/src/components/ControlBar.svelte b/ui/src/components/ControlBar.svelte index 9ed4727..cbc8363 100644 --- a/ui/src/components/ControlBar.svelte +++ b/ui/src/components/ControlBar.svelte @@ -10,6 +10,13 @@ const ride = $derived(app.ride); const running = $derived(ride?.status === 'running'); + /** + * Before a ride starts, the gradient trim, the lap marker and End have + * nothing to act on. The ride screen is showing a large Start button at that + * point, and nine similar-looking buttons beside it only make it harder to + * find. + */ + const started = $derived(ride?.status === 'running' || ride?.status === 'paused'); let flash = $state(null); let flashTimer: ReturnType | null = null; @@ -26,35 +33,39 @@
-
- - - -
+ {#if started} +
+ + + +
+ {/if}
-
- + {#if started} + + {/if} {#if running} {/if} - + {#if started} + + {/if} diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte index 0a382d1..25057e5 100644 --- a/ui/src/components/HelpOverlay.svelte +++ b/ui/src/components/HelpOverlay.svelte @@ -1,11 +1,23 @@ -
+
+ {#if simulated} + +
+ Simulated ride + Power, speed and distance are fabricated. No trainer is being read. +
+ {/if} +
-

{profile?.name ?? 'No route'}

- {#if profile?.description} -

{profile.description}

+

{profile?.name ?? 'No route loaded'}

+ {#if routeSummary} +

{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}

{:else} -

Manual control — load a profile to ride terrain.

+

Manual control — choose a route to ride real terrain.

{/if}
- {#if ride?.source === 'mock'} - Simulated — no trainer + {#if trainerChip} + {trainerChip.label} {/if} {statusChip.label} {MODE_LABEL[ride?.mode ?? 'ManualGrade']} Target {targetText(ride?.target ?? null)} +
@@ -119,6 +204,33 @@ + {#if preRide} + +
+ {#if profile} + +
+ {profile.name} + {routeSummary} + +
+ {:else} + +
+ + Pick a bundled route or open your own GPX. Or start now and ride on manual gradient. + + +
+ {/if} +
+ {/if} +
+ +
@@ -226,11 +340,56 @@