`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as long as the process. Every launch started from nothing: find the trainer, press Connect, find the strap, press Connect, and only then ride. FR-1.5 has been a Should since the beginning and was never actually true. It is a file now — devices.json in the app data directory, written when a link actually comes up rather than when Connect is pressed. A Connect the hardware then refuses is not a pairing, and writing one down would mean a trainer the rider gave up on getting chased on every launch afterwards. Forgetting is recorded too, in its own list: absent means never seen, forgotten means the rider looked at this device and said no, and auto-connect has to keep honouring that on the next launch as well. The file is advisory — a corrupt one costs auto-connect, never a ride. Auto-connect is driven by the scan rather than fired once at startup. The hardware is asleep at startup — a trainer wakes when the cranks turn, a strap when it is put on (A-4) — so a remembered device is reconnected the moment it advertises, through the same path the rider's own click takes, scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY cooldown and cleared when the link comes up or the rider connects by hand: an app that never stops trying can never honestly say it has stopped (FR-1.11). A device disconnected by hand is left alone for the rest of the session, since a disconnect that undoes itself two ticks later is not a disconnect. Pods now prefer the pod we know. Every Click advertises the same name and the same type byte, so before this a rider whose partner was warming up in the next room got whichever pod woke first. With nothing of that kind remembered anything still goes, or there could never be a first pairing. And the pair is one pod, not two. Confirmed on this hardware 2026-08-21: pairing the − pod alone delivers all ten buttons, its twin's included — which §2.3.1 had established for the frames but not for the pairing. So take_plus_pod holds the + pod back while a known − pod may merely be asleep, and connect_controller with no pod named means the − pod rather than both. The wait is bounded by PLUS_GRACE, because a flat − pod should cost the rider a D-pad and not a controller, and Buttons is untouched: it is what makes the handover between the two configurations invisible. Not yet tested against real hardware — nothing was advertising here. The store, the retry budget and the pod-preference rules have unit tests, and a seeded devices.json was confirmed to load and seed the − pod at launch, but the connect path itself waits for a ride. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
865 lines
55 KiB
Markdown
865 lines
55 KiB
Markdown
# BikeControl — Requirements Specification
|
||
|
||
**Status:** Draft v0.4
|
||
**Date:** 2026-08-05
|
||
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
||
|
||
> **v0.4 changes — the input decision is reversed.** Further research found that the Click
|
||
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
|
||
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
|
||
> with three open-source implementations of the Zwift protocol — one **GPL-3.0**, one
|
||
> **MIT** (§3.4–3.6) — **the app talks to the Click v2 directly over BLE.** v0.3's
|
||
> OpenBikeControl bridge is dropped: **there is no fallback input path.** New risk: the
|
||
> **left pod**, where third-party support is weakest, now carries all gradient and route
|
||
> control with nothing behind it (RISK-9).
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
BikeControl is a desktop-first (Android-later) application that connects to a Van Rysel
|
||
D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and provides:
|
||
|
||
- **Virtual shifting** on the single-cog drivetrain, via the Click's shift paddles.
|
||
- **Handlebar gradient control**, via the Click's D-pad.
|
||
- **Gradient-adaptive routes** driven from GPX or hand-authored profiles.
|
||
- **Synthetic waveform profiles** — sine, square, ramp — applied to resistance, gradient
|
||
or target power.
|
||
- **Ride recording** exported as a FIT file.
|
||
- **A GUI** for connection management and live telemetry visualisation.
|
||
|
||
### 1.1 Goals
|
||
|
||
| ID | Goal |
|
||
|----|------|
|
||
| G-1 | Ride a gradient profile end-to-end without touching the computer once started |
|
||
| G-2 | Control gearing and gradient entirely from the bars |
|
||
| G-3 | Produce a FIT file that imports cleanly into Strava / Garmin Connect |
|
||
| G-4 | Run on Linux desktop today; port to Android without rewriting the core |
|
||
| G-5 | Fail safe — never leave the trainer at high resistance after a fault |
|
||
| G-6 | Prefer documented, openly-licensed protocols; keep any proprietary protocol handling isolated and replaceable |
|
||
|
||
### 1.2 Non-goals
|
||
|
||
- Multiplayer, social features, or any network service.
|
||
- 3D world rendering or avatars.
|
||
- ANT+ support (§10.1).
|
||
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
||
app (§2.3); we only need to speak the protocol afterwards.
|
||
- Acting as a bridge for *other* training apps — that is what the existing BikeControl
|
||
app does, and this project does not duplicate it.
|
||
- Any secondary input protocol. The Click v2 client is the only input path.
|
||
|
||
---
|
||
|
||
## 2. Hardware & Operating Assumptions
|
||
|
||
### 2.1 Van Rysel D100 ✅
|
||
|
||
**FTMS support confirmed** by a working MIT-licensed implementation built specifically for
|
||
this trainer (§3.2).
|
||
|
||
| Property | Value | Confidence |
|
||
|----------|-------|------------|
|
||
| Bluetooth profile | FTMS (`0x1826`) | **Confirmed** |
|
||
| Target power (`0x05`) | Supported; reference clamps 100–600 W | **Confirmed** |
|
||
| Target resistance (`0x04`) | Supported, sint16; reference caps at 100 | **Confirmed** |
|
||
| Target inclination (`0x03`) | Implemented in the reference as sint16 | **Confirmed** |
|
||
| Sim mode (`0x11`) | Not used by the reference | **Unconfirmed — TASK-1** |
|
||
| Concurrent BLE hosts | Assume **one** | Assumed |
|
||
|
||
> **A-1:** The reference drives grade via `SetTargetInclination` (`0x03`), not
|
||
> `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
|
||
requirement, not a nicety: without it the rider has exactly one gear.
|
||
|
||
### 2.3 Zwift Click v2 ⚠
|
||
|
||
The unit is a **v2** (two pods: navigation D-pad on the left, lettered face buttons on the
|
||
right, a shift paddle under each). This is the harder variant, and the difference is not
|
||
merely one of degree.
|
||
|
||
**The unlock is performed by the rider, not by the app.** This is the key finding of v0.4
|
||
and it changes everything. The Click v2's encryption context times out roughly a minute
|
||
after it leaves a Zwift session; refreshing it requires the official Zwift app. But once
|
||
refreshed, **the device works with *any* third-party app for ~24 hours**.
|
||
|
||
The rider's procedure — **free, no paid subscription required**:
|
||
|
||
1. Open the Zwift app (desktop or mobile) and log in.
|
||
2. Go to the device pairing screen; pair the Click.
|
||
3. Keep it connected 10–30 seconds while pressing a button.
|
||
4. Close Zwift completely.
|
||
|
||
> **A-2:** BikeControl 6.0.0 (June 2026) added a keep-alive that removes the daily unlock
|
||
> entirely — but only for the **right** controller, only for paid Pro users, and the
|
||
> 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 ✅ **TASK-0 answered**
|
||
|
||
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 | 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) | **TI OAD firmware update — do not write.** Their `0x2901` descriptors name them: `Img Identify`, `Img Block`, `OAD Extended Control` | **Confirmed, read 2026-08-20** |
|
||
| `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 |
|
||
|
||
> **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.
|
||
|
||
> **The pods are not disjoint — answered 2026-08-05.** All ten buttons arrived over the
|
||
> **single** pod at `f4:c4:59:03:a1:8e` (type byte `0x0B`), and with both pods connected the
|
||
> `+` paddle arrives **twice**: once from the pod it is printed on and once relayed by its
|
||
> twin. `−` arrives once. Measured from a ride log rather than a capture — one press of `+`
|
||
> moved the gear by two (7 → 9 → 11), one press of `−` by one.
|
||
>
|
||
> Two consequences, both handled in `controller::Buttons`. The app must **merge the pair into
|
||
> one controller**: a button is held when either pod says so, and only that aggregate's edges
|
||
> may act, or every `+` press shifts twice. And **a pod sending its twin's paddle is normal**,
|
||
> so it is evidence of a wrong-way-round pair only while that pod has never sent its own —
|
||
> otherwise the connection screen asks the rider to swap a pair that is filed correctly.
|
||
|
||
> **One link is the whole controller — confirmed 2026-08-21.** Pairing the `−` pod *alone*
|
||
> delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and
|
||
> face buttons relayed from its twin. There is nothing a second link adds, and there is one
|
||
> thing it takes away — connected as a pair, the `−` pod stops reporting its own paddle.
|
||
>
|
||
> So the app pairs the `−` pod and stops there (`controller::take_plus_pod`). The `+` pod is a
|
||
> **substitute, not a second half**: it is connected on sight only when no `−` pod is known, or
|
||
> when a known one has been unreachable for `PLUS_GRACE`, so a flat `−` pod costs the rider a
|
||
> D-pad rather than a controller. `Buttons` stays exactly as it is — it is what makes the
|
||
> handover between the two configurations invisible.
|
||
|
||
### 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 in cleartext, and the app has never sent a key. The
|
||
ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no
|
||
crypto crate has been added.
|
||
|
||
> **A pod offering a public key does not mean it wants encryption.** Captured on
|
||
> 2026-08-20: the `−` pod sent `ff 03 00 0a 21 02 …` — protobuf field 1, 33 bytes, a
|
||
> compressed P-256 point — **in the same session as 304 cleartext `0x23` button frames**.
|
||
> The key offer and the cleartext stream coexist. This cost an evening: a pod that had gone
|
||
> quiet for an unrelated reason was read as "it has switched to the encrypted path", twice,
|
||
> and neither reading survived contact with the capture. If a pod goes silent, the key
|
||
> frames are not the evidence — see the teardown leak in §7.
|
||
|
||
| Stage | Mechanism |
|
||
|-------|-----------|
|
||
| Key agreement | **ECDH on NIST P-256** (`prime256v1`); each side sends a public key |
|
||
| Key derivation | **HKDF** → 36 bytes: bytes 0–31 = AES key, bytes 32–35 = IV |
|
||
| Cipher | **AES-256-CCM**, 4-byte MAC |
|
||
| Framing | 4-byte big-endian counter prepended per message; nonce = IV ‖ counter |
|
||
|
||
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.
|
||
|
||
> **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
|
||
|
||
- **A-3:** BLE peripherals accept a single central connection. Only one host may hold the
|
||
trainer, and only one may hold the Click.
|
||
- **A-4:** The trainer wakes on pedalling; the Click wakes on button press. Either may be
|
||
absent from a scan until woken — the UI must say so rather than report "not found".
|
||
|
||
---
|
||
|
||
## 3. Prior Art, Reuse and the Input Decision
|
||
|
||
### 3.1 `OpenBikeControl/bikecontrol` — Zwift device reference ⚠
|
||
|
||
Flutter/Dart, 589★, actively maintained. A *bridge* app that translates controller input
|
||
into actions for other training apps. Not used as a component here, but its public source
|
||
documents Zwift device discrimination (manufacturer-data type bytes, v1 vs v2 response
|
||
codes) and the shape of the v2 unlock.
|
||
|
||
> **⚠ Licensing.** Current versions are a custom **Non-Commercial** licence (©
|
||
> OpenBikeControl UG): personal/educational use only, source-only redistribution under the
|
||
> same terms, no commercial use, no marketplace distribution, and the grant is
|
||
> **revocable**. Versions before the `gpl3` tag were **GPL-3.0** (copyleft). **No code is
|
||
> copied from it** — treat it as a map, and confirm bytes against the device.
|
||
|
||
### 3.2 `obostjancic/smart-trainer-control` — D100 FTMS reference ✅
|
||
|
||
React/TypeScript, Web Bluetooth, **MIT**. Literally *"Control a Van Rysel D100 smart
|
||
trainer from your browser"*. Working FTMS client, control-point encoders, and FIT and TCX
|
||
writers with tests.
|
||
|
||
**MIT means this logic can be ported freely, with attribution.** It pre-solves most of
|
||
TASK-1 and de-risks the FIT writer.
|
||
|
||
### 3.3 `OpenBikeControl/openbikecontrol-protocol` — considered, not used
|
||
|
||
An MIT open standard (service `d273f680-…`) for controllers to drive trainer apps, with
|
||
Python references for both sides. v0.3 proposed consuming it via a bridge process. **That
|
||
approach is dropped** — it required a second app running during every ride. Recorded here
|
||
only so the decision is not silently revisited.
|
||
|
||
### 3.4 `cagnulein/qdomyos-zwift` (QZ) — GPL-3.0 Zwift protocol implementation ⭐
|
||
|
||
C++/Qt, 826★, **GPL-3.0**. `src/zwift_play/` contains a complete working implementation:
|
||
`zapCrypto.h` (ECDH/HKDF/AES-CCM), `localKeyProvider.h` (P-256 keygen), `zapBleUuids.h`,
|
||
`zapConstants.h`, `abstractZapDevice.h` (handshake), and `zwiftclickremote.cpp`.
|
||
|
||
**This is the reference that makes direct Click v2 support tractable.** It is the source of
|
||
the crypto details in §2.3.1.
|
||
|
||
> **Licensing:** GPL-3.0 is copyleft. **Porting this code makes BikeControl GPL-3.0.** The
|
||
> underlying algorithm — ECDH P-256 + HKDF + AES-256-CCM — is standard cryptography and not
|
||
> itself copyrightable, so a clean implementation from the *documented* protocol (§2.3.1) is
|
||
> unencumbered. See OQ-9.
|
||
|
||
### 3.5 `ajchellew/zwiftplay` — the original reverse-engineering ⚠
|
||
|
||
Kotlin/Android + a C# Windows console app. The origin of most public knowledge here,
|
||
including the packet captures, the `RideOn` handshake, and the fourth (`…0006-…`)
|
||
characteristic.
|
||
|
||
> **⚠ No LICENSE file — all rights reserved.** Excellent *documentation*, but **no code may
|
||
> be copied from it.** Its README is the citable protocol description.
|
||
|
||
### 3.6 `jat255/zwift_click_handling` — MIT, Python, Linux/BlueZ ✅
|
||
|
||
A small **MIT** script using `bleak` on Linux that connects to a Click and logs button
|
||
press/release and battery level. Notably it connects **without encryption** and still
|
||
receives events (A-3), and it carries the protocol constants in reusable form.
|
||
|
||
**MIT and already proven on Linux/BlueZ** — the natural basis for the `probe` tool.
|
||
|
||
### 3.7 Decision: direct Click v2, no fallback
|
||
|
||
v0.3 routed all input through a bridge process because the unlock looked insurmountable.
|
||
The research above shows it is not: **the rider performs the unlock in the free Zwift app,
|
||
and the protocol afterwards is fully documented across three open implementations.**
|
||
|
||
```
|
||
Zwift Click v2 ──BLE, ECDH P-256 + HKDF + AES-256-CCM──► This app
|
||
(rider unlocks daily in the free Zwift app)
|
||
```
|
||
|
||
| | |
|
||
|---|---|
|
||
| **No second app during rides** | The bridge's real cost was a process running every session. A once-daily 30-second unlock is a far smaller tax. |
|
||
| **The protocol is documented** | §2.3.1 is a complete spec, corroborated by three independent implementations. |
|
||
| **Standard crypto, pure Rust** | `p256`, `hkdf`, `sha2`, `aes`, `ccm` — no C dependency, no bespoke cryptography. |
|
||
| **Lower latency** | No intermediate hop in the NFR-1 budget. |
|
||
| **One code path** | No input abstraction to maintain, no divergence between paths. |
|
||
|
||
**Accepted consequence.** There is no second way in. If a Zwift firmware update breaks the
|
||
client, or the left pod proves unreliable (RISK-9), controller input is lost until the
|
||
client is fixed — the keyboard and on-screen controls (FR-3.19) are the only stopgap. This
|
||
is a deliberate trade of resilience for simplicity.
|
||
|
||
> **OQ-9 — licensing.** Porting QZ's crypto directly makes this project **GPL-3.0**. Writing
|
||
> it from the documented algorithm keeps the licence open. Which do you want? For a personal
|
||
> project GPL-3.0 costs nothing.
|
||
|
||
---
|
||
|
||
## 4. Technology Stack
|
||
|
||
**Tauri v2**, Rust core + web frontend. Confirmed; we port rather than import.
|
||
|
||
| Layer | Choice | Notes |
|
||
|-------|--------|-------|
|
||
| Shell | Tauri v2 | Desktop + Android from one codebase |
|
||
| Core | Rust | BLE, physics, profiles, gearing, FIT |
|
||
| BLE | `btleplug` directly | One `platform::Manager` on every target. On Android its "droidplug" backend is half Java: the classes are taken from the crate source at the `Cargo.lock` version (NFR-14), `platform::init` is called from `MainActivity.onCreate`, and the runtime permissions are requested there. `tauri-plugin-blec` was the v0.4 plan and would have supplied that plumbing, but it wraps btleplug in its own device model, which `crates/ble` would then have to be written against twice |
|
||
| Crypto | `p256`, `hkdf`, `sha2`, `aes`, `ccm` | Click v2 session (§2.3.1); pure Rust, no OpenSSL |
|
||
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
||
| Charts | `uPlot` | Canvas, built for streaming series |
|
||
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
|
||
|
||
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
|
||
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
|
||
keep all device I/O, physics and state in the core. The frontend renders telemetry pushed
|
||
over Tauri events and issues intents as commands. This keeps the safety-critical path (§7)
|
||
independent of the webview and the core testable without a UI.
|
||
|
||
```
|
||
bikecontrol/
|
||
├── crates/
|
||
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
||
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
||
│ ├── fit/ # FIT encoder (ported from §3.2)
|
||
│ └── probe/ # CLI for protocol discovery (Phase 0)
|
||
├── src-tauri/ # Tauri shell, commands, event bridge
|
||
└── ui/ # web frontend
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Functional Requirements
|
||
|
||
### 5.1 Device discovery and connection (FR-1)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-1.1 | Scan for BLE peripherals; list name, address, RSSI, advertised services | Must |
|
||
| FR-1.2 | Identify trainers by FTMS service UUID and Click pods by the Zwift custom service UUID plus manufacturer-data type byte | Must |
|
||
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
|
||
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
|
||
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
|
||
| FR-1.5a | Remembering survives the process: trainer, `−` pod and heart rate monitor are written to `devices.json` in the app data directory when a link actually comes up, and a device the rider forgets is recorded as refused rather than merely dropped | Should |
|
||
| FR-1.5b | Auto-connect is driven by the scan, not by startup — the hardware is asleep at launch (A-4), so a remembered device is reconnected the moment it advertises. Bounded by `AUTO_ATTEMPTS`, so "gave up" (FR-1.11) is not contradicted two ticks later, and suspended for any device the rider disconnected by hand this session | Should |
|
||
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
|
||
| 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, for the controller as well as the trainer. Giving up is announced as a terminal state carrying the reason — never a silent return to `Idle`, and never an actor that stops without saying so | 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)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-2.1 | Acquire FTMS control (`0x00`) before issuing commands | Must |
|
||
| FR-2.2 | Subscribe to Indoor Bike Data (`0x2AD2`); decode all present fields per the flags bitfield | Must |
|
||
| FR-2.3 | Set gradient via `SetTargetInclination` (`0x03`), or `0x11` if TASK-1 confirms support | Must |
|
||
| FR-2.4 | Set resistance via `SetTargetResistanceLevel` (`0x04`) | Must |
|
||
| FR-2.5 | Set target power via `SetTargetPower` (`0x05`) — needed for ERG waveforms | Must |
|
||
| FR-2.6 | Read and respect `Fitness Machine Feature` (`0x2ACC`) and `Supported Resistance Level Range` (`0x2AD6`); never send out-of-range values | Must |
|
||
| FR-2.7 | Handle control-point indications including error responses, not fire-and-forget | Must |
|
||
| FR-2.8 | Rate-limit control writes (≤4 Hz target) | Must |
|
||
|
||
**Decoding note (FR-2.2):** Indoor Bike Data is variable-length, determined by a leading
|
||
16-bit flags field. **Bit 0 is inverted** — instantaneous speed is present when the bit is
|
||
*clear*. Fields must be consumed strictly in specification order.
|
||
|
||
### 5.3 Controller input (FR-3)
|
||
|
||
Input comes from the **Zwift Click v2 over BLE, directly** (§2.3.1). There is no bridge and
|
||
no secondary protocol path.
|
||
|
||
**Direct Click v2 client**
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-3.1 | Discover Click pods by Zwift custom service UUID plus manufacturer data; identify left vs right pod | Must |
|
||
| FR-3.2 | Perform the `RideOn` handshake and establish the encrypted session — ECDH P-256 → HKDF → AES-256-CCM (§2.3.1) | Must |
|
||
| FR-3.3 | Attempt the **unencrypted** path first (A-3) and fall back to encrypted — it is far simpler if the v2 permits it | Should |
|
||
| FR-3.4 | Connect **both pods concurrently**; each is an independent BLE peripheral | Must |
|
||
| FR-3.5 | Decode controller notifications into press/release events for every button on both pods | Must |
|
||
| FR-3.6 | Handle keepalive/empty messages (`0x15`) and maintain the session | Must |
|
||
| FR-3.7 | Decode battery level (`0x19`) per pod and warn when low | Should |
|
||
|
||
**Unlock handling — the app guides, the rider performs**
|
||
|
||
The unlock happens in the Zwift app (§2.3). This application never performs it, never asks
|
||
for Zwift credentials, and never talks to Zwift's servers. It detects the state and walks
|
||
the rider through the steps.
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-3.8 | Detect the locked failure mode — notifications stop roughly a minute after connecting — and distinguish it from an ordinary dropout | Must |
|
||
| FR-3.9 | Track the last known-good unlock per pod and show remaining validity against the ~24 h window | Must |
|
||
| FR-3.10 | **Guide** the rider through the unlock with explicit ordered steps (open Zwift → log in → pairing screen → pair the Click → hold a button 10–30 s → close Zwift → return here) | Must |
|
||
| FR-3.11 | State plainly that no paid Zwift subscription is required | Should |
|
||
| FR-3.12 | Warn before a ride starts when the unlock is stale, expiring soon, or unknown | Must |
|
||
| FR-3.13 | Detect and confirm success automatically once events resume, closing the guidance without the rider having to declare it worked | Should |
|
||
| FR-3.14 | Offer "mark as unlocked" for riders who unlocked outside the app | Could |
|
||
| FR-3.15 | Never store Zwift credentials or contact Zwift services | Must |
|
||
|
||
**Input handling**
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-3.16 | Debounce input; support press, release and hold-to-repeat | Must |
|
||
| FR-3.17 | All bindings user-configurable; the mapping below is the default | Should |
|
||
| FR-3.18 | Hold on D-pad up/down repeats the gradient step at a fixed rate | Should |
|
||
| FR-3.19 | Keyboard and on-screen controls mirror every action — needed to develop and test the app before the Click client works, and to keep a ride going if a pod dies mid-session | Must |
|
||
|
||
**Button mapping.** Per OQ-1: shift paddles shift, D-pad handles gradient.
|
||
|
||
| Click v2 control | Pod | Action |
|
||
|------------------|-----|--------|
|
||
| Shift paddle `+` | Right | Virtual gear **up** |
|
||
| Shift paddle `−` | Left | Virtual gear **down** |
|
||
| D-pad up | Left | Gradient **+0.5%** |
|
||
| D-pad down | Left | Gradient **−0.5%** |
|
||
| D-pad left | Left | **Previous** route / profile |
|
||
| D-pad right | Left | **Next** route / profile |
|
||
| Face button A | Right | Cycle control mode (§5.4) |
|
||
| Face button Z | Right | Reset gradient offset to zero |
|
||
| Face button B | Right | Pause / resume ride |
|
||
| Face button Y | Right | Insert lap marker |
|
||
|
||
> **RISK-9 applies here.** Every gradient and route control sits on the **left** pod, which
|
||
> is where third-party support is weakest (§10). With no bridge fallback, the left pod is a
|
||
> single point of failure for gradient control — TASK-0 must prove it works before this
|
||
> mapping is committed to.
|
||
|
||
### 5.4 Control modes (FR-4)
|
||
|
||
| ID | Mode | Behaviour | Priority |
|
||
|----|------|-----------|----------|
|
||
| FR-4.1 | **Virtual gearing** | Paddles shift a configurable virtual cassette; resistance follows gear × terrain | **Must** |
|
||
| FR-4.2 | **Manual grade** | D-pad adjusts simulated gradient in ±0.5% steps | Must |
|
||
| FR-4.3 | **Resistance** | Buttons step trainer resistance directly, ignoring physics | Must |
|
||
| FR-4.4 | **Route** | Gradient driven by route profile at current distance; paddles shift | Must |
|
||
| FR-4.5 | **Waveform** | Gradient/resistance/power driven by a synthetic profile (§5.6) | Must |
|
||
| FR-4.6 | **ERG** | Trainer holds a fixed target power | Should |
|
||
|
||
Gearing and gradient are **simultaneously active**, not alternatives — the paddles always
|
||
shift while the D-pad always trims gradient. "Mode" selects where the *base* gradient comes
|
||
from (manual, route, or waveform).
|
||
|
||
**Virtual shifting design (FR-4.1).** No mechanical shifting exists, and FTMS has no
|
||
virtual-shifting opcode. Approach:
|
||
|
||
1. Define a virtual cassette: N gears (default ~24), ratios configurable.
|
||
2. The app owns the physics (FR-7.1), so it knows the wheel force required for the current
|
||
virtual speed and gradient.
|
||
3. The selected gear sets the cadence needed to hold that speed:
|
||
`cadence = v / (gear_ratio × wheel_circumference)`.
|
||
4. The app sets trainer resistance so effort at that cadence matches the physics —
|
||
effectively `resistance = f(gradient, speed, gear_ratio)`.
|
||
5. Mapping that target onto a D100 resistance level needs **empirical calibration** of the
|
||
trainer's resistance curve (TASK-3).
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-4.1.1 | Configurable gear count and ratios, with sensible defaults | Must |
|
||
| FR-4.1.2 | Display current gear prominently, with clear feedback on each shift | Must |
|
||
| FR-4.1.3 | Clamp at top and bottom gear; never wrap around | Must |
|
||
| FR-4.1.4 | Shift response ≤250 ms end-to-end (NFR-1) | Must |
|
||
| FR-4.1.5 | Persist gear selection across a reconnect | Should |
|
||
|
||
### 5.5 Routes (FR-5)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-5.1 | Import GPX; derive a distance/elevation profile | Must |
|
||
| FR-5.2 | Smooth GPX elevation before differentiating into gradients — raw GPS elevation is far too noisy to send to a trainer | Must |
|
||
| FR-5.3 | Clamp derived gradients to a configurable range (default −10%…+15%) | Must |
|
||
| FR-5.4 | Hand-authored routes as an ordered segment list in a human-editable file | Must |
|
||
| FR-5.5 | Interpolate between profile points so grade changes are continuous, not stepped | Must |
|
||
| FR-5.6 | Support looping, and support finishing at the end | Should |
|
||
| FR-5.7 | Show distance covered, remaining, and elevation profile with current position | Must |
|
||
| FR-5.8 | Cycle routes from the D-pad mid-ride (`0x12`/`0x13`) | Must |
|
||
|
||
### 5.6 Synthetic waveform profiles (FR-6)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-6.1 | Waveform types: **sine, square, triangle, sawtooth, ramp, constant** | Must |
|
||
| FR-6.2 | Apply to any of three channels: **gradient (%)**, **resistance level**, **target power (W)** | Must |
|
||
| FR-6.3 | Parameterise by amplitude, midpoint, period, phase, and duration or repeat count | Must |
|
||
| FR-6.4 | Support **time-based** and **distance-based** periods | Should |
|
||
| FR-6.5 | Compose into a sequence of blocks — warm-up ramp, sine intervals, cool-down | Must |
|
||
| FR-6.6 | Share a file format with hand-authored routes, so a profile may mix terrain and waveform blocks | Should |
|
||
| FR-6.7 | Render a preview chart before the ride; show position within it during | Must |
|
||
| FR-6.8 | Clamp generated values to the safe range at transmission (SAF-3) | Must |
|
||
| FR-6.9 | Smooth transitions between blocks so the trainer does not step discontinuously | Should |
|
||
|
||
**Illustrative format** (TBD):
|
||
|
||
```yaml
|
||
name: "Over-unders + hill repeats"
|
||
blocks:
|
||
- { type: ramp, channel: power, from_w: 100, to_w: 200, duration_s: 600 }
|
||
- { type: sine, channel: power, midpoint_w: 240, amplitude_w: 40,
|
||
period_s: 120, repeats: 8 }
|
||
- { type: segments, channel: gradient, loop: true, segments: [
|
||
{ distance_m: 800, gradient_pct: 6.5 },
|
||
{ distance_m: 400, gradient_pct: -3.0 } ] }
|
||
- { type: constant, channel: power, watts: 120, duration_s: 300 }
|
||
```
|
||
|
||
### 5.7 Ride engine (FR-7)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-7.1 | Compute virtual speed from measured power, gradient and mass — the app owns the physics rather than trusting the trainer's reported speed | Must |
|
||
| FR-7.2 | Integrate speed into distance, which drives route position | Must |
|
||
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
|
||
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
|
||
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
|
||
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
|
||
|
||
```
|
||
F_propulsive = (P_measured × drivetrain_efficiency) / max(v, v_min)
|
||
F_gravity = m × g × sin(atan(gradient))
|
||
F_rolling = m × g × Crr × cos(atan(gradient))
|
||
F_aero = ½ × ρ × CdA × v²
|
||
a = (F_propulsive − F_gravity − F_rolling − F_aero) / m
|
||
v += a × Δt (clamped at ≥ 0)
|
||
```
|
||
|
||
Owning the physics is a prerequisite for virtual shifting, makes behaviour reproducible in
|
||
tests, and removes dependence on the trainer's internal mass assumptions.
|
||
|
||
### 5.8 Recording and export (FR-8)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-8.1 | Record a 1 Hz series: timestamp, power, cadence, speed, distance, gradient, gear, mode | Must |
|
||
| FR-8.2 | Export a valid FIT activity (file_id, session, lap, record, activity; correct CRC) | Must |
|
||
| FR-8.3 | Verify exported FIT files import into Strava and Garmin Connect | Must |
|
||
| FR-8.4 | Persist the raw series so a FIT can be regenerated after a crash | Must |
|
||
| FR-8.5 | Continue recording across a BLE dropout, marking the gap rather than aborting | Must |
|
||
| FR-8.6 | Write incrementally — a crash must not lose the session | Must |
|
||
| FR-8.7 | Record lap markers triggered from the controller (`0x35`) | Should |
|
||
|
||
> Only FIT export was requested. In-app ride *history* is not in scope for v1 — the FIT
|
||
> file is the ride artifact. FR-8.4's raw log is crash safety, not a history feature.
|
||
|
||
### 5.9 GUI (FR-9)
|
||
|
||
**Connection screen**
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-9.1 | Live device list with signal strength and identified type | Must |
|
||
| FR-9.2 | Per-device connect / disconnect / forget, with clear state and error text | Must |
|
||
| FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
|
||
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
|
||
|
||
**Ride screen**
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-9.5 | Large, legible readouts readable at ~1 m. **The screen is route-led, not power-led** — ETA, distance remaining, speed, gradient and elevation take visual priority; power, cadence and heart rate are present but subordinate | Must |
|
||
| FR-9.6 | Live streaming charts of power and gradient/target | Must |
|
||
| FR-9.7 | **The route elevation profile with current position marked is the primary visual element** of the ride screen, not a supporting chart. For waveform profiles, the profile preview serves the same role | Must |
|
||
| FR-9.15 | **Estimated time to finish (ETA)**, alongside distance covered and remaining | Must |
|
||
| FR-9.8 | Prominent display of active mode, current gear, and current target | Must |
|
||
| FR-9.9 | Visible feedback on every button press, so the rider knows input registered | Must |
|
||
| 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 |
|
||
| FR-9.17 | **Screen size is a measured input to the layout, not an assumption.** Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own | Must |
|
||
| FR-9.18 | **Legibility floors are absolute.** No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go | Must |
|
||
| FR-9.19 | **Touch is a first-class input on Android.** Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap | Must |
|
||
|
||
**Post-ride**
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-9.13 | Summary: duration, distance, elevation, average/max power, average cadence | Must |
|
||
| FR-9.14 | Save FIT to a chosen location, with the path confirmed | Must |
|
||
|
||
---
|
||
|
||
## 6. Non-Functional Requirements
|
||
|
||
| ID | Requirement |
|
||
|----|-------------|
|
||
| NFR-1 | **Input latency** — button press to trainer resistance change ≤ 250 ms |
|
||
| NFR-2 | **Telemetry** — process trainer notifications at native rate (1–4 Hz) without backlog |
|
||
| NFR-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth |
|
||
| NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
|
||
| NFR-5 | **Portability** — `core` and `fit` compile for Android with no platform-specific code |
|
||
| 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 |
|
||
| NFR-11 | **Screen stays lit** — a live ride (running or paused) holds a system idle inhibitor, so the display never blanks or locks under a rider whose hands are on the bars. Held for the ride, not for the app: it is taken when the ride starts and released when it ends, however it ends. Failing to take it costs a blanked screen, never a ride. On Android this is `FLAG_KEEP_SCREEN_ON`, set for as long as the app is in the foreground: it needs no permission and lapses on backgrounding, so there is nothing to leak — but it is coarser than the desktop inhibitor, which is scoped to the ride |
|
||
| NFR-12 | **Reproducible builds** — every artifact (Linux `.deb`/AppImage, Arch `.pkg.tar.zst`, Android APK) is built in CI from a pinned container image, not from a developer's machine. The images are built from `Dockerfile.builder` and `Dockerfile.arch` in this repo |
|
||
| NFR-13 | **No untracked source** — `src-tauri/gen/` is generated and untracked, so every hand-written Android source lives in `src-tauri/android/` and is copied in after each `tauri android init`. CI fails if a source exists only under `gen/`, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written |
|
||
| NFR-14 | **Version-locked BLE Java** — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a `NoSuchMethodError` at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in `Cargo.lock`, never from a separately published artifact |
|
||
|
||
---
|
||
|
||
## 7. Safety Requirements
|
||
|
||
| ID | Requirement |
|
||
|----|-------------|
|
||
| SAF-1 | On controller disconnect, hold the last target — never continue applying pending increments |
|
||
| SAF-2 | On app exit, crash, or ride end, reset the trainer to 0% grade / minimum resistance |
|
||
| SAF-3 | Clamp gradient, resistance and target power to configurable safe ranges **at the point of transmission**, regardless of source |
|
||
| SAF-4 | If the trainer stops acknowledging control-point writes, stop sending and alert the rider |
|
||
| 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) |
|
||
|
||
### 7.1 How SAF-9 was violated, and what it looked like *(fixed 2026-08-20)*
|
||
|
||
Worth recording, because the symptom pointed everywhere except the cause.
|
||
|
||
`Actor::teardown` unsubscribed every notifying characteristic before disconnecting, each
|
||
under its own `DISCONNECT_TIMEOUT` of 2 s. A Click carries **five** of them, so the exit
|
||
path could spend 10 s on optional work against the controller's 3 s shutdown budget. The
|
||
supervisor gave up first, the process exited, and `disconnect` was never reached — leaving
|
||
BlueZ holding the pod with **no application running**. Confirmed directly: both apps
|
||
stopped, `bluetoothctl devices Connected` still listing it.
|
||
|
||
The next connect then inherited a half-dead session, and that is the part worth
|
||
remembering: **it streamed battery every ~5 s and no button frames at all.** A pod that
|
||
answers the handshake, reports battery, and ignores every press is indistinguishable from
|
||
broken hardware, and it was read that way — as a dead left pod (RISK-9), as a wrong bit
|
||
map, as pods filed into the wrong slot, and twice as a lapsed Zwift unlock. None of those
|
||
survived a capture. It also produced the connect failures that came with it,
|
||
`le-connection-abort-by-local` and `service discovery timed out`, and it came and went
|
||
depending on whether the previous exit had happened to time out.
|
||
|
||
Three rules fall out of it:
|
||
|
||
- **Nothing optional runs before the disconnect on an exit path.** Dropping the link clears
|
||
the peripheral's CCCDs anyway, so the unsubscribes were politeness toward a connection
|
||
about to be destroyed. The trainer's SAF-2 writes stay, because those are not optional.
|
||
- **A timed-out disconnect is logged with its consequence, not just its name.** The cost
|
||
lands on the *next* run, which is exactly why this hid for so long.
|
||
- **Android is the worse case.** A leaked link there survives the app being swiped away.
|
||
|
||
---
|
||
|
||
## 8. Architecture
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ Frontend (webview) │
|
||
│ Connection manager · Gauges · uPlot · Profile editor│
|
||
└──────────────▲────────────────────────┬──────────────┘
|
||
events │ │ commands
|
||
┌──────────────┴────────────────────────▼──────────────┐
|
||
│ Rust core │
|
||
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │
|
||
│ │ Ride │ │ Profile │ │ Virtual │ │ Recorder │ │
|
||
│ │ engine │◄┤ engine │ │ gearing │ │ → FIT │ │
|
||
│ │(physics) │ │(GPX/wave)│ └────┬────┘ └──────────┘ │
|
||
│ └────▲─────┘ └──────────┘ │ target │
|
||
│ ┌────┴───────────┐ ┌──────────▼──────┐ │
|
||
│ │ FTMS client │ │ Click v2 client │ │
|
||
│ │ (trainer, BLE) │ │ (BLE + crypto) │ │
|
||
│ └────────▲───────┘ └────────▲────────┘ │
|
||
│ │ btleplug / blec │ │
|
||
└───────────┼───────────────────┼──────────────────────┘
|
||
│ │ ECDH P-256 → HKDF
|
||
│ │ → AES-256-CCM
|
||
┌──────┴──────┐ ┌──────┴──────────────┐
|
||
│ D100 │ │ Click v2 L + R pods │
|
||
└─────────────┘ └─────────────────────┘
|
||
▲
|
||
daily unlock performed
|
||
by the rider in Zwift
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Delivery Plan
|
||
|
||
### Phase 0 — D100 protocol discovery *(blocking for Phase 1)*
|
||
|
||
| Task | Description |
|
||
|------|-------------|
|
||
| **TASK-1** | `probe` against the D100: enumerate services, dump `Fitness Machine Feature` and `Supported Resistance Level Range`, log decoded Indoor Bike Data, resolve whether `0x11` works (A-1) |
|
||
| **TASK-2** | Write control commands to the D100; confirm physical resistance change |
|
||
|
||
**Exit criteria:** telemetry decodes correctly and a written command produces a felt
|
||
resistance change.
|
||
|
||
### Phase 0b — Deferred discovery *(blocking for Phase 3 only)*
|
||
|
||
| Task | Description |
|
||
|------|-------------|
|
||
| **TASK-0** | **Prove the Click v2 — the riskiest thing in the project.** Unlock both pods in the Zwift app, then with `probe`: (a) try the **unencrypted** path (A-3); (b) if that fails, do the full `RideOn` + ECDH/HKDF/AES-CCM handshake; (c) log raw and decrypted frames against a known button sequence; (d) **confirm every button on *both* pods registers** (RISK-9); (e) measure how long events survive without a fresh unlock. Prototyping in Python against §3.6's MIT code is legitimate — the goal is knowledge, not shipped code |
|
||
| **TASK-3** | **Characterise the D100 resistance curve** — required to map virtual gears onto it (FR-4.1) |
|
||
| **TASK-4** | Android BLE spike (RISK-1). **Built, not yet proven on hardware:** the APK builds via btleplug's droidplug backend, and the manifest carries the Android 12+ runtime pair `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` with `android:usesPermissionFlags="neverForLocation"` on the scan permission — which we can claim honestly only because every scan is filtered by service UUID — plus `ACCESS_FINE_LOCATION` capped at API 30 for older releases. `MainActivity` requests them at runtime before the first scan, not merely declares them. What remains is the actual spike: install on a phone and confirm a scan finds the D100 and both Click pods, and that a connect and a control-point write survive a screen-off |
|
||
|
||
> **TASK-0(d) satisfied — RISK-9 retired (2026-08-20).** Four connect/disconnect cycles
|
||
> against the left (`−`) pod with `probe zwift --buttons`: every button registers, on the
|
||
> bit §2.3.1 documents — D-pad `left`/`up`/`right`/`down` on bits 0–3, the `−` paddle on
|
||
> bit 8 (6, 30 and 14 presses across three rounds). The right pod was mapped the same way:
|
||
> `A`/`B`/`Y`/`Z` on bits 4–7 and the `+` paddle on bit 12, over 83 presses. Both halves of
|
||
> the bit table are now hardware-verified, and QS's `wontfix` for the left `−` does not
|
||
> reproduce here. **OQ-10 does not need answering** and the gradient mapping can stay on
|
||
> the left pod.
|
||
>
|
||
> The left pod did appear dead for most of an evening first. It was not: see the teardown
|
||
> leak in §7.
|
||
|
||
### Phase 1 — D100 + GUI + profiles ⭐ *(current focus)*
|
||
|
||
**The Click is deferred to Phase 3.** All control in this phase is on-screen; the app is
|
||
fully rideable from the keyboard and mouse before any Zwift protocol work begins. This
|
||
removes the only component with unknown protocol risk from the critical path.
|
||
|
||
FR-1 (trainer only), FR-2, FR-4.2/4.3/4.4/4.5, FR-5 (GPX), FR-6 (waveforms), FR-7
|
||
(physics), FR-3.19 (keyboard/on-screen), FR-9.1–9.3, 9.5–9.8, 9.10–9.12, §7 safety.
|
||
|
||
*Milestone: load a GPX or a sine-wave profile, ride it, and control gradient from the GUI.*
|
||
|
||
### Phase 2 — Recording
|
||
FR-8 (recording, FIT, crash safety), FR-9.13/9.14.
|
||
*Milestone: a ride lands in Strava.*
|
||
|
||
### Phase 3 — Zwift Click v2
|
||
TASK-0 (protocol discovery), then FR-3.1–3.18, FR-9.4, FR-4.1 (virtual gearing, which needs
|
||
TASK-3's resistance-curve calibration), RISK-9 resolution.
|
||
*Milestone: shift gears and trim gradient from the bars.*
|
||
|
||
### Phase 4 — Polish and stretch
|
||
FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding),
|
||
FR-4.6 (ERG), FR-9.11/9.12, Android.
|
||
|
||
---
|
||
|
||
## 10. Risks
|
||
|
||
| ID | Risk | Impact | Mitigation |
|
||
|----|------|--------|------------|
|
||
| **RISK-1** | `btleplug`'s Android backend is the least mature part of the stack | Android target lost | Partly retired: the target builds, the Java backend is version-locked to the crate (NFR-14) and the JNI init is wired and symbol-checked in CI. What is *not* yet proven is a scan and a connect on real hardware — TASK-4 |
|
||
| **RISK-2** | Virtual shifting feel may be poor if the D100's resistance curve is coarse or laggy | Core feature degraded | TASK-3 characterises it early; fall back to fewer, wider-spaced gears |
|
||
| **RISK-3** | Rust FIT *encoders* are thin — most crates read rather than write | FR-8.2 slips | Port the MIT writer from §3.2; TCX fallback |
|
||
| **RISK-4** | **No fallback input path (§3.7).** A firmware change or a protocol error leaves no second way in | Controller input lost entirely until the client is fixed | Accepted deliberately. Keep protocol handling isolated in `ble`; pin known-good behaviour in tests; keyboard/on-screen controls (FR-3.19) are the only stopgap |
|
||
| **RISK-5** | The Click v2 crypto is more involved than the D100 work — ECDH, HKDF, AES-CCM, session counters — and a subtle error yields silence rather than a clear failure | Phase 1 slips | Three reference implementations to check against (§3.4–3.6); `probe` logs raw and decrypted frames side by side (NFR-8) |
|
||
| **RISK-6** | The unencrypted path (A-3) may not work on a v2, forcing full crypto immediately | Less schedule slack | TASK-0 tests it first; the crypto path is specified either way |
|
||
| **RISK-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) |
|
||
| **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
|
||
| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `−` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod |
|
||
|
||
### 10.1 On ANT+
|
||
|
||
The D100 likely supports ANT+ FE-C, which permits multiple simultaneous connections and
|
||
would sidestep BLE contention. Excluded because it needs a USB stick on desktop and is
|
||
effectively dead on Android — the opposite of G-4. Revisit only if BLE contention proves
|
||
intolerable.
|
||
|
||
---
|
||
|
||
## 11. Open Questions
|
||
|
||
| ID | Question |
|
||
|----|----------|
|
||
| **OQ-2** | Gradient step size — is ±0.5% per D-pad press right? And what repeat rate on hold? |
|
||
| **OQ-3** | How many virtual gears, and what ratio spread? (Default: 24, roughly a 2×12 road setup.) |
|
||
| **OQ-4** | Should route mode auto-advance to the next route on completion, or stop and wait? |
|
||
| **OQ-5** | Is heart rate wanted? A BLE HRM strap is a small increment now and awkward to retrofit into the FIT writer later. |
|
||
| **OQ-6** | Which frontend framework? Svelte recommended. |
|
||
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
|
||
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
|
||
|
||
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
|
||
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
||
|
||
---
|
||
|
||
## 12. Glossary
|
||
|
||
| Term | Meaning |
|
||
|------|---------|
|
||
| **FTMS** | Fitness Machine Service — standard BLE profile for trainer telemetry and control |
|
||
| **Pod** | One half of a Click v2 — left (D-pad) or right (face buttons); each is an independent BLE peripheral |
|
||
| **Unlock** | Refreshing the Click v2's encryption context via the free Zwift app; lasts ~24 h |
|
||
| **ECDH / HKDF / AES-CCM** | The key agreement, key derivation and cipher used by the Zwift session (§2.3.1) |
|
||
| **ERG mode** | Trainer holds fixed target power regardless of cadence |
|
||
| **Sim mode** | Trainer applies resistance simulating a gradient; power varies with rider effort |
|
||
| **Virtual shifting** | Synthesising gear changes by varying trainer resistance, on a single-cog drivetrain |
|
||
| **Crr** | Coefficient of rolling resistance |
|
||
| **CdA** | Drag coefficient × frontal area |
|
||
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
|