Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+131
-18
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user