Scaffold workspace, shared types and requirements spec
Cargo workspace with core/ble/fit/probe crates. crates/core/src/types.rs is the fixed contract between the BLE layer, ride engine and UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+675
@@ -0,0 +1,675 @@
|
||||
# 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.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
|
||||
|
||||
Established from three independent open-source implementations (§3.4–3.6).
|
||||
|
||||
| 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) |
|
||||
|
||||
**Encryption.** The handshake performs a key exchange and messages are then encrypted:
|
||||
|
||||
| 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 |
|
||||
|
||||
All of this maps onto pure-Rust RustCrypto crates (`p256`, `hkdf`, `sha2`, `aes`, `ccm`),
|
||||
so no C dependency is needed.
|
||||
|
||||
> **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.
|
||||
|
||||
### 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` via `tauri-plugin-blec` | blec supplies Android JNI/permission plumbing |
|
||||
| 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.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 |
|
||||
|
||||
### 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 (power, cadence, speed, gradient, gear, elapsed) readable at ~1 m | Must |
|
||||
| FR-9.6 | Live streaming charts of power and gradient/target | Must |
|
||||
| FR-9.7 | Route elevation profile or waveform preview with current position marked | 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 |
|
||||
|
||||
**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 |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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: minimal Tauri v2 Android build scanning via `blec` (RISK-1) |
|
||||
|
||||
> If TASK-0(d) fails on the left pod, resolve OQ-10 before building the button mapping.
|
||||
|
||||
### 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 | TASK-4 spikes it in Phase 0 |
|
||||
| **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** | **The left pod is where third-party support is weakest.** QZ has an open `wontfix` issue where the left Click's `−` never registers, and BikeControl's keep-alive covers only the right pod. All gradient and route control is mapped to the left pod | Gradient and route control lost; shifting-up survives | **TASK-0 proves both pods before the mapping is committed.** If the left pod is unreliable, remap onto the right pod and move gradient to a modifier gesture |
|
||||
|
||||
### 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 |
|
||||
Reference in New Issue
Block a user