From 57eb5e809b78e832aa063952166406acff13050e Mon Sep 17 00:00:00 2001 From: dtourolle Date: Wed, 5 Aug 2026 15:33:28 +0200 Subject: [PATCH] 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 --- .gitignore | 7 + Cargo.lock | 2 + README.md | 76 +- REQUIREMENTS.md | 149 +- crates/ble/src/click.rs | 499 + crates/ble/src/client.rs | 252 +- crates/ble/src/lib.rs | 8 + crates/ble/src/scan.rs | 26 +- crates/ble/src/zwift.rs | 1076 + crates/core/src/energy.rs | 93 + crates/core/src/gearing.rs | 234 + crates/core/src/gpx.rs | 220 + crates/core/src/lib.rs | 3 + crates/core/src/physics.rs | 126 +- crates/core/src/session.rs | 81 +- crates/core/src/types.rs | 32 + crates/fit/src/builder.rs | 127 +- crates/fit/src/rawlog.rs | 6 + crates/fit/src/recorder.rs | 5 + crates/probe/src/cli.rs | 33 +- crates/probe/src/commands.rs | 390 +- crates/probe/src/main.rs | 8 + packaging/arch/PKGBUILD | 75 + packaging/arch/bikecontrol.desktop | 10 + run.sh | 22 + scripts/build-arch.sh | 160 + src-tauri/Cargo.toml | 15 +- src-tauri/src/backend.rs | 13 +- src-tauri/src/commands.rs | 37 + src-tauri/src/controller.rs | 397 + src-tauri/src/derive.rs | 115 +- src-tauri/src/devices.rs | 666 +- src-tauri/src/events.rs | 12 +- src-tauri/src/lib.rs | 26 +- src-tauri/src/mock.rs | 12 +- src-tauri/src/session_backend.rs | 249 +- src-tauri/src/state.rs | 239 +- src-tauri/src/trainer.rs | 864 + traces/activity_1860230831.gpx | 51049 ++++++++++++++++++++ ui/src/App.svelte | 63 +- ui/src/components/ConnectionScreen.svelte | 30 + ui/src/components/ControlBar.svelte | 55 +- ui/src/components/HelpOverlay.svelte | 44 +- ui/src/components/RideScreen.svelte | 263 +- ui/src/lib/app.svelte.ts | 15 +- ui/src/lib/bridge.ts | 39 + ui/src/lib/types.ts | 16 + zwift-capture.txt | 229 + 48 files changed, 57737 insertions(+), 431 deletions(-) create mode 100644 crates/ble/src/click.rs create mode 100644 crates/ble/src/zwift.rs create mode 100644 crates/core/src/energy.rs create mode 100644 crates/core/src/gearing.rs create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/bikecontrol.desktop create mode 100755 run.sh create mode 100755 scripts/build-arch.sh create mode 100644 src-tauri/src/controller.rs create mode 100644 src-tauri/src/trainer.rs create mode 100644 traces/activity_1860230831.gpx create mode 100644 zwift-capture.txt 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 @@