Record what the interface now promises
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>
This commit is contained in:
+299
-4
@@ -1,9 +1,18 @@
|
|||||||
# BikeControl — Requirements Specification
|
# BikeControl — Requirements Specification
|
||||||
|
|
||||||
**Status:** Draft v0.4
|
**Status:** Draft v0.5
|
||||||
**Date:** 2026-08-05
|
**Date:** 2026-08-21
|
||||||
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
**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
|
> **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
|
> 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
|
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
|
||||||
@@ -41,7 +50,10 @@ D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and pro
|
|||||||
|
|
||||||
### 1.2 Non-goals
|
### 1.2 Non-goals
|
||||||
|
|
||||||
- Multiplayer, social features, or any network service.
|
- 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.
|
- 3D world rendering or avatars.
|
||||||
- ANT+ support (§10.1).
|
- ANT+ support (§10.1).
|
||||||
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
||||||
@@ -370,6 +382,7 @@ is a deliberate trade of resilience for simplicity.
|
|||||||
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
||||||
| Charts | `uPlot` | Canvas, built for streaming series |
|
| Charts | `uPlot` | Canvas, built for streaming series |
|
||||||
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
|
| 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**.
|
**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
|
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
|
||||||
@@ -383,6 +396,7 @@ bikecontrol/
|
|||||||
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
||||||
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
||||||
│ ├── fit/ # FIT encoder (ported from §3.2)
|
│ ├── 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)
|
│ └── probe/ # CLI for protocol discovery (Phase 0)
|
||||||
├── src-tauri/ # Tauri shell, commands, event bridge
|
├── src-tauri/ # Tauri shell, commands, event bridge
|
||||||
└── ui/ # web frontend
|
└── ui/ # web frontend
|
||||||
@@ -583,6 +597,8 @@ blocks:
|
|||||||
| FR-7.2 | Integrate speed into distance, which drives route position | 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.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.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.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 |
|
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
|
||||||
|
|
||||||
@@ -623,6 +639,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
|||||||
| FR-9.2 | Per-device connect / disconnect / forget, with clear state and error text | 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.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.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**
|
**Ride screen**
|
||||||
|
|
||||||
@@ -640,6 +658,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
|||||||
| 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.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.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.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 |
|
| 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**
|
**Post-ride**
|
||||||
@@ -651,6 +671,242 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 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
|
## 6. Non-Functional Requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| ID | Requirement |
|
||||||
@@ -660,7 +916,7 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
|||||||
| NFR-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth |
|
| 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-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-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-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-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-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-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
|
||||||
@@ -669,6 +925,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
|||||||
| 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-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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -748,6 +1006,20 @@ Three rules fall out of it:
|
|||||||
by the rider in Zwift
|
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
|
## 9. Delivery Plan
|
||||||
@@ -806,6 +1078,20 @@ TASK-3's resistance-curve calibration), RISK-9 resolution.
|
|||||||
FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding),
|
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.
|
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
|
## 10. Risks
|
||||||
@@ -821,6 +1107,10 @@ FR-4.6 (ERG), FR-9.11/9.12, Android.
|
|||||||
| **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-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-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-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+
|
### 10.1 On ANT+
|
||||||
|
|
||||||
@@ -842,6 +1132,9 @@ intolerable.
|
|||||||
| **OQ-6** | Which frontend framework? Svelte recommended. |
|
| **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-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-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
|
**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).
|
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
||||||
@@ -862,3 +1155,5 @@ MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
|||||||
| **Crr** | Coefficient of rolling resistance |
|
| **Crr** | Coefficient of rolling resistance |
|
||||||
| **CdA** | Drag coefficient × frontal area |
|
| **CdA** | Drag coefficient × frontal area |
|
||||||
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
|
| **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) |
|
||||||
|
|||||||
Reference in New Issue
Block a user