Six rows, each of which the last three commits either implement or were written to stop being ambiguous about. FR-7.4a says what "configurable" has to mean: reachable from the app, validated, and durable. FR-7.4 has been a Must since the first draft and was read as satisfied by a command that existed — which is how every rider ended up on a 105 kg default. FR-7.5a puts units on the display side of the line and keeps recordings SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only against a reference the rider gave, and gradient as a shape with the next block announced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1160 lines
79 KiB
Markdown
1160 lines
79 KiB
Markdown
# BikeControl — Requirements Specification
|
||
|
||
**Status:** Draft v0.5
|
||
**Date:** 2026-08-21
|
||
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
||
|
||
> **v0.5 changes — the ride leaves the app.** §5.10 adds two ways for a finished activity
|
||
> to reach somewhere else: an Android **share sheet**, which needs no credential and is the
|
||
> only route into Garmin Connect; and an opt-in **upload to Runalyze**, chosen because its
|
||
> auth is a token the rider pastes in rather than an OAuth flow with a client secret to hide.
|
||
> Strava is deliberately not in scope (RISK-11). This is the first network traffic the app
|
||
> has ever originated, so §1.2's non-goal and NFR-6 are both amended to say precisely what is
|
||
> and is not permitted — the short version being that nothing on the network is ever between
|
||
> a rider and a ride.
|
||
|
||
> **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 acting as a network *service*. The app never listens on
|
||
a socket and never holds an account of its own. One-way export of a finished ride to a
|
||
destination the rider configured (§5.10) is not that, and is the only network traffic
|
||
the app ever originates.
|
||
- 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 |
|
||
| HTTP | `reqwest` + `rustls-tls` | Activity upload only (FR-10), and nothing else in the app opens a socket. **Not** `native-tls`: OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is already the choice for the Click session |
|
||
|
||
**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)
|
||
│ ├── sync/ # activity upload — one trait, one impl per service (§5.10)
|
||
│ └── 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.4a | **Configurable means reachable and durable.** A setup screen in the app edits them, Rust validates and refuses values that make the engine produce nonsense, and the result is written to `settings.json` in the app data directory so it survives the process. A command the UI never calls does not satisfy FR-7.4 — it left every rider on the 105 kg default | Must |
|
||
| FR-7.5a | Units are a **display preference** (metric or imperial), applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to | Should |
|
||
| 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 |
|
||
| FR-9.20 | **The setup is presented as roles, not radios.** Trainer, shifter and heart rate each get a slot that says what is filling it, what is missing and the one action that would fill it; the device list of FR-9.1 sits below them. A peripheral of no known role is listed but folded away — unless no trainer has been identified at all, since a trainer that does not advertise FTMS until it is connected classifies as unknown | Should |
|
||
| FR-9.21 | **No MAC addresses on screen.** An address is not something a rider reads, types or acts on, and on Android it is randomised. The device kind is a glyph and the identity is the name; where two rows would otherwise be indistinguishable, four characters of the address disambiguate them | Should |
|
||
|
||
**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.22 | **Effort is shown as a zone where the rider has given a reference.** Power against FTP (Coggan's seven) and heart rate against maximum (five), applied to the rolling average rather than the instantaneous figure so the colour does not strobe at 4 Hz. **No reference, no zone**: an unset FTP draws the plain number rather than a zone measured against a guess. Colour never carries the meaning alone — the zone's short name is rendered beside it | Should |
|
||
| FR-9.23 | **Gradient is a shape as well as a number**, and the profile's next block is announced with the distance or time to it — what is coming decides whether to shift now | Should |
|
||
| 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 |
|
||
|
||
---
|
||
|
||
### 5.10 Sharing and upload (FR-10)
|
||
|
||
Everything in this section operates on a file that already exists. `stop_ride` writes the
|
||
activity and *then* emits the summary (FR-8.2), so by the time a rider can ask for any of
|
||
this, the ride is on disk and safe. Nothing here touches the ride loop, the BLE stack or
|
||
the recorder, and nothing here may sit between a rider and a ride: the app stays fully
|
||
usable with the network down and no account anywhere (NFR-6).
|
||
|
||
Two destinations, deliberately different in kind.
|
||
|
||
**Share** (FR-10.1–10.8) hands the file to another app on the phone. No network code, no
|
||
credential, no service-specific knowledge and no terms of service on our side — the rider
|
||
picks Strava, Garmin Connect, Dropbox or email from the system chooser and that app does
|
||
the rest. It is the *only* route into Garmin Connect, whose Connect Developer Program is
|
||
partner-approval-only and effectively closed to new applicants, and it is the cheapest
|
||
thing in this document that a rider would use every day.
|
||
|
||
**Upload** (FR-10.9–10.24) sends the file to one named service over HTTPS. That service is
|
||
**Runalyze**, chosen for what it does *not* require: no OAuth, no registered application,
|
||
no client secret to hide inside an open-source binary, and no per-application rate ceiling
|
||
shared across every install. Strava has all four of those problems and is out of scope
|
||
here — RISK-11. Runalyze is the cheap case that proves the trait, the queue and the failure
|
||
classification; a second service should then cost one new impl and nothing else.
|
||
|
||
#### 5.10.1 Share the activity to another app (Android)
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-10.1 | Share the finished activity to any app on the device that accepts a file, from the post-ride summary (FR-9.13) and from the recovered-ride list (FR-8.4) | Must |
|
||
| FR-10.2 | The share hands out a `content://` URI from the app's own `FileProvider` with `FLAG_GRANT_READ_URI_PERMISSION`, scoped to the receiving app for the life of the intent. The FIT is never copied out of app-private storage to make this work | Must |
|
||
| FR-10.3 | The receiving app sees the ride's own filename — `2026-08-21T18-42-10.fit` — not a temporary name. A rider looking at Garmin Connect a week later must be able to tell which ride is which | Must |
|
||
| FR-10.4 | Presented with `Intent.createChooser`, so the rider always gets a picker and Android never silently commits them to a default | Must |
|
||
| FR-10.5 | The share *reads* the automatic copy in the rides directory. Nothing is moved and nothing is deleted, so a dismissed chooser costs nothing — the same contract `save_copy` already holds for FR-9.14 | Must |
|
||
| FR-10.6 | Any ride still on disk can be shared, not only the one that just ended | Should |
|
||
| FR-10.7 | If nothing on the device accepts the file, say so plainly and point at FR-9.14's save dialog as the way out | Must |
|
||
| FR-10.8 | Desktop is out of scope. FR-9.14's save-to-a-chosen-location *is* the desktop share; there is no `xdg-open` handoff and no portal | — |
|
||
|
||
**Mechanism.**
|
||
|
||
The manifest already declares a `FileProvider` under `${applicationId}.fileprovider` —
|
||
`tauri-plugin-dialog` put it there — but its generated `res/xml/file_paths.xml` grants only
|
||
`<external-path>` and `<cache-path>`. The rides directory is neither: `app_data_dir()` on
|
||
Android is internal, app-private storage. `file_paths.xml` therefore needs a `<files-path>`
|
||
entry covering `rides/`, and per NFR-13 that file must become a **tracked** source under
|
||
`src-tauri/android/src/main/res/xml/`, copied in by `scripts/sync-android-sources.sh` —
|
||
otherwise the next `tauri android init` regenerates the two-entry version over it. This is
|
||
precisely the silent failure NFR-13 exists for: an APK that builds, installs, runs, and
|
||
throws `IllegalArgumentException: Failed to find configured root` the first time anyone
|
||
presses Share.
|
||
|
||
The share itself is one Kotlin method on `MainActivity`, called over JNI from `android.rs`
|
||
exactly as `requestBluetoothEnable` already is, plus one Tauri command in front of it:
|
||
|
||
```text
|
||
ACTION_SEND
|
||
type = <OQ-11>
|
||
EXTRA_STREAM = FileProvider.getUriForFile(ctx, "$packageName.fileprovider", fit)
|
||
addFlags(FLAG_GRANT_READ_URI_PERMISSION)
|
||
→ startActivity(Intent.createChooser(intent, …))
|
||
```
|
||
|
||
`startActivity` may only be called from the UI thread, and the thread hop belongs in Kotlin
|
||
for the reason `requestBluetoothEnable` documents: Rust has no cheap way to get there.
|
||
|
||
The MIME type is the one part that cannot be settled from a desk. `application/vnd.ant.fit`
|
||
is the registered type; `application/octet-stream` is what a good many Android apps actually
|
||
filter on. Guessing wrong yields an *empty chooser*, not an error — OQ-11, RISK-10.
|
||
|
||
#### 5.10.2 Upload the activity to Runalyze
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-10.9 | Upload a finished activity to Runalyze | Must |
|
||
| FR-10.10 | The only credential ever asked for is a personal API token, generated by the rider at `runalyze.com/settings/personal-api` and pasted in. No password, no OAuth, no account of ours | Must |
|
||
| FR-10.11 | Upload is **opt-in and off by default**. An installation that has never been given a token originates no network traffic of any kind, ever (NFR-6) | Must |
|
||
| FR-10.12 | With auto-upload on, a finished ride is queued at the moment the activity is written — not when the rider happens to reach a screen | Must |
|
||
| FR-10.13 | With it off, the rider can still upload a chosen ride on demand | Must |
|
||
| FR-10.14 | The upload never blocks the summary, the UI, or the next ride. A rider who finishes and immediately starts again waits on nothing | Must |
|
||
| FR-10.15 | The queue survives process death, a force-stop and a flight-mode ride. A ride queued in a basement uploads when the phone next sees a network, on a later launch if that is when it happens | Must |
|
||
| FR-10.16 | Retries are bounded and backed off; the backoff is persisted, so a restart cannot reset it into a hot loop | Must |
|
||
| FR-10.17 | Errors are classified *retryable* (DNS, TLS, timeout, 5xx, 429) or *permanent* (bad token, rejected file, other 4xx), and the two are never conflated. A bad token must not be retried for a week, and a dropped connection must not be reported as a rejected ride | Must |
|
||
| FR-10.18 | Idempotent per (ride, service): a ride already accepted is never sent twice, whatever the rider presses | Must |
|
||
| FR-10.19 | A duplicate reported by the service is recorded as **success**, not failure. That single rule is what makes FR-10.16's retries safe | Must |
|
||
| FR-10.20 | `recording::prune` never deletes a ride with a non-terminal queue entry, however old it is | Must |
|
||
| FR-10.21 | The rider can see the state of every queued upload, retry a failed one, and cancel one outright | Should |
|
||
| FR-10.22 | The token is never written to a log, never put in a message shown to the rider, and never emitted as a `tracing` field at any level — NFR-8's "log everything" stops at the credential | Must |
|
||
| FR-10.23 | TLS certificate verification is never disabled, and no setting exists that disables it | Must |
|
||
| FR-10.24 | A failed upload cannot damage the ride. The rides directory is read-only to this whole subsystem | Must |
|
||
|
||
**The API, as we use it.** Confirmed against Runalyze's own reference client
|
||
(`Runalyze/personal-api`, `bash/upload-activities.sh`):
|
||
|
||
```text
|
||
POST https://runalyze.com/api/v1/activities/uploads
|
||
token: <personal token>
|
||
Content-Type: multipart/form-data
|
||
file=@2026-08-21T18-42-10.fit
|
||
```
|
||
|
||
| Element | Value | State |
|
||
|---------|-------|-------|
|
||
| Endpoint | `POST /api/v1/activities/uploads` | **Confirmed** |
|
||
| Auth | a `token:` header — *not* `Authorization:` | **Confirmed** |
|
||
| Form field | `file` | **Confirmed** |
|
||
| Accepted formats | `fit`, `tcx`, `gpx`, `ttbin`, `fitlog` | **Confirmed** |
|
||
| Auth failure | response body contains `No valid token` | **Confirmed** — the only error the reference client checks |
|
||
| Success body | — | **Unknown, OQ-12** |
|
||
| Duplicate response | — | **Unknown, OQ-12** |
|
||
| Rate / size limits | none published | Unknown |
|
||
|
||
Two consequences worth stating before anything is written.
|
||
|
||
**Processing is asynchronous on their side.** Runalyze's own README says uploads are queued
|
||
and do not appear immediately. There is no activity id to poll for and no "it is live"
|
||
moment to report, so an accepted upload means *accepted* and the UI must say that — not
|
||
"uploaded to Runalyze", as though the rider could go and look at it. This is simpler than
|
||
Strava's upload-then-poll; the honest wording is the whole of the difference.
|
||
|
||
**The success and duplicate bodies must be captured, not guessed.** The reference client
|
||
discards the body apart from one substring check, and the published documentation is behind
|
||
a login. Before FR-10.19 can be implemented, one real upload and one deliberate re-upload of
|
||
the same file are run by hand and both bodies pinned as fixtures — the same discipline
|
||
§2.3.1 applied to the Click's handshake reply, which turned out to be `02 03` and not the
|
||
documented `01 03`. Until that is done the honest classification is: a 2xx not containing
|
||
`No valid token` is *accepted*, and duplicate detection does not exist.
|
||
|
||
#### 5.10.3 The upload queue
|
||
|
||
The design problem here is not the HTTP. It is that a rider finishes in a basement, on a
|
||
phone with no signal, and closes the app. `stop_ride` → `POST` is wrong for exactly the
|
||
reason `stop_ride` → save-dialog was wrong: it makes the ride's fate depend on a moment that
|
||
may not go well. §5.8 solved that by writing the file first and offering the dialog second.
|
||
This is the same solution one layer out — an upload is a **pending intent recorded next to
|
||
the file**, not an action attempted once.
|
||
|
||
```text
|
||
app_data_dir()/uploads.json
|
||
{ "version": 1,
|
||
"entries": [ { "ride": "2026-08-21T18-42-10",
|
||
"target": "runalyze",
|
||
"state": "pending" | "done" | "failed",
|
||
"attempts": 3,
|
||
"nextAttemptMs": 1755800000000,
|
||
"detail": "No valid token" }, … ] }
|
||
```
|
||
|
||
Same contract as `devices.json` and `settings.json`: versioned, an unknown version discarded
|
||
rather than guessed at, written only when something changes, and **advisory** — an
|
||
unreadable `uploads.json` costs the rider an upload, never a ride, so every failure there is
|
||
logged and swallowed.
|
||
|
||
| From | Event | To |
|
||
|------|-------|----|
|
||
| *(ride written, or the rider asks)* | queued | `pending`, attempts 0 |
|
||
| `pending` | accepted — **or duplicate** (FR-10.19) | `done`, **terminal** |
|
||
| `pending` | retryable error, attempts left | `pending`, attempts + 1, `nextAttemptMs` pushed out |
|
||
| `pending` | retryable error, attempts exhausted | `failed`, **terminal**, carrying the last reason |
|
||
| `pending` | permanent error | `failed`, **terminal**, carrying the reason |
|
||
| `failed` | explicit rider retry (FR-10.21) | `pending`, attempts 0 |
|
||
| any | the FIT is no longer on disk | entry dropped |
|
||
|
||
- Backoff exponential from 30 s, capped near an hour, and **persisted** — a relaunch must
|
||
not reset a failing entry into a tight retry loop against someone else's server.
|
||
- Attempts capped (10). Past that the entry is `failed` carrying the last retryable reason,
|
||
and only an explicit rider retry revives it.
|
||
- The drain runs at launch — *after* `recover_orphans`, so a ride rebuilt from its journal
|
||
can be queued too — and on a low-frequency timer thereafter. Never on the ride loop's
|
||
thread, and it never holds the ride-state lock across a socket, for the reason
|
||
`RecorderHandle` documents about holding it across an `fsync`.
|
||
- One upload in flight at a time. Concurrency buys nothing here and costs correctness.
|
||
- An entry whose FIT has gone (deleted by a rider, pruned by an older build) is dropped,
|
||
not retried.
|
||
|
||
**Prune (FR-10.20).** `prune` currently keeps the newest 20 stems and deletes the rest. It
|
||
gains a predicate: a stem with a non-terminal entry in `uploads.json` is never deleted. Without
|
||
that, a laptop left off for three weeks quietly deletes the rides it was going to upload.
|
||
|
||
#### 5.10.4 Where the code goes
|
||
|
||
```text
|
||
crates/sync/ # NEW — one trait, one impl. No Tauri, no filesystem policy.
|
||
├── lib.rs # ActivityTarget, UploadOutcome, UploadError
|
||
└── runalyze.rs # the multipart POST and its response classification
|
||
src-tauri/src/
|
||
└── uploads.rs # NEW — the store, the queue, the drain task, the commands
|
||
```
|
||
|
||
```rust
|
||
pub struct Activity<'a> { pub path: &'a Path, pub filename: &'a str }
|
||
|
||
pub enum UploadOutcome { Accepted { remote_id: Option<String> }, Duplicate }
|
||
|
||
pub enum UploadError {
|
||
/// Try again later: DNS, TLS, timeout, 5xx, 429.
|
||
Retryable(String),
|
||
/// Do not try again: bad token, rejected file, any other 4xx.
|
||
Permanent(String),
|
||
}
|
||
|
||
pub trait ActivityTarget {
|
||
fn id(&self) -> &'static str;
|
||
async fn upload(&self, activity: Activity<'_>) -> Result<UploadOutcome, UploadError>;
|
||
}
|
||
```
|
||
|
||
`crates/sync` takes no Tauri dependency and knows nothing about where rides live, for the
|
||
same reason `crates/fit` knows neither: it can then be tested against a local mock HTTP
|
||
server with no app running, the way `fit` is tested by encoding with our writer and decoding
|
||
with somebody else's parser. `Duplicate` is an outcome rather than an error precisely
|
||
because FR-10.19 turns on it, and a variant is harder to mishandle than a string match at a
|
||
call site.
|
||
|
||
Dependency: `reqwest` with `rustls-tls`, **not** `native-tls`. OpenSSL cross-compiled
|
||
against the Android NDK is a build problem this project does not need, and pure-Rust crypto
|
||
is the choice already made for the Click session (§4).
|
||
|
||
#### 5.10.5 The token
|
||
|
||
`devices.json` and `settings.json` are plaintext, which is right for a MAC address and a
|
||
rider's mass. An API token is not in that class: it grants write access to somebody's
|
||
training history for as long as they leave it valid.
|
||
|
||
| ID | Requirement | Priority |
|
||
|----|-------------|----------|
|
||
| FR-10.25 | The token is not stored in `settings.json` beside the rider's mass | Must |
|
||
| FR-10.26 | Desktop: the OS credential store (`keyring` — Secret Service, Keychain, Credential Manager). A store that is missing or locked degrades to *no token configured* and asks the rider to paste it again; it never silently falls back to a plaintext file | Must |
|
||
| FR-10.27 | Android: app-private storage, which the OS already isolates per app — `EncryptedSharedPreferences` if it can be reached without a Java dependency costing more than it saves | Should |
|
||
| FR-10.28 | The UI never redisplays a stored token. It shows *configured* / *not configured*, and offers replace and remove | Must |
|
||
|
||
And be honest about the ceiling: on a rooted phone, or a desktop with no keyring daemon
|
||
running, none of this hides the token from someone holding the device. What it buys is that
|
||
the token is not sitting in a world-readable JSON file beside the rides, and is not in the
|
||
settings file a rider pastes into a bug report.
|
||
|
||
---
|
||
|
||
## 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** — every function that is part of *riding* works with no internet connection: discovery, connection, control, profiles, physics, recording and the FIT file. The app contacts no service unless the rider has configured one (FR-10.11), and it never contacts Zwift — only the rider's separate daily unlock needs the internet. An installation with no upload token configured originates no network traffic at all |
|
||
| 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 |
|
||
| NFR-15 | **Nothing on the network is on the ride path** — no ride may be blocked, delayed or degraded by a network operation. Sharing and uploading run *behind* the ride, never in front of it; the ride loop awaits neither, and a service that is down, slow or wrong costs a rider an upload and never a session |
|
||
| NFR-16 | **Credentials are not settings** — an API token is never stored in `settings.json`, never logged at any level, and never redisplayed by the UI once stored (FR-10.22, FR-10.25–10.28). NFR-8's "log everything" stops at the credential |
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
Everything above is the ride. §5.10 hangs off the *right* of it and touches nothing in it:
|
||
|
||
```
|
||
Recorder ──► rides/<stamp>.fit ──┬──► the FIT is the ride artifact; nothing below alters it
|
||
│
|
||
├──► FR-9.14 save dialog (desktop + Android, today)
|
||
├──► FR-10.1 share sheet (Android — JNI → Kotlin → FileProvider)
|
||
└──► FR-10.9 upload queue ──► crates/sync ──► Runalyze
|
||
uploads.json (HTTPS, opt-in)
|
||
```
|
||
|
||
The arrows only ever point away from the file. Nothing on the right re-enters the ride loop,
|
||
and the queue is the only thing there that owns state of its own.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### Phase 5 — Sharing and upload
|
||
FR-10, plus the NFR-6 / NFR-15 / NFR-16 amendments. Two independent pieces: the share ships
|
||
first and is blocked on nothing in the upload.
|
||
|
||
| Step | Work |
|
||
|------|------|
|
||
| **1** | **Share (FR-10.1–10.8).** `file_paths.xml` becomes a tracked source under `src-tauri/android/` with a `<files-path>` for `rides/`, copied by `sync-android-sources.sh` and checked in CI like the manifest (NFR-13). One Kotlin method on `MainActivity`, one JNI entry in `android.rs`, one command, one button on the summary. **Settles OQ-11 on a real phone against Garmin Connect and Strava** — which is the reason it goes first |
|
||
| **2** | **Token storage (FR-10.25–10.28)**, before any code exists that has a token to lose |
|
||
| **3** | **`crates/sync` and the Runalyze impl**, against a local mock HTTP server. Then one real upload and one deliberate re-upload by hand, both bodies pinned as fixtures — **OQ-12**, without which FR-10.19 is a guess |
|
||
| **4** | **The queue (FR-10.12–10.21)**: `uploads.json`, the drain task, the prune predicate, the UI |
|
||
|
||
*Milestone: a ride reaches Garmin Connect from the phone with no cable, and reaches Runalyze
|
||
without the rider touching anything.*
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| **RISK-10** | The FIT share MIME type is not what the receiving apps filter on | **The chooser comes up empty, with no error anywhere.** A feature that looks broken and logs nothing | OQ-11: settle it on a real phone against Garmin Connect, Strava and a file manager *before* the UI is written. `application/vnd.ant.fit` is the registered type; `application/octet-stream` is what many Android apps actually match, and is the fallback |
|
||
| **RISK-11** | Strava is the service riders will ask for next, and its token exchange needs a `client_secret` inside an open-source native binary — it has no PKCE — against a rate limit shared by every install rather than held per rider | An obvious feature request carries policy risk and a ceiling that arrives without warning | Deferred deliberately, not forgotten. Runalyze first proves the trait, the queue and the error classification; when Strava is done, the likely shape is the rider registering their *own* API application, which moves both the secret and the rate limit onto them. Until then the share sheet reaches Strava's own app and needs none of it |
|
||
| **RISK-12** | Runalyze's success and duplicate response bodies are undocumented — the reference client discards the body apart from one substring check, and the published docs are behind a login | FR-10.18/10.19's idempotency would rest on a guess, and a retried upload could silently double a ride | Capture both by hand and pin them as fixtures before implementing (OQ-12). Until then, treat a 2xx not containing `No valid token` as accepted and do not claim duplicate detection at all |
|
||
| **RISK-13** | `file_paths.xml` is generated by the dialog plugin and regenerated by `tauri android init`, and its roots do not cover app-private storage | Share throws `Failed to find configured root` in a build that is otherwise perfectly healthy | Exactly the failure NFR-13 exists for. The file moves under `src-tauri/android/`, the sync script copies it, and CI checks it as it already checks the manifest |
|
||
|
||
### 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? |
|
||
| **OQ-11** | Which MIME type do Garmin Connect's and Strava's Android apps actually accept a `.fit` on — `application/vnd.ant.fit`, `application/octet-stream`, or both? Answered by trying it on a phone, not by reading (RISK-10). |
|
||
| **OQ-12** | What do Runalyze's upload responses look like on success, and on a re-upload of a file already accepted? Both need capturing before FR-10.19 can be written (RISK-12). |
|
||
| **OQ-13** | Is auto-upload a standing preference or a per-ride choice — and if standing, does a ride abandoned after ninety seconds get uploaded too? (Suggest: standing, with a minimum-duration floor, so a two-minute test of the trainer does not land in the training log.) |
|
||
|
||
**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 |
|
||
| **FileProvider** | Android component that hands another app a `content://` URI onto a file in this app's private storage, without making the file world-readable (FR-10.2) |
|
||
| **Personal API token** | Runalyze's credential: a string the rider generates in their own account settings and pastes in. Not OAuth — no authorisation flow, no redirect, no client secret (FR-10.10) |
|