Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbc5aa292 | ||
|
|
41574f694e | ||
|
|
0c757a4a15 | ||
|
|
cdff678167 | ||
|
|
4269c5a446 | ||
|
|
7497a5d602 | ||
|
|
5dff2500e2 |
Generated
+1
@@ -255,6 +255,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+312
-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
|
||||||
@@ -188,6 +200,17 @@ button we have found drives them.
|
|||||||
> so it is evidence of a wrong-way-round pair only while that pod has never sent its own —
|
> 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.
|
> 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
|
### 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
|
The trainer answers the same handshake (`RideOn 00 09` → `RideOn 02 00`) on the original
|
||||||
@@ -359,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
|
||||||
@@ -372,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
|
||||||
@@ -390,6 +415,8 @@ bikecontrol/
|
|||||||
| FR-1.3 | Connect to trainer and controller independently; either may connect first | 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.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.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.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.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.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
|
||||||
@@ -570,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 |
|
||||||
|
|
||||||
@@ -610,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**
|
||||||
|
|
||||||
@@ -627,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**
|
||||||
@@ -638,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 |
|
||||||
@@ -647,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 |
|
||||||
@@ -656,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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -735,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
|
||||||
@@ -793,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
|
||||||
@@ -808,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+
|
||||||
|
|
||||||
@@ -829,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).
|
||||||
@@ -849,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) |
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ bikecontrol-fit = { workspace = true }
|
|||||||
|
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
# Not for the frontend — nothing in `ui/` calls the fs commands. This is here
|
||||||
|
# for `FsExt::read_to_string`, the one API that can read what the *picker*
|
||||||
|
# returns on Android: a `content://` URI rather than a path. See
|
||||||
|
# `read_picked_file` in commands.rs.
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
+173
-27
@@ -4,12 +4,13 @@
|
|||||||
//! they mutate Rust-side state and the resulting truth comes back on the event
|
//! they mutate Rust-side state and the resulting truth comes back on the event
|
||||||
//! channel. The UI never assumes a command took effect (§4.3).
|
//! channel. The UI never assumes a command took effect (§4.3).
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||||
use bikecontrol_core::profile::Profile;
|
use bikecontrol_core::profile::Profile;
|
||||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||||||
|
|
||||||
use bikecontrol_ble::PodId;
|
use bikecontrol_ble::PodId;
|
||||||
|
|
||||||
@@ -188,8 +189,7 @@ pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
|
|||||||
/// Returns the path actually written, so the UI can confirm it rather than
|
/// Returns the path actually written, so the UI can confirm it rather than
|
||||||
/// claiming success against a path it merely proposed.
|
/// claiming success against a path it merely proposed.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||||||
let dest = PathBuf::from(&path);
|
|
||||||
let source = {
|
let source = {
|
||||||
let inner = state.lock();
|
let inner = state.lock();
|
||||||
let summary = inner
|
let summary = inner
|
||||||
@@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
PathBuf::from(&summary.fit_path)
|
PathBuf::from(&summary.fit_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
recording::save_copy(&source, &dest)?;
|
match &path {
|
||||||
let written = dest.display().to_string();
|
FilePath::Path(dest) => recording::save_copy(&source, dest)?,
|
||||||
|
// Android: the save dialog returns a `content://` URI for a document
|
||||||
|
// the provider has already created. There is no directory to make and
|
||||||
|
// no path to copy to — the bytes go down a descriptor the resolver
|
||||||
|
// opens, which is the same reason `read_picked_file` exists.
|
||||||
|
FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
|
||||||
|
}
|
||||||
|
let written = path.to_string();
|
||||||
if let Some(summary) = state.lock().last_summary.as_mut() {
|
if let Some(summary) = state.lock().last_summary.as_mut() {
|
||||||
summary.saved_path = Some(written.clone());
|
summary.saved_path = Some(written.clone());
|
||||||
}
|
}
|
||||||
@@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy the activity into a document the rider chose from an Android picker.
|
||||||
|
///
|
||||||
|
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
|
||||||
|
/// filesystem path on the other end, only a URI the content resolver can turn
|
||||||
|
/// into a writable descriptor. Still a copy, never a move, for the reason
|
||||||
|
/// `save_copy` documents — the automatic file in the rides directory has to
|
||||||
|
/// survive a failed export.
|
||||||
|
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
|
||||||
|
let mut from = std::fs::File::open(source)
|
||||||
|
.map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
|
||||||
|
let mut to = app
|
||||||
|
.fs()
|
||||||
|
.open(
|
||||||
|
dest.clone(),
|
||||||
|
OpenOptions::new().write(true).truncate(true).clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))?;
|
||||||
|
std::io::copy(&mut from, &mut to)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||||
state.lock().reset_ride();
|
state.lock().reset_ride();
|
||||||
@@ -412,10 +441,19 @@ pub fn set_rider_config(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
config: RiderConfig,
|
config: RiderConfig,
|
||||||
) -> Cmd<RiderConfig> {
|
) -> Cmd<RiderConfig> {
|
||||||
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
|
// Every bound refused here is one that makes the engine produce nonsense
|
||||||
return Err("Rider and bike mass must be positive and realistic".into());
|
// rather than something merely unusual, and each refusal says what a
|
||||||
|
// workable value looks like — this is now a form a rider fills in, not a
|
||||||
|
// struct only the developer ever touched.
|
||||||
|
crate::settings::validate_rider(&config)?;
|
||||||
|
{
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.inputs.rider = config;
|
||||||
|
// Written down immediately. A setup that lasts only as long as the
|
||||||
|
// process is what left every rider on the 105 kg default (FR-7.4).
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
}
|
}
|
||||||
state.lock().inputs.rider = config;
|
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -431,14 +469,40 @@ pub fn set_safety_limits(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
limits: SafetyLimits,
|
limits: SafetyLimits,
|
||||||
) -> Cmd<SafetyLimits> {
|
) -> Cmd<SafetyLimits> {
|
||||||
if limits.min_gradient_pct >= limits.max_gradient_pct {
|
crate::settings::validate_limits(&limits)?;
|
||||||
return Err("Gradient limits are inverted".into());
|
{
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.inputs.limits = limits;
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
}
|
}
|
||||||
state.lock().inputs.limits = limits;
|
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
Ok(limits)
|
Ok(limits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display preferences: FTP, maximum heart rate, units (§4.3).
|
||||||
|
///
|
||||||
|
/// Separate from [`rider_config`] because nothing here reaches the physics —
|
||||||
|
/// these decide how a number is drawn, not what it is. They are stored in the
|
||||||
|
/// same file because that is where the rider expects to find them.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn preferences(state: State<'_, AppState>) -> crate::settings::Preferences {
|
||||||
|
state.lock().settings.prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_preferences(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
prefs: crate::settings::Preferences,
|
||||||
|
) -> Cmd<crate::settings::Preferences> {
|
||||||
|
crate::settings::validate_prefs(&prefs)?;
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.settings.prefs = prefs;
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
|
Ok(prefs)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Profiles (§5.5, §5.6)
|
// Profiles (§5.5, §5.6)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -453,22 +517,101 @@ fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a profile from a path on disk. GPX is detected by extension, everything
|
/// Read a file the rider picked, wherever it actually lives.
|
||||||
/// else is treated as the YAML profile format.
|
///
|
||||||
|
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
|
||||||
|
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
|
||||||
|
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
|
||||||
|
/// or directory" — an error the rider gets for a file they are looking at in
|
||||||
|
/// the picker. And for a provider backed by a server rather than storage
|
||||||
|
/// (Nextcloud, Drive) there may be no local file at all until the resolver
|
||||||
|
/// opens the stream, so no amount of path-guessing could have found one.
|
||||||
|
///
|
||||||
|
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
|
||||||
|
/// opened directly, a URI goes through the Android content resolver for a file
|
||||||
|
/// descriptor. On desktop it is `std::fs` with extra steps.
|
||||||
|
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
|
||||||
|
app.fs()
|
||||||
|
.read_to_string(path.clone())
|
||||||
|
.map_err(|e| format!("{path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picked file is GPX, decided by content rather than by name.
|
||||||
|
///
|
||||||
|
/// The extension is not always there to read: a `content://` URI carries a
|
||||||
|
/// document id, which for some providers contains no filename at all. XML is
|
||||||
|
/// unmistakable next to the YAML profile format — no YAML document begins with
|
||||||
|
/// `<` — so the first non-space character is the reliable test and the
|
||||||
|
/// extension is only a fast path.
|
||||||
|
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
|
||||||
|
path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filename behind a picked file, if there is one to be had.
|
||||||
|
///
|
||||||
|
/// A `FilePath::Path` always has a stem. A URI might: providers over real
|
||||||
|
/// storage encode the path in the last segment, percent-escaped but with the
|
||||||
|
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
|
||||||
|
/// ids, which would make a terrible route name — so "does it end in an
|
||||||
|
/// extension we know" is the test, and anything else gets `None` and falls back
|
||||||
|
/// to the name inside the GPX.
|
||||||
|
fn picked_file_stem(path: &FilePath) -> Option<String> {
|
||||||
|
match path {
|
||||||
|
FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
|
||||||
|
FilePath::Url(url) => {
|
||||||
|
let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
|
||||||
|
if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The escaped separators are all that stands between the document
|
||||||
|
// id and the name inside it.
|
||||||
|
let decoded = stem
|
||||||
|
.replace("%2F", "/")
|
||||||
|
.replace("%2f", "/")
|
||||||
|
.replace("%3A", ":")
|
||||||
|
.replace("%3a", ":");
|
||||||
|
let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
|
||||||
|
(!name.is_empty()).then(|| name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
|
||||||
|
///
|
||||||
|
/// The fallback when the picker gave us no filename to use. Matches on local
|
||||||
|
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
|
||||||
|
/// same reason `core::gpx` does.
|
||||||
|
fn gpx_name(xml: &str) -> Option<String> {
|
||||||
|
let doc = roxmltree::Document::parse(xml).ok()?;
|
||||||
|
let named = |parent: &str| {
|
||||||
|
doc.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == parent)?
|
||||||
|
.children()
|
||||||
|
.find(|c| c.is_element() && c.tag_name().name() == "name")?
|
||||||
|
.text()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
|
named("metadata").or_else(|| named("trk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a profile the rider picked: a path on desktop, a `content://` URI on
|
||||||
|
/// Android. GPX is detected by content, everything else is treated as the YAML
|
||||||
|
/// profile format.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_profile_from_path(
|
pub fn load_profile_from_path(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
path: String,
|
path: FilePath,
|
||||||
) -> Cmd<ProfileView> {
|
) -> Cmd<ProfileView> {
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
let text = read_picked_file(&app, &path)?;
|
||||||
let stem = std::path::Path::new(&path)
|
let is_gpx = looks_like_gpx(&path, &text);
|
||||||
.file_stem()
|
let name = picked_file_stem(&path)
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.or_else(|| gpx_name(&text))
|
||||||
.unwrap_or_else(|| "Profile".into());
|
.unwrap_or_else(|| "Profile".into());
|
||||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||||||
let (view, geom) = profile_view::build(&profile, path);
|
|
||||||
state.lock().set_profile(profile, view.clone(), geom);
|
state.lock().set_profile(profile, view.clone(), geom);
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
notify(
|
notify(
|
||||||
@@ -620,7 +763,14 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
|
|||||||
state.controller().status()
|
state.controller().status()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
|
/// Connect a Click pod, or the one that matters when `pod` is omitted (FR-1.4).
|
||||||
|
///
|
||||||
|
/// **Omitting `pod` means the `−` pod, not both.** One link is the whole
|
||||||
|
/// controller: the `−` pod relays its twin's paddle and face buttons, so all
|
||||||
|
/// ten buttons arrive over it alone (§2.3.1, confirmed in the field
|
||||||
|
/// 2026-08-21). Connecting the pair adds nothing and breaks one thing — the
|
||||||
|
/// `−` pod stops reporting its own paddle. The `+` pod keeps its own button on
|
||||||
|
/// the connection screen for the case where the `−` pod is flat or absent.
|
||||||
///
|
///
|
||||||
/// `device_id` is an address, for a specific pod the scanner has already
|
/// `device_id` is an address, for a specific pod the scanner has already
|
||||||
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
||||||
@@ -645,12 +795,8 @@ pub fn connect_controller(
|
|||||||
if address.is_some() {
|
if address.is_some() {
|
||||||
return Err("An address names one pod, so say which pod it is".into());
|
return Err("An address names one pod, so say which pod it is".into());
|
||||||
}
|
}
|
||||||
// Both, each on its own schedule: a pod that is awake connects now
|
|
||||||
// rather than queueing behind its sleeping twin.
|
|
||||||
let known = state.lock().devices.click_pod_addresses();
|
let known = state.lock().devices.click_pod_addresses();
|
||||||
for id in PodId::BOTH {
|
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned());
|
||||||
controller.connect(id, known.get(&id).cloned());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+131
-15
@@ -74,6 +74,19 @@ const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
|
|||||||
/// trainer's: there is no reset sequence here, only an unsubscribe and a
|
/// trainer's: there is no reset sequence here, only an unsubscribe and a
|
||||||
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
||||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
/// How long a known `−` pod is waited for before a lone `+` pod will do.
|
||||||
|
///
|
||||||
|
/// One link is the whole controller: the `−` pod relays its twin's paddle and
|
||||||
|
/// face buttons (§2.3.1), so with a `−` pod in the house there is nothing for a
|
||||||
|
/// second link to add and one specific thing for it to break — connected as a
|
||||||
|
/// pair, the `−` pod stops reporting its *own* paddle. Confirmed in the field
|
||||||
|
/// 2026-08-21: pairing the `−` pod alone gives all ten buttons.
|
||||||
|
///
|
||||||
|
/// But holding out forever would mean a flat or lost `−` pod costs the rider
|
||||||
|
/// their `+` paddle as well, which is a worse trade than a redundant link. So
|
||||||
|
/// the wait is bounded: long enough for a rider to wake both pods in whatever
|
||||||
|
/// order they like, short enough that half a controller beats none.
|
||||||
|
const PLUS_GRACE: Duration = Duration::from_secs(45);
|
||||||
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
|
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
|
||||||
///
|
///
|
||||||
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
|
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
|
||||||
@@ -379,6 +392,14 @@ enum Cmd {
|
|||||||
/// Connect one pod. `address` is used when the scanner has already seen it,
|
/// Connect one pod. `address` is used when the scanner has already seen it,
|
||||||
/// which is both faster and unambiguous.
|
/// which is both faster and unambiguous.
|
||||||
Connect { pod: PodId, address: Option<String> },
|
Connect { pod: PodId, address: Option<String> },
|
||||||
|
/// This is the pod we paired with last time (FR-1.5).
|
||||||
|
///
|
||||||
|
/// Sets the slot's address without touching the radio, so a pod that is
|
||||||
|
/// still asleep is nonetheless *known* — which is what lets the `+` pod
|
||||||
|
/// hold back for a `−` pod that has not woken up yet, and what gives a
|
||||||
|
/// manual connect an address to go straight to instead of a type byte to
|
||||||
|
/// go hunting with.
|
||||||
|
Remember { pod: PodId, address: String },
|
||||||
/// The scanner has this pod in view *right now* (FR-1.5).
|
/// The scanner has this pod in view *right now* (FR-1.5).
|
||||||
///
|
///
|
||||||
/// The whole difficulty with a Click is that it advertises for only a few
|
/// The whole difficulty with a Click is that it advertises for only a few
|
||||||
@@ -456,6 +477,14 @@ impl ControllerHandle {
|
|||||||
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
|
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed the pod we paired with last time, from the remembered-device store.
|
||||||
|
pub fn remember(&self, pod: PodId, address: &str) {
|
||||||
|
let _ = self.cmd_tx.try_send(Cmd::Remember {
|
||||||
|
pod,
|
||||||
|
address: address.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
||||||
///
|
///
|
||||||
/// Called from the device scan on every pass. Cheap and idempotent: the
|
/// Called from the device scan on every pass. Cheap and idempotent: the
|
||||||
@@ -587,6 +616,10 @@ async fn run(
|
|||||||
let mut swapped = false;
|
let mut swapped = false;
|
||||||
// One press is one press, whichever pods report it (see `Buttons`).
|
// One press is one press, whichever pods report it (see `Buttons`).
|
||||||
let mut buttons = Buttons::default();
|
let mut buttons = Buttons::default();
|
||||||
|
// When a lone `+` pod stops being worse than no controller at all. Pushed
|
||||||
|
// back whenever the `−` pod is reachable, so it only ever expires against a
|
||||||
|
// `−` pod that is genuinely not coming (see `PLUS_GRACE`).
|
||||||
|
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
|
||||||
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -618,29 +651,43 @@ async fn run(
|
|||||||
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
||||||
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
||||||
start_attempt(slot, pod, selector, &attempt_tx);
|
start_attempt(slot, pod, selector, &attempt_tx);
|
||||||
|
if pod == PodId::Minus {
|
||||||
|
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
}
|
||||||
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
|
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
|
||||||
}
|
}
|
||||||
|
Cmd::Remember { pod, address } => {
|
||||||
|
// Only fills a gap. A pod we have actually talked to
|
||||||
|
// this session knows its own address better than a file
|
||||||
|
// written last week does.
|
||||||
|
status_tx.send_modify(|s| {
|
||||||
|
let p = s.get_mut(pod);
|
||||||
|
if p.address.is_none() && !address.trim().is_empty() {
|
||||||
|
tracing::info!(pod = pod.as_str(), %address, "controller: remembered pod");
|
||||||
|
p.address = Some(address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Cmd::Seen { pod, address } => {
|
Cmd::Seen { pod, address } => {
|
||||||
// One link is the whole controller.
|
// One link is the whole controller — see
|
||||||
//
|
// `take_plus_pod`, which owns this rule. The short of
|
||||||
// The `−` pod relays its twin: connected on its own it
|
// it: the `−` pod relays its twin, so all ten buttons
|
||||||
// delivers its own paddle and D-pad *and* the `+`
|
// arrive over it alone (§2.3.1, confirmed in the field
|
||||||
// paddle and face buttons — all ten buttons, measured
|
// 2026-08-21), and a `+` link is a substitute for a `−`
|
||||||
// over 445 frames on one characteristic (§2.3.1). So
|
// pod that is missing rather than the other half of a
|
||||||
// the `+` pod is not connected while the `−` pod is
|
// pair. Connecting both is what the merge in `Buttons`
|
||||||
// there to speak for it. It stays a fallback for the
|
|
||||||
// case where the `−` pod is absent or the rider only
|
|
||||||
// owns that half.
|
|
||||||
//
|
|
||||||
// Connecting both is what the pair-merge in `Buttons`
|
|
||||||
// exists to paper over, and it is also the
|
// exists to paper over, and it is also the
|
||||||
// configuration in which the `−` pod stops reporting
|
// configuration in which the `−` pod stops reporting
|
||||||
// its own paddle — the failure that cost an evening.
|
// its own paddle — the failure that cost an evening.
|
||||||
// Not opening the second link removes both.
|
if pod == PodId::Plus
|
||||||
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
|
&& !take_plus_pod(
|
||||||
|
minus.idle(),
|
||||||
|
status_tx.borrow().minus.address.is_some(),
|
||||||
|
tokio::time::Instant::now() >= plus_gate,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"controller: + pod seen but the − pod is already speaking for it"
|
"controller: + pod seen; the − pod speaks for the pair"
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -653,6 +700,9 @@ async fn run(
|
|||||||
// already found are the seconds it goes back to sleep in.
|
// already found are the seconds it goes back to sleep in.
|
||||||
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
||||||
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
||||||
|
if pod == PodId::Minus {
|
||||||
|
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
}
|
||||||
status_tx.send_modify(|s| {
|
status_tx.send_modify(|s| {
|
||||||
let p = s.get_mut(pod);
|
let p = s.get_mut(pod);
|
||||||
p.address = Some(address);
|
p.address = Some(address);
|
||||||
@@ -738,6 +788,13 @@ async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = housekeeping.tick() => {
|
_ = housekeeping.tick() => {
|
||||||
|
// A `−` pod that is up, or on its way up, is a `−` pod worth
|
||||||
|
// waiting for. Only a slot that has been idle for the whole
|
||||||
|
// grace period lets the `+` pod in.
|
||||||
|
if !minus.idle() {
|
||||||
|
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
}
|
||||||
|
|
||||||
// Converge on one link. The `+` pod may have connected first —
|
// Converge on one link. The `+` pod may have connected first —
|
||||||
// it is the one the rider happened to wake — and once the `−`
|
// it is the one the rider happened to wake — and once the `−`
|
||||||
// pod is up it speaks for both, so the second link is redundant
|
// pod is up it speaks for both, so the second link is redundant
|
||||||
@@ -820,6 +877,25 @@ async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May a `+` pod the scan has just seen be connected?
|
||||||
|
///
|
||||||
|
/// The `−` pod is the controller (§2.3.1): connected on its own it delivers all
|
||||||
|
/// ten buttons, its twin's included. So a `+` link is only ever a *substitute*,
|
||||||
|
/// and opening one alongside a working `−` link is the configuration in which
|
||||||
|
/// the `−` pod stops reporting its own paddle.
|
||||||
|
///
|
||||||
|
/// Three inputs, in the order they decide:
|
||||||
|
/// - `minus_idle` — false when the `−` pod is connected or being connected.
|
||||||
|
/// Nothing else matters then: it is already speaking for both.
|
||||||
|
/// - `minus_known` — we have an address for a `−` pod, from this session or
|
||||||
|
/// from the remembered-device store. With none, there is no `−` pod to wait
|
||||||
|
/// for and the `+` pod is the whole controller.
|
||||||
|
/// - `gate_expired` — the `−` pod has been unreachable for [`PLUS_GRACE`].
|
||||||
|
/// A flat or lost `−` pod must not cost the rider their `+` paddle too.
|
||||||
|
fn take_plus_pod(minus_idle: bool, minus_known: bool, gate_expired: bool) -> bool {
|
||||||
|
minus_idle && (!minus_known || gate_expired)
|
||||||
|
}
|
||||||
|
|
||||||
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
||||||
match pod {
|
match pod {
|
||||||
PodId::Minus => minus,
|
PodId::Minus => minus,
|
||||||
@@ -1574,6 +1650,46 @@ mod tests {
|
|||||||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_link_is_the_whole_controller() {
|
||||||
|
// Confirmed in the field 2026-08-21: pairing the − pod alone gives all
|
||||||
|
// ten buttons, because it relays its twin (§2.3.1). So the + pod is a
|
||||||
|
// substitute, never a second half.
|
||||||
|
|
||||||
|
// The − pod is up, or on its way up. Nothing else matters.
|
||||||
|
assert!(!take_plus_pod(false, true, true));
|
||||||
|
assert!(!take_plus_pod(false, false, true));
|
||||||
|
|
||||||
|
// We know a − pod exists and it has not been out of reach for long. It
|
||||||
|
// is almost certainly just asleep — a Click only advertises while awake
|
||||||
|
// (A-4) — so wait rather than open a link we would only close again.
|
||||||
|
assert!(!take_plus_pod(true, true, false));
|
||||||
|
|
||||||
|
// No − pod has ever been seen or remembered: this rider's + pod *is*
|
||||||
|
// their controller, and making them wait for a pod they do not own
|
||||||
|
// would be waiting forever.
|
||||||
|
assert!(take_plus_pod(true, false, false));
|
||||||
|
|
||||||
|
// The − pod is known but has stayed out of reach. Flat, or left in the
|
||||||
|
// garage. Half a controller beats none.
|
||||||
|
assert!(take_plus_pod(true, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waiting_for_the_minus_pod_is_shorter_than_giving_up_on_it() {
|
||||||
|
// The grace period is a pause, not a policy: it must expire long before
|
||||||
|
// the reconnect budget does, or a rider whose − pod is flat sits with no
|
||||||
|
// controller at all while the app keeps hoping.
|
||||||
|
assert!(
|
||||||
|
PLUS_GRACE <= Duration::from_secs(60),
|
||||||
|
"a rider with a flat − pod waits {PLUS_GRACE:?} for their + paddle"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
PLUS_GRACE >= Duration::from_secs(20),
|
||||||
|
"shorter than the time it takes to wake two pods by hand"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
||||||
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
||||||
|
|||||||
+311
-27
@@ -19,7 +19,8 @@
|
|||||||
//! point is refused.
|
//! point is refused.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::Duration;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
||||||
use bikecontrol_ble::uuids;
|
use bikecontrol_ble::uuids;
|
||||||
@@ -31,6 +32,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::controller::{ControllerHandle, PodState};
|
use crate::controller::{ControllerHandle, PodState};
|
||||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||||
|
use crate::known::KnownDevices;
|
||||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||||
|
|
||||||
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
||||||
@@ -41,6 +43,23 @@ const IDLE_POLL: Duration = Duration::from_millis(400);
|
|||||||
/// How long to wait before looking for the adapter again. Longer than the scan
|
/// How long to wait before looking for the adapter again. Longer than the scan
|
||||||
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
||||||
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
/// How long before auto-connect tries a remembered device again (FR-1.5).
|
||||||
|
///
|
||||||
|
/// An attempt only starts against a peripheral the scan can *see*, so the
|
||||||
|
/// common case needs no backing off at all. This is for the awkward one: a
|
||||||
|
/// trainer that advertises happily and then refuses the connect, which without
|
||||||
|
/// a cooldown would be retried every device tick — four failed connects a
|
||||||
|
/// second, all of them fighting the scan for the one adapter.
|
||||||
|
const AUTO_RETRY: Duration = Duration::from_secs(15);
|
||||||
|
/// How many times auto-connect will chase one device before leaving it alone.
|
||||||
|
///
|
||||||
|
/// FR-1.11 in the small: giving up has to be terminal until the rider acts, and
|
||||||
|
/// an unbounded retry would quietly undo it — the trainer supervisor announces
|
||||||
|
/// that it has stopped trying, and two ticks later the device list starts the
|
||||||
|
/// whole thing again. Cleared the moment the link does come up, and by any
|
||||||
|
/// Connect the rider presses themselves, so the budget is per problem rather
|
||||||
|
/// than per lifetime.
|
||||||
|
const AUTO_ATTEMPTS: u32 = 5;
|
||||||
|
|
||||||
/// What to suggest when there is no adapter. The remedy is platform-specific
|
/// What to suggest when there is no adapter. The remedy is platform-specific
|
||||||
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
||||||
@@ -126,6 +145,14 @@ pub struct PollResult {
|
|||||||
pub hr_changed: Option<HeartRateStatus>,
|
pub hr_changed: Option<HeartRateStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-connect's memory of one address: when it last tried, and how many
|
||||||
|
/// times it has tried since the last time the link actually came up.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct AutoAttempts {
|
||||||
|
last: Instant,
|
||||||
|
tries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// What the scan task publishes.
|
/// What the scan task publishes.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ScanSnapshot {
|
pub struct ScanSnapshot {
|
||||||
@@ -146,8 +173,23 @@ pub struct DeviceRegistry {
|
|||||||
hr: HeartRateHandle,
|
hr: HeartRateHandle,
|
||||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||||
scan_on: watch::Sender<bool>,
|
scan_on: watch::Sender<bool>,
|
||||||
forgotten: HashSet<String>,
|
/// Everything the rider has paired with, and everything they have refused.
|
||||||
remembered: HashSet<String>,
|
/// Backed by a file, so it outlives the process (FR-1.5).
|
||||||
|
known: KnownDevices,
|
||||||
|
/// Links the rider closed by hand *this session*. Auto-connect must not
|
||||||
|
/// undo a deliberate disconnect two ticks later — that is not a disconnect,
|
||||||
|
/// it is a flicker. Deliberately not persisted: a fresh launch is a fresh
|
||||||
|
/// intention, and next time they start the app they do want their trainer.
|
||||||
|
auto_off: HashSet<String>,
|
||||||
|
/// What auto-connect has already tried, per address, so a device that
|
||||||
|
/// advertises but will not connect is retried on a schedule and then let
|
||||||
|
/// be (see [`AUTO_RETRY`], [`AUTO_ATTEMPTS`]).
|
||||||
|
auto: HashMap<String, AutoAttempts>,
|
||||||
|
/// Addresses the most recent scan pass actually saw. A remembered device is
|
||||||
|
/// only chased while it is advertising: a connect against a sleeping
|
||||||
|
/// peripheral burns the whole scan timeout for nothing, and the scan is
|
||||||
|
/// already telling us the moment it wakes (A-4).
|
||||||
|
seen_now: HashSet<String>,
|
||||||
/// The list published last tick, for change detection.
|
/// The list published last tick, for change detection.
|
||||||
published: Vec<DeviceInfo>,
|
published: Vec<DeviceInfo>,
|
||||||
last_trainer: TrainerStatus,
|
last_trainer: TrainerStatus,
|
||||||
@@ -173,8 +215,10 @@ impl DeviceRegistry {
|
|||||||
hr,
|
hr,
|
||||||
scan_rx,
|
scan_rx,
|
||||||
scan_on,
|
scan_on,
|
||||||
forgotten: HashSet::new(),
|
known: KnownDevices::default(),
|
||||||
remembered: HashSet::new(),
|
auto_off: HashSet::new(),
|
||||||
|
auto: HashMap::new(),
|
||||||
|
seen_now: HashSet::new(),
|
||||||
published: Vec::new(),
|
published: Vec::new(),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
scan_suspended: false,
|
scan_suspended: false,
|
||||||
@@ -182,6 +226,29 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load the remembered devices and tell the supervisors what we know
|
||||||
|
/// (FR-1.5).
|
||||||
|
///
|
||||||
|
/// Separate from `new` because `AppState::new` runs before Tauri can hand
|
||||||
|
/// out an `AppHandle`, and without one there is no data directory to read.
|
||||||
|
/// Until this is called the registry remembers nothing, which is the
|
||||||
|
/// correct behaviour for the handful of milliseconds it lasts.
|
||||||
|
pub fn attach_store(&mut self, path: PathBuf) {
|
||||||
|
self.known = KnownDevices::load(path);
|
||||||
|
// Hand the controller the pod we paired with last time. It does not
|
||||||
|
// connect anything — it is what lets the supervisor hold out for *our*
|
||||||
|
// − pod instead of grabbing the first Click that happens to wake up.
|
||||||
|
for kind in [DeviceKind::ClickMinus, DeviceKind::ClickPlus] {
|
||||||
|
if let (Some(pod), Some(known)) = (kind.pod_id(), self.known.first_of(kind)) {
|
||||||
|
self.controller.remember(pod, &known.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
count = self.known.len(),
|
||||||
|
"remembered devices loaded; they will reconnect as they advertise"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Rider-initiated. Cancels any suspension: an explicit request outranks
|
/// Rider-initiated. Cancels any suspension: an explicit request outranks
|
||||||
/// our own bookkeeping in both directions.
|
/// our own bookkeeping in both directions.
|
||||||
pub fn start_scan(&mut self) {
|
pub fn start_scan(&mut self) {
|
||||||
@@ -229,6 +296,10 @@ impl DeviceRegistry {
|
|||||||
let changed = next != self.published;
|
let changed = next != self.published;
|
||||||
self.published = next;
|
self.published = next;
|
||||||
|
|
||||||
|
// After publishing, never before: `connect` reads the published list,
|
||||||
|
// and a device discovered this tick has to be in it to be connectable.
|
||||||
|
self.auto_connect();
|
||||||
|
|
||||||
PollResult {
|
PollResult {
|
||||||
changed,
|
changed,
|
||||||
transitions,
|
transitions,
|
||||||
@@ -244,10 +315,12 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let controller = self.controller.status();
|
let controller = self.controller.status();
|
||||||
|
|
||||||
|
self.seen_now.clear();
|
||||||
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
||||||
for d in &snapshot.devices {
|
for d in &snapshot.devices {
|
||||||
let id = d.address.clone();
|
let id = d.address.clone();
|
||||||
if self.forgotten.contains(&id) {
|
self.seen_now.insert(id.clone());
|
||||||
|
if self.known.is_forgotten(&id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = classify(d);
|
let kind = classify(d);
|
||||||
@@ -257,8 +330,15 @@ impl DeviceRegistry {
|
|||||||
// is the pod the rider already told us about by pressing a button
|
// is the pod the rider already told us about by pressing a button
|
||||||
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
||||||
// it on purpose, in which case the supervisor ignores this.
|
// it on purpose, in which case the supervisor ignores this.
|
||||||
|
//
|
||||||
|
// Once we have paired with a pod, it is that pod we chase. Every
|
||||||
|
// Click advertises the same name and the same type byte, so without
|
||||||
|
// this a rider whose partner is warming up in the same room gets
|
||||||
|
// whichever pod woke first.
|
||||||
if let Some(pod) = kind.pod_id() {
|
if let Some(pod) = kind.pod_id() {
|
||||||
self.controller.pod_seen(pod, &id);
|
if is_ours(&self.known, kind, &id) {
|
||||||
|
self.controller.pod_seen(pod, &id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.push(DeviceInfo {
|
out.push(DeviceInfo {
|
||||||
kind,
|
kind,
|
||||||
@@ -272,7 +352,9 @@ impl DeviceRegistry {
|
|||||||
},
|
},
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
||||||
remembered: self.remembered.contains(&id),
|
// Filled in below, once every row exists: the pass that records
|
||||||
|
// a live link is the same pass that reads the store back.
|
||||||
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -296,7 +378,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -308,7 +390,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = DeviceKind::Trainer;
|
device.kind = DeviceKind::Trainer;
|
||||||
device.state = trainer.state.clone();
|
device.state = trainer.state.clone();
|
||||||
device.control_acquired = trainer.control_acquired;
|
device.control_acquired = trainer.control_acquired;
|
||||||
device.remembered = true;
|
|
||||||
device.error = trainer.error.clone().or_else(|| {
|
device.error = trainer.error.clone().or_else(|| {
|
||||||
trainer
|
trainer
|
||||||
.stale
|
.stale
|
||||||
@@ -329,7 +410,7 @@ impl DeviceRegistry {
|
|||||||
let Some(address) = pod.address.clone() else {
|
let Some(address) = pod.address.clone() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self.forgotten.contains(&address) {
|
if self.known.is_forgotten(&address) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = match id {
|
let kind = match id {
|
||||||
@@ -348,7 +429,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -360,7 +441,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = kind;
|
device.kind = kind;
|
||||||
device.battery_pct = pod.battery_percent;
|
device.battery_pct = pod.battery_percent;
|
||||||
device.error = pod.error.clone();
|
device.error = pod.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
device.state = match pod.state {
|
device.state = match pod.state {
|
||||||
PodState::Connected => ConnectionState::Connected,
|
PodState::Connected => ConnectionState::Connected,
|
||||||
PodState::Searching => ConnectionState::Connecting,
|
PodState::Searching => ConnectionState::Connecting,
|
||||||
@@ -380,7 +460,7 @@ impl DeviceRegistry {
|
|||||||
// link.
|
// link.
|
||||||
let hr = self.hr.status();
|
let hr = self.hr.status();
|
||||||
if let Some(address) = hr.address.clone() {
|
if let Some(address) = hr.address.clone() {
|
||||||
if !self.forgotten.contains(&address) {
|
if !self.known.is_forgotten(&address) {
|
||||||
let idx = match out.iter().position(|d| d.id == address) {
|
let idx = match out.iter().position(|d| d.id == address) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
@@ -396,7 +476,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(HEART_RATE_SERVICE)],
|
services: vec![describe_service(HEART_RATE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -409,7 +489,6 @@ impl DeviceRegistry {
|
|||||||
device.battery_pct = hr.battery_percent;
|
device.battery_pct = hr.battery_percent;
|
||||||
device.heart_rate_bpm = hr.bpm;
|
device.heart_rate_bpm = hr.bpm;
|
||||||
device.error = hr.error.clone();
|
device.error = hr.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
if let Some(name) = &hr.name {
|
if let Some(name) = &hr.name {
|
||||||
device.name = name.clone();
|
device.name = name.clone();
|
||||||
}
|
}
|
||||||
@@ -429,9 +508,95 @@ impl DeviceRegistry {
|
|||||||
.then(b.rssi.cmp(&a.rssi))
|
.then(b.rssi.cmp(&a.rssi))
|
||||||
.then(a.id.cmp(&b.id))
|
.then(a.id.cmp(&b.id))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A link that actually came up is what "paired" means (FR-1.5) — not a
|
||||||
|
// Connect the rider pressed against a trainer that then refused them.
|
||||||
|
// So the store is written from the finished list rather than from the
|
||||||
|
// request, and the row's `remembered` flag is read straight back out of
|
||||||
|
// it, which keeps the screen and the file incapable of disagreeing.
|
||||||
|
for device in &out {
|
||||||
|
if matches!(
|
||||||
|
device.state,
|
||||||
|
ConnectionState::Connected | ConnectionState::Controlling
|
||||||
|
) {
|
||||||
|
self.known
|
||||||
|
.remember(&device.address, Some(&device.name), device.kind);
|
||||||
|
// And a link that came up settles whatever auto-connect was
|
||||||
|
// struggling with, so the next problem starts from a full
|
||||||
|
// budget rather than inheriting the last one's.
|
||||||
|
self.auto.remove(&device.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for device in &mut out {
|
||||||
|
device.remembered = self.known.contains(&device.address);
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconnect to remembered hardware as it turns up (FR-1.5).
|
||||||
|
///
|
||||||
|
/// This is the whole feature from the rider's side: launch the app, get on
|
||||||
|
/// the bike, pedal, and the trainer and strap are simply there — no trip to
|
||||||
|
/// the device screen, no Connect button, no repairing what was paired last
|
||||||
|
/// week. Deliberately driven by the scan rather than fired once at startup,
|
||||||
|
/// because the hardware is asleep at startup: a trainer wakes when the
|
||||||
|
/// cranks turn and a strap when it is put on, and *that* is the moment
|
||||||
|
/// worth acting on (A-4, FR-1.8).
|
||||||
|
///
|
||||||
|
/// Click pods are absent here on purpose. They already have this, in the
|
||||||
|
/// shape of `pod_seen` above — a pod's advertising window is too short for
|
||||||
|
/// anything that waits for the list to be published.
|
||||||
|
fn auto_connect(&mut self) {
|
||||||
|
if !self.scanning {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let candidates: Vec<DeviceInfo> = self
|
||||||
|
.published
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.kind, DeviceKind::Trainer | DeviceKind::HeartRate))
|
||||||
|
// Advertising right now, so the connect has something to reach.
|
||||||
|
.filter(|d| self.seen_now.contains(&d.address))
|
||||||
|
.filter(|d| d.remembered && !self.auto_off.contains(&d.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for device in candidates {
|
||||||
|
// Whatever is holding the supervisor — a live link, a connect in
|
||||||
|
// flight, a reconnect the BLE layer is running — outranks this.
|
||||||
|
let busy = match device.kind {
|
||||||
|
DeviceKind::Trainer => self.trainer.status().is_attached(),
|
||||||
|
_ => self.hr.status().is_attached(),
|
||||||
|
};
|
||||||
|
if busy {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let previous = self.auto.get(&device.id).copied();
|
||||||
|
if !may_auto_connect(previous.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tries = previous.map_or(0, |a| a.tries);
|
||||||
|
self.auto.insert(
|
||||||
|
device.id.clone(),
|
||||||
|
AutoAttempts {
|
||||||
|
last: Instant::now(),
|
||||||
|
tries: tries + 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
name = %device.name,
|
||||||
|
address = %device.address,
|
||||||
|
attempt = tries + 1,
|
||||||
|
"reconnecting to a remembered device"
|
||||||
|
);
|
||||||
|
// Through the same path the rider's own click takes, so a connect
|
||||||
|
// started by the scan and one started by a finger cannot diverge —
|
||||||
|
// including suspending the scan for a trainer.
|
||||||
|
if let Err(e) = self.connect(&device.id) {
|
||||||
|
tracing::debug!(address = %device.address, reason = %e, "auto-connect declined");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<DeviceInfo> {
|
pub fn list(&self) -> Vec<DeviceInfo> {
|
||||||
self.published.clone()
|
self.published.clone()
|
||||||
}
|
}
|
||||||
@@ -449,13 +614,12 @@ impl DeviceRegistry {
|
|||||||
// controller supervisor rather than handled here, so the device list
|
// controller supervisor rather than handled here, so the device list
|
||||||
// and the connection screen drive the same one link per pod (FR-1.4).
|
// and the connection screen drive the same one link per pod (FR-1.4).
|
||||||
if let Some(pod) = device.kind.pod_id() {
|
if let Some(pod) = device.kind.pod_id() {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.controller.connect(pod, Some(device.address.clone()));
|
self.controller.connect(pod, Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,13 +627,12 @@ impl DeviceRegistry {
|
|||||||
// the heart rate supervisor so this list and whatever else drives the
|
// the heart rate supervisor so this list and whatever else drives the
|
||||||
// link stay one path.
|
// link stay one path.
|
||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.hr.connect(Some(device.address.clone()));
|
self.hr.connect(Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,8 +671,7 @@ impl DeviceRegistry {
|
|||||||
// soon as the trainer is no longer attached (FR-1.12).
|
// soon as the trainer is no longer attached (FR-1.12).
|
||||||
self.set_scanning(false);
|
self.set_scanning(false);
|
||||||
self.scan_suspended = true;
|
self.scan_suspended = true;
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.trainer
|
self.trainer
|
||||||
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
||||||
|
|
||||||
@@ -517,7 +679,7 @@ impl DeviceRegistry {
|
|||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.control_acquired = false;
|
info.control_acquired = false;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +687,11 @@ impl DeviceRegistry {
|
|||||||
let mut device = self
|
let mut device = self
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||||
|
// The device stays remembered — the rider closed this link, not the
|
||||||
|
// pairing — but auto-connect stops chasing it for the rest of the
|
||||||
|
// session. A "disconnect" that reconnects itself half a second later is
|
||||||
|
// not a disconnect (FR-1.5).
|
||||||
|
self.auto_off.insert(id.to_string());
|
||||||
if device.kind == DeviceKind::Trainer {
|
if device.kind == DeviceKind::Trainer {
|
||||||
// SAF-2 runs inside the supervisor before the link drops.
|
// SAF-2 runs inside the supervisor before the link drops.
|
||||||
self.trainer.disconnect();
|
self.trainer.disconnect();
|
||||||
@@ -550,12 +717,29 @@ impl DeviceRegistry {
|
|||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.hr.disconnect();
|
self.hr.disconnect();
|
||||||
}
|
}
|
||||||
self.remembered.remove(id);
|
self.known.forget(&device.address);
|
||||||
self.forgotten.insert(id.to_string());
|
self.auto_off.remove(id);
|
||||||
|
self.auto.remove(id);
|
||||||
self.published.retain(|d| d.id != id);
|
self.published.retain(|d| d.id != id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rider asked for this device: un-forget it and re-arm auto-connect.
|
||||||
|
///
|
||||||
|
/// It is deliberately *not* remembered here. A Connect that the hardware
|
||||||
|
/// then refuses is not a pairing, and writing one down would mean a trainer
|
||||||
|
/// the rider gave up on getting chased on every launch afterwards. The
|
||||||
|
/// store is written when the link actually comes up — see `build`.
|
||||||
|
///
|
||||||
|
/// A row's `id` *is* its address — see how every branch of `build`
|
||||||
|
/// constructs one — so the same string keys the store and the two
|
||||||
|
/// session-lifetime maps.
|
||||||
|
fn wanted(&mut self, address: &str) {
|
||||||
|
self.known.unforget(address);
|
||||||
|
self.auto_off.remove(address);
|
||||||
|
self.auto.remove(address);
|
||||||
|
}
|
||||||
|
|
||||||
/// The address of each Click pod the scanner has seen (FR-1.4).
|
/// The address of each Click pod the scanner has seen (FR-1.4).
|
||||||
///
|
///
|
||||||
/// Connecting by address is both faster and unambiguous: the pods share a
|
/// Connecting by address is both faster and unambiguous: the pods share a
|
||||||
@@ -616,6 +800,33 @@ fn describe_service(uuid: Uuid) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May auto-connect try this address now?
|
||||||
|
///
|
||||||
|
/// Two brakes, and they answer different questions. [`AUTO_RETRY`] is about
|
||||||
|
/// *rate*: a device that advertises and then refuses must not be hammered four
|
||||||
|
/// times a second. [`AUTO_ATTEMPTS`] is about *ending*: an app that never stops
|
||||||
|
/// trying can never honestly tell the rider it has stopped (FR-1.11).
|
||||||
|
fn may_auto_connect(previous: Option<&AutoAttempts>) -> bool {
|
||||||
|
match previous {
|
||||||
|
None => true,
|
||||||
|
Some(a) => a.tries < AUTO_ATTEMPTS && a.last.elapsed() >= AUTO_RETRY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this the device we paired with, or is the slot simply still empty?
|
||||||
|
///
|
||||||
|
/// With nothing of this kind remembered, anything will do — that is a first
|
||||||
|
/// pairing, and refusing it would mean the rider could never make one. Once
|
||||||
|
/// something *is* remembered, only that peripheral is chased automatically; a
|
||||||
|
/// replacement is adopted the moment the rider connects it by hand, which is
|
||||||
|
/// what the Connect button on the row is for.
|
||||||
|
///
|
||||||
|
/// A free function rather than a method so the rule can be checked without a
|
||||||
|
/// radio, two supervisors and a Tokio runtime.
|
||||||
|
fn is_ours(known: &KnownDevices, kind: DeviceKind, address: &str) -> bool {
|
||||||
|
known.contains(address) || !known.any_of_kind(kind)
|
||||||
|
}
|
||||||
|
|
||||||
/// May a scan we suspended for a connect be switched back on?
|
/// May a scan we suspended for a connect be switched back on?
|
||||||
///
|
///
|
||||||
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
|
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
|
||||||
@@ -819,6 +1030,79 @@ mod tests {
|
|||||||
assert!(!should_resume_scan(false, &idle));
|
assert!(!should_resume_scan(false, &idle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_connect_paces_itself_and_eventually_stops() {
|
||||||
|
// Never tried: go.
|
||||||
|
assert!(may_auto_connect(None));
|
||||||
|
|
||||||
|
// Just tried. A trainer that advertises and then refuses would
|
||||||
|
// otherwise be retried on every device tick, four times a second, each
|
||||||
|
// attempt fighting the scan for the one adapter.
|
||||||
|
let just_now = AutoAttempts {
|
||||||
|
last: Instant::now(),
|
||||||
|
tries: 1,
|
||||||
|
};
|
||||||
|
assert!(!may_auto_connect(Some(&just_now)));
|
||||||
|
|
||||||
|
// Long enough ago, and budget left.
|
||||||
|
let stale = AutoAttempts {
|
||||||
|
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
|
||||||
|
tries: 1,
|
||||||
|
};
|
||||||
|
assert!(may_auto_connect(Some(&stale)));
|
||||||
|
|
||||||
|
// Out of budget. FR-1.11: the app has to be able to stop, or "gave up"
|
||||||
|
// is a message it contradicts two ticks later. The rider's own Connect
|
||||||
|
// clears this — see `wanted`.
|
||||||
|
let spent = AutoAttempts {
|
||||||
|
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
|
||||||
|
tries: AUTO_ATTEMPTS,
|
||||||
|
};
|
||||||
|
assert!(!may_auto_connect(Some(&spent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_pod_will_do_until_one_has_been_paired_with() {
|
||||||
|
// FR-1.5. Every Click advertises the same name and the same type byte,
|
||||||
|
// so "a − pod is advertising" is not the same question as "*our* − pod
|
||||||
|
// is advertising" the moment there is more than one in the room.
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
|
||||||
|
|
||||||
|
known.remember(
|
||||||
|
"AA:BB:CC:DD:EE:01",
|
||||||
|
Some("Zwift Click"),
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
);
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "aa:bb:cc:dd:ee:01"));
|
||||||
|
// The one on the next bike.
|
||||||
|
assert!(!is_ours(
|
||||||
|
&known,
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
"AA:BB:CC:DD:EE:02"
|
||||||
|
));
|
||||||
|
// A kind we have never paired with is still wide open.
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickPlus, "AA:BB:CC:DD:EE:03"));
|
||||||
|
assert!(is_ours(&known, DeviceKind::Trainer, "AA:BB:CC:DD:EE:04"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_replacement_is_adopted_once_the_rider_connects_it() {
|
||||||
|
// The pod broke and a new one arrived. Auto-connect ignores it — it is
|
||||||
|
// not the one we know — but the rider's own Connect pairs it, and from
|
||||||
|
// then on it is chased like the old one.
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.remember("AA:BB:CC:DD:EE:01", None, DeviceKind::ClickMinus);
|
||||||
|
assert!(!is_ours(
|
||||||
|
&known,
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
"AA:BB:CC:DD:EE:99"
|
||||||
|
));
|
||||||
|
known.remember("AA:BB:CC:DD:EE:99", None, DeviceKind::ClickMinus);
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:99"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn well_known_services_are_named_and_zwift_is_recognised() {
|
fn well_known_services_are_named_and_zwift_is_recognised() {
|
||||||
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! Devices the rider has paired before, remembered across launches (FR-1.5).
|
||||||
|
//!
|
||||||
|
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
|
||||||
|
//! which meant it lasted exactly as long as the process. Every launch started
|
||||||
|
//! from nothing: find the trainer, press Connect, find the strap, press
|
||||||
|
//! Connect, and only then ride. This is that set written down.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! app_data_dir()/devices.json
|
||||||
|
//! { "version": 1,
|
||||||
|
//! "devices": [ { address, name, kind }, … ],
|
||||||
|
//! "forgotten": [ address, … ] }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Two lists rather than one, because *forgotten* is not merely "absent".
|
||||||
|
//! Absent means never seen; forgotten means the rider looked at this device and
|
||||||
|
//! said no, and auto-connect has to keep honouring that on the next launch too.
|
||||||
|
//!
|
||||||
|
//! The file is small and written only when something actually changes — a pair,
|
||||||
|
//! an unpair, a name learned — so this never lands in the ride loop's path. It
|
||||||
|
//! is also *advisory*: a corrupt or unreadable file costs the rider their
|
||||||
|
//! auto-connect, never their ride, so every failure here is logged and
|
||||||
|
//! swallowed rather than propagated.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::devices::DeviceKind;
|
||||||
|
|
||||||
|
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||||
|
/// we do not know is discarded rather than guessed at.
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
const FILE: &str = "devices.json";
|
||||||
|
|
||||||
|
/// One device the rider has paired with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KnownDevice {
|
||||||
|
/// As the adapter reports it. Matching is case-insensitive — see [`key`] —
|
||||||
|
/// but what is written down is what we were told.
|
||||||
|
pub address: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub kind: DeviceKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file on disk. Kept separate from the in-memory form so the indexes below
|
||||||
|
/// are never serialised.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
struct Stored {
|
||||||
|
version: u32,
|
||||||
|
devices: Vec<KnownDevice>,
|
||||||
|
forgotten: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
|
||||||
|
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
|
||||||
|
/// but not a platform is not much of a pairing.
|
||||||
|
fn key(address: &str) -> String {
|
||||||
|
address.trim().to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the app remembers about the hardware in the room, plus where to
|
||||||
|
/// write it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct KnownDevices {
|
||||||
|
devices: BTreeMap<String, KnownDevice>,
|
||||||
|
forgotten: BTreeSet<String>,
|
||||||
|
/// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
|
||||||
|
/// is an `AppHandle` to ask for a data directory, so the registry spends
|
||||||
|
/// the first moments of the process with an in-memory-only store.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KnownDevices {
|
||||||
|
/// Read the file, or start empty if it is missing, unreadable or from a
|
||||||
|
/// version we do not understand.
|
||||||
|
pub fn load(path: PathBuf) -> Self {
|
||||||
|
let mut out = Self {
|
||||||
|
path: Some(path.clone()),
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
let text = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stored: Stored = match serde_json::from_str(&text) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if stored.version != VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
found = stored.version,
|
||||||
|
expected = VERSION,
|
||||||
|
"remembered devices are from another version; starting fresh"
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for device in stored.devices {
|
||||||
|
out.devices.insert(key(&device.address), device);
|
||||||
|
}
|
||||||
|
out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.devices.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.devices.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has this device been paired with before?
|
||||||
|
pub fn contains(&self, address: &str) -> bool {
|
||||||
|
self.devices.contains_key(&key(address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the rider say no to this device?
|
||||||
|
pub fn is_forgotten(&self, address: &str) -> bool {
|
||||||
|
self.forgotten.contains(&key(address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remembered device of this kind, if there is one. Used to prefer
|
||||||
|
/// *our* Click over the identically-named one in the next room.
|
||||||
|
pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
|
||||||
|
self.devices.values().find(|d| d.kind == kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
|
||||||
|
self.first_of(kind).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a pairing. No-op — and no write — when nothing changed, which is
|
||||||
|
/// the common case: this is called from the device poll, four times a
|
||||||
|
/// second.
|
||||||
|
pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
|
||||||
|
// Nothing useful to auto-connect to, and a list full of every anonymous
|
||||||
|
// peripheral in the building helps nobody.
|
||||||
|
if kind == DeviceKind::Unknown || address.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let id = key(address);
|
||||||
|
let entry = KnownDevice {
|
||||||
|
address: address.to_string(),
|
||||||
|
name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
let unchanged = self.devices.get(&id) == Some(&entry);
|
||||||
|
let was_forgotten = self.forgotten.remove(&id);
|
||||||
|
if unchanged && !was_forgotten {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(address, name, ?kind, "remembering device");
|
||||||
|
self.devices.insert(id, entry);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rider said no. Both halves matter: drop the pairing *and* record the
|
||||||
|
/// refusal, so the next launch does not helpfully connect it again.
|
||||||
|
pub fn forget(&mut self, address: &str) {
|
||||||
|
let id = key(address);
|
||||||
|
let removed = self.devices.remove(&id).is_some();
|
||||||
|
let added = self.forgotten.insert(id);
|
||||||
|
if removed || added {
|
||||||
|
tracing::info!(address, "forgetting device");
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit connect outranks an earlier refusal.
|
||||||
|
pub fn unforget(&mut self, address: &str) {
|
||||||
|
if self.forgotten.remove(&key(address)) {
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the file, atomically: a half-written `devices.json` would be
|
||||||
|
/// discarded whole on the next launch, and losing the pairings because the
|
||||||
|
/// power went out mid-`write` is exactly the failure this module exists to
|
||||||
|
/// prevent.
|
||||||
|
fn save(&self) {
|
||||||
|
let Some(path) = &self.path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
devices: self.devices.values().cloned().collect(),
|
||||||
|
forgotten: self.forgotten.iter().cloned().collect(),
|
||||||
|
};
|
||||||
|
if let Err(e) = write_atomic(path, &stored) {
|
||||||
|
// Advisory, not fatal: the rider loses auto-connect on the next
|
||||||
|
// launch, never this ride.
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||||
|
let text = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the remembered devices live: beside the recorded rides, in the app's
|
||||||
|
/// own data directory.
|
||||||
|
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||||
|
Ok(dir.join(FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"bikecontrol-known-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir.join(FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pairing_survives_a_reload() {
|
||||||
|
let path = temp();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut known = KnownDevices::load(path.clone());
|
||||||
|
known.remember(
|
||||||
|
"F4:C4:59:03:A1:8E",
|
||||||
|
Some("Zwift Click"),
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
);
|
||||||
|
|
||||||
|
let again = KnownDevices::load(path);
|
||||||
|
assert!(again.contains("F4:C4:59:03:A1:8E"));
|
||||||
|
// The same peripheral, as Android spells it.
|
||||||
|
assert!(again.contains("f4:c4:59:03:a1:8e"));
|
||||||
|
assert_eq!(
|
||||||
|
again
|
||||||
|
.first_of(DeviceKind::ClickMinus)
|
||||||
|
.unwrap()
|
||||||
|
.name
|
||||||
|
.as_deref(),
|
||||||
|
Some("Zwift Click")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forgetting_outlives_the_process_too() {
|
||||||
|
// The whole point: "no" has to be remembered as firmly as "yes", or the
|
||||||
|
// next launch connects the neighbour's trainer again.
|
||||||
|
let path = temp().with_extension("forget.json");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut known = KnownDevices::load(path.clone());
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
|
||||||
|
known.forget("aa:bb:cc:dd:ee:ff");
|
||||||
|
|
||||||
|
let again = KnownDevices::load(path);
|
||||||
|
assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(!again.any_of_kind(DeviceKind::Trainer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connecting_again_undoes_a_refusal() {
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.forget("AA:BB:CC:DD:EE:FF");
|
||||||
|
assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
|
||||||
|
assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(known.contains("AA:BB:CC:DD:EE:FF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unidentified_peripheral_is_not_worth_remembering() {
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
|
||||||
|
assert!(known.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
|
||||||
|
let path = temp().with_extension("corrupt.json");
|
||||||
|
std::fs::write(&path, b"{ this is not json").unwrap();
|
||||||
|
let known = KnownDevices::load(path);
|
||||||
|
assert!(known.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ pub mod derive;
|
|||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod heart_rate;
|
pub mod heart_rate;
|
||||||
|
pub mod known;
|
||||||
|
pub mod settings;
|
||||||
pub mod profile_view;
|
pub mod profile_view;
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
pub mod samples;
|
pub mod samples;
|
||||||
@@ -66,6 +68,7 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// ride lifecycle
|
// ride lifecycle
|
||||||
@@ -96,6 +99,8 @@ pub fn run() {
|
|||||||
commands::set_rider_config,
|
commands::set_rider_config,
|
||||||
commands::safety_limits,
|
commands::safety_limits,
|
||||||
commands::set_safety_limits,
|
commands::set_safety_limits,
|
||||||
|
commands::preferences,
|
||||||
|
commands::set_preferences,
|
||||||
// profiles
|
// profiles
|
||||||
commands::load_profile_from_path,
|
commands::load_profile_from_path,
|
||||||
commands::load_profile_from_text,
|
commands::load_profile_from_text,
|
||||||
@@ -119,6 +124,29 @@ pub fn run() {
|
|||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
// FR-1.5: the hardware the rider paired with last time, before the
|
||||||
|
// first scan pass so the very first thing the scanner sees can be
|
||||||
|
// reconnected rather than merely listed. A missing data directory
|
||||||
|
// costs auto-connect and nothing else, so it is a warning, not a
|
||||||
|
// failed launch.
|
||||||
|
match known::store_path(&handle) {
|
||||||
|
Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"),
|
||||||
|
}
|
||||||
|
// FR-7.4: the rider's mass, bike and drag, before the first tick
|
||||||
|
// reads them. Restored ahead of the ride loop starting so no
|
||||||
|
// snapshot is ever computed against the 105 kg default the struct
|
||||||
|
// falls back to.
|
||||||
|
match settings::store_path(&handle) {
|
||||||
|
Ok(path) => {
|
||||||
|
let state = handle.state::<AppState>();
|
||||||
|
let mut inner = state.lock();
|
||||||
|
let inner = &mut *inner;
|
||||||
|
let (rider, limits) = (&mut inner.inputs.rider, &mut inner.inputs.limits);
|
||||||
|
inner.settings.attach(path, rider, limits);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "rider settings unavailable"),
|
||||||
|
}
|
||||||
// NFR-7: scanning starts immediately, not on a user click.
|
// NFR-7: scanning starts immediately, not on a user click.
|
||||||
handle.state::<AppState>().lock().devices.start_scan();
|
handle.state::<AppState>().lock().devices.start_scan();
|
||||||
state::spawn_ride_loop(handle.clone());
|
state::spawn_ride_loop(handle.clone());
|
||||||
|
|||||||
@@ -333,13 +333,17 @@ fn block_label(block: &Block) -> String {
|
|||||||
amplitude,
|
amplitude,
|
||||||
repeats
|
repeats
|
||||||
),
|
),
|
||||||
|
// No distance in the label. The block already carries `start_x` and
|
||||||
|
// `end_x`, and the frontend renders that span in the rider's own units
|
||||||
|
// (FR-7.5a) — a kilometre baked into the text here would sit inside a
|
||||||
|
// sentence that says miles everywhere else.
|
||||||
Block::Segments { segments } => {
|
Block::Segments { segments } => {
|
||||||
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
|
format!(
|
||||||
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
|
"{} segment{}",
|
||||||
}
|
segments.len(),
|
||||||
Block::Terrain { points } => {
|
if segments.len() == 1 { "" } else { "s" }
|
||||||
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
|
)
|
||||||
format!("terrain · {:.1} km", d / 1000.0)
|
|
||||||
}
|
}
|
||||||
|
Block::Terrain { .. } => "terrain".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Rider setup, remembered across launches (FR-7.4).
|
||||||
|
//!
|
||||||
|
//! Two things were wrong before this module existed, and they were the same
|
||||||
|
//! thing twice.
|
||||||
|
//!
|
||||||
|
//! `RiderConfig` lived only in [`crate::backend::RideInputs`], which meant it
|
||||||
|
//! lived exactly as long as the process — and nothing in the UI ever called
|
||||||
|
//! `set_rider_config`, so in practice every ride was ridden as a **105 kg
|
||||||
|
//! rider on an 8 kg bike**, the struct's own defaults. Mass is not a cosmetic
|
||||||
|
//! setting: it sets the speed a given power produces, the ETA that follows from
|
||||||
|
//! it, the calorie estimate, and how a 6% ramp feels. A 62 kg rider was being
|
||||||
|
//! shown somebody else's ride.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! app_data_dir()/settings.json
|
||||||
|
//! { "version": 1, "rider": {…}, "limits": {…}, "prefs": {…} }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Written whole on every change — it is three small structs, and settings are
|
||||||
|
//! changed by hand at human speed, so this never lands in the ride loop's path.
|
||||||
|
//! Like [`crate::known`] it is *advisory*: an unreadable file costs the rider
|
||||||
|
//! their setup, never their ride, so every failure is logged and swallowed.
|
||||||
|
//!
|
||||||
|
//! ## Why `prefs` is here and not in `RiderConfig`
|
||||||
|
//!
|
||||||
|
//! FTP, maximum heart rate and the unit system change nothing about the
|
||||||
|
//! physics — they decide how a number is *drawn*. `bikecontrol_core::types` is
|
||||||
|
//! the frozen contract the engine and the FIT writer share, and a display
|
||||||
|
//! preference has no business in it. The file keeps them side by side because
|
||||||
|
//! that is where the rider expects to find them; the types stay apart.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bikecontrol_core::types::{RiderConfig, SafetyLimits};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||||
|
/// we do not know is discarded rather than guessed at.
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
const FILE: &str = "settings.json";
|
||||||
|
|
||||||
|
/// Which units the rider reads. Everything is *stored* and *recorded* in SI
|
||||||
|
/// regardless — this is the last conversion before the glass, so a FIT file
|
||||||
|
/// never depends on what the screen was set to.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Units {
|
||||||
|
#[default]
|
||||||
|
Metric,
|
||||||
|
Imperial,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display preferences. Not physics — see the module note.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Preferences {
|
||||||
|
/// Functional threshold power, watts. The reference every power zone is a
|
||||||
|
/// fraction of; zero means the rider has not set one and zones are not
|
||||||
|
/// drawn at all, which is honest — a zone against a guessed FTP is worse
|
||||||
|
/// than no zone.
|
||||||
|
pub ftp_w: u16,
|
||||||
|
/// Maximum heart rate, bpm. Same contract: zero means no HR zones.
|
||||||
|
pub max_hr_bpm: u16,
|
||||||
|
pub units: Units,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Preferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Deliberately no default FTP: an invented threshold would colour
|
||||||
|
// every ride wrong and look authoritative doing it.
|
||||||
|
ftp_w: 0,
|
||||||
|
max_hr_bpm: 0,
|
||||||
|
units: Units::Metric,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file on disk.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
struct Stored {
|
||||||
|
version: u32,
|
||||||
|
rider: RiderConfig,
|
||||||
|
limits: SafetyLimits,
|
||||||
|
prefs: Preferences,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rider's setup, plus where to write it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub prefs: Preferences,
|
||||||
|
/// `None` before [`Settings::attach`] — `AppState::new` runs before there
|
||||||
|
/// is an `AppHandle` to ask for a data directory, so the first moments of
|
||||||
|
/// the process are in-memory only.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Point at the file and read it back over the defaults already in
|
||||||
|
/// `rider` and `limits`.
|
||||||
|
///
|
||||||
|
/// Applied by `&mut` rather than returned because a partial application is
|
||||||
|
/// the one outcome that must not be possible: the rider's mass and the
|
||||||
|
/// safety clamps that bound what can be sent to the trainer come from the
|
||||||
|
/// same file and are adopted in the same breath.
|
||||||
|
pub fn attach(&mut self, path: PathBuf, rider: &mut RiderConfig, limits: &mut SafetyLimits) {
|
||||||
|
self.path = Some(path.clone());
|
||||||
|
let text = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not read settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stored: Stored = match serde_json::from_str(&text) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "settings are unreadable; using defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if stored.version != VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
found = stored.version,
|
||||||
|
expected = VERSION,
|
||||||
|
"settings are from another version; using defaults"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A stored file that fails the same checks the commands apply is not
|
||||||
|
// trusted just because it is on disk — it may predate a tightened
|
||||||
|
// bound, or have been edited by hand.
|
||||||
|
if validate_rider(&stored.rider).is_err() || validate_limits(&stored.limits).is_err() {
|
||||||
|
tracing::warn!("stored settings are out of range; using defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*rider = stored.rider;
|
||||||
|
*limits = stored.limits;
|
||||||
|
self.prefs = stored.prefs;
|
||||||
|
tracing::info!(rider_kg = stored.rider.rider_kg, "settings restored");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the file, atomically. A half-written `settings.json` is discarded
|
||||||
|
/// whole on the next launch, which would silently put the rider back on a
|
||||||
|
/// 105 kg default — the exact failure this module exists to prevent.
|
||||||
|
pub fn save(&self, rider: &RiderConfig, limits: &SafetyLimits) {
|
||||||
|
let Some(path) = &self.path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
rider: *rider,
|
||||||
|
limits: *limits,
|
||||||
|
prefs: self.prefs,
|
||||||
|
};
|
||||||
|
if let Err(e) = write_atomic(path, &stored) {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not save settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds worth refusing, with the reason a rider can act on.
|
||||||
|
///
|
||||||
|
/// These are not taste. Each one is a value that makes the ride engine produce
|
||||||
|
/// nonsense rather than merely something unusual — a zero mass divides, a zero
|
||||||
|
/// wheel circumference divides, an efficiency above 1 invents power.
|
||||||
|
pub fn validate_rider(c: &RiderConfig) -> Result<(), String> {
|
||||||
|
let check = |ok: bool, msg: &str| if ok { Ok(()) } else { Err(msg.to_string()) };
|
||||||
|
check(
|
||||||
|
(20.0..=250.0).contains(&c.rider_kg),
|
||||||
|
"Rider mass must be between 20 and 250 kg.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(1.0..=50.0).contains(&c.bike_kg),
|
||||||
|
"Bike mass must be between 1 and 50 kg.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.0005..=0.05).contains(&c.crr),
|
||||||
|
"Rolling resistance is typically 0.002–0.010 for road tyres.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.1..=1.5).contains(&c.cda),
|
||||||
|
"CdA must be between 0.1 and 1.5 m² — a road position is about 0.32.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=1.0).contains(&c.drivetrain_efficiency),
|
||||||
|
"Drivetrain efficiency is a fraction between 0.5 and 1.0 — about 0.97 for a clean chain.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=1.6).contains(&c.air_density),
|
||||||
|
"Air density must be between 0.5 and 1.6 kg/m³ — sea level is 1.225.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=3.5).contains(&c.wheel_circumference_m),
|
||||||
|
"Wheel circumference must be between 0.5 and 3.5 m — a 700×25 is about 2.1.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.1..=0.25).contains(&c.crank_length_m),
|
||||||
|
"Crank length must be between 0.10 and 0.25 m — road cranks are 0.170–0.175.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=20.0).contains(&c.physical_development_m),
|
||||||
|
"Physical development must be between 0.5 and 20 m per crank revolution.",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_limits(l: &SafetyLimits) -> Result<(), String> {
|
||||||
|
if l.min_gradient_pct >= l.max_gradient_pct {
|
||||||
|
return Err("Gradient limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.min_resistance >= l.max_resistance {
|
||||||
|
return Err("Resistance limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.min_power_w >= l.max_power_w {
|
||||||
|
return Err("Power limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.max_power_w > 2000 {
|
||||||
|
return Err("Maximum power above 2000 W is not a limit, it is a hazard.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_prefs(p: &Preferences) -> Result<(), String> {
|
||||||
|
// Zero is the "not set" case for both, and must stay reachable: a rider who
|
||||||
|
// does not know their FTP is better served by no zones than by a guess.
|
||||||
|
if p.ftp_w != 0 && !(50..=600).contains(&p.ftp_w) {
|
||||||
|
return Err("FTP must be between 50 and 600 W, or 0 for no zones.".into());
|
||||||
|
}
|
||||||
|
if p.max_hr_bpm != 0 && !(100..=230).contains(&p.max_hr_bpm) {
|
||||||
|
return Err("Maximum heart rate must be between 100 and 230 bpm, or 0 for no zones.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||||
|
let text = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beside the remembered devices and the recorded rides.
|
||||||
|
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||||
|
Ok(dir.join(FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp(name: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("bikecontrol-settings-test-{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rider_survives_the_process() {
|
||||||
|
let path = temp("roundtrip");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||||
|
rider.rider_kg = 62.0;
|
||||||
|
settings.prefs.ftp_w = 240;
|
||||||
|
settings.save(&rider, &limits);
|
||||||
|
|
||||||
|
let mut again = Settings::default();
|
||||||
|
let mut rider2 = RiderConfig::default();
|
||||||
|
let mut limits2 = SafetyLimits::default();
|
||||||
|
again.attach(path.clone(), &mut rider2, &mut limits2);
|
||||||
|
assert_eq!(rider2.rider_kg, 62.0);
|
||||||
|
assert_eq!(again.prefs.ftp_w, 240);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_leaves_the_defaults_alone() {
|
||||||
|
let path = temp("missing");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path, &mut rider, &mut limits);
|
||||||
|
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||||
|
assert_eq!(settings.prefs, Preferences::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file someone edited by hand must not be able to put a zero mass into
|
||||||
|
/// the engine, which would divide by it on the next tick.
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_file_is_refused_whole() {
|
||||||
|
let path = temp("nonsense");
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
rider: RiderConfig {
|
||||||
|
rider_kg: 0.0,
|
||||||
|
..RiderConfig::default()
|
||||||
|
},
|
||||||
|
limits: SafetyLimits::default(),
|
||||||
|
prefs: Preferences {
|
||||||
|
ftp_w: 300,
|
||||||
|
..Preferences::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_atomic(&path, &stored).unwrap();
|
||||||
|
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||||
|
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||||
|
// Refused whole: the preferences in the same file do not sneak through.
|
||||||
|
assert_eq!(settings.prefs.ftp_w, 0);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_means_no_zones_rather_than_an_invalid_ftp() {
|
||||||
|
assert!(validate_prefs(&Preferences::default()).is_ok());
|
||||||
|
assert!(validate_prefs(&Preferences {
|
||||||
|
ftp_w: 20,
|
||||||
|
..Preferences::default()
|
||||||
|
})
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ pub struct Inner {
|
|||||||
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
||||||
/// the webview asks for them.
|
/// the webview asks for them.
|
||||||
pub recovered: Vec<Recovered>,
|
pub recovered: Vec<Recovered>,
|
||||||
|
/// The rider's own setup, and where it is written down (FR-7.4).
|
||||||
|
pub settings: crate::settings::Settings,
|
||||||
lap_start_ms: u64,
|
lap_start_ms: u64,
|
||||||
lap_start_m: f64,
|
lap_start_m: f64,
|
||||||
lap_power_sum: f64,
|
lap_power_sum: f64,
|
||||||
@@ -104,6 +106,7 @@ impl Inner {
|
|||||||
laps: Vec::new(),
|
laps: Vec::new(),
|
||||||
last_summary: None,
|
last_summary: None,
|
||||||
recovered: Vec::new(),
|
recovered: Vec::new(),
|
||||||
|
settings: crate::settings::Settings::default(),
|
||||||
lap_start_ms: 0,
|
lap_start_ms: 0,
|
||||||
lap_start_m: 0.0,
|
lap_start_m: 0.0,
|
||||||
lap_power_sum: 0.0,
|
lap_power_sum: 0.0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "BikeControl",
|
"productName": "BikeControl",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "paris.tourolle.bikecontrol",
|
"identifier": "paris.tourolle.bikecontrol",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui/dist",
|
"frontendDist": "../ui/dist",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"android": {
|
"android": {
|
||||||
"versionCode": 1100
|
"versionCode": 1200
|
||||||
},
|
},
|
||||||
"shortDescription": "Indoor cycling trainer control",
|
"shortDescription": "Indoor cycling trainer control",
|
||||||
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import HelpOverlay from './components/HelpOverlay.svelte';
|
import HelpOverlay from './components/HelpOverlay.svelte';
|
||||||
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
||||||
import RideScreen from './components/RideScreen.svelte';
|
import RideScreen from './components/RideScreen.svelte';
|
||||||
|
import SettingsScreen from './components/SettingsScreen.svelte';
|
||||||
import SummaryScreen from './components/SummaryScreen.svelte';
|
import SummaryScreen from './components/SummaryScreen.svelte';
|
||||||
import Toasts from './components/Toasts.svelte';
|
import Toasts from './components/Toasts.svelte';
|
||||||
|
|
||||||
@@ -39,6 +40,23 @@
|
|||||||
app.run(fn);
|
app.run(fn);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup is reachable from anywhere, on the key every other application
|
||||||
|
// uses for it, and returns to whichever screen it was opened from.
|
||||||
|
if (e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
app.openSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// On setup, the ride controls are inert — a stray arrow key while reading
|
||||||
|
// the form must not trim the gradient of a ride happening behind it.
|
||||||
|
if (app.screen === 'settings') {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
app.closeSettings();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The summary screen has its own two keys, and swallows the ride controls:
|
// The summary screen has its own two keys, and swallows the ride controls:
|
||||||
// nudging the gradient of a ride that has ended is meaningless, and space
|
// nudging the gradient of a ride that has ended is meaningless, and space
|
||||||
// would silently start a new one out from under the summary.
|
// would silently start a new one out from under the summary.
|
||||||
@@ -151,6 +169,13 @@
|
|||||||
if (!input.pressed) return;
|
if (!input.pressed) return;
|
||||||
const run = (fn: () => Promise<unknown>) => app.run(fn);
|
const run = (fn: () => Promise<unknown>) => app.run(fn);
|
||||||
|
|
||||||
|
// Same rule as the keyboard: setup swallows the ride controls, and the
|
||||||
|
// pod's left button is the way back out of it.
|
||||||
|
if (app.screen === 'settings') {
|
||||||
|
if (input.button === 'left') app.closeSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// As with the keyboard: on the summary the ride controls are inert, and the
|
// As with the keyboard: on the summary the ride controls are inert, and the
|
||||||
// face buttons carry that screen's own two actions instead. Leaving `a` on
|
// face buttons carry that screen's own two actions instead. Leaving `a` on
|
||||||
// toggle-pause here would restart the ride the rider just finished.
|
// toggle-pause here would restart the ride the rider just finished.
|
||||||
@@ -211,6 +236,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if !ready}
|
{:else if !ready}
|
||||||
<div class="boot"><span class="label">Starting…</span></div>
|
<div class="boot"><span class="label">Starting…</span></div>
|
||||||
|
{:else if app.screen === 'settings'}
|
||||||
|
<SettingsScreen />
|
||||||
{:else if app.screen === 'summary'}
|
{:else if app.screen === 'summary'}
|
||||||
<SummaryScreen />
|
<SummaryScreen />
|
||||||
{:else if app.screen === 'ride'}
|
{:else if app.screen === 'ride'}
|
||||||
|
|||||||
@@ -39,6 +39,25 @@
|
|||||||
--power: #dfe8f3;
|
--power: #dfe8f3;
|
||||||
--power-raw: #3f4d5d;
|
--power-raw: #3f4d5d;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Effort zones (see `powerZone` in lib/format.ts). An ordered ramp, not a
|
||||||
|
* categorical palette: cool and quiet at the bottom, hot and loud at the top,
|
||||||
|
* so intensity reads from the colour before the number is even focused on.
|
||||||
|
* Four of the seven are the tones this stylesheet already uses for ok / warn
|
||||||
|
* / climb / bad, which keeps one vocabulary on the screen rather than two.
|
||||||
|
*
|
||||||
|
* Colour never carries the meaning alone — every zoned readout renders "Z4"
|
||||||
|
* beside the swatch. That is what makes this safe for the ~8% of male riders
|
||||||
|
* with a colour vision deficiency, and for a phone in direct sun.
|
||||||
|
*/
|
||||||
|
--zone-1: #7b8b9c;
|
||||||
|
--zone-2: #4aa8ff;
|
||||||
|
--zone-3: #35d9a0;
|
||||||
|
--zone-4: #ffcf4a;
|
||||||
|
--zone-5: #ff9a3c;
|
||||||
|
--zone-6: #ff5a52;
|
||||||
|
--zone-7: #c07cff;
|
||||||
|
|
||||||
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
||||||
--gap: 24px;
|
--gap: 24px;
|
||||||
--edge: 44px;
|
--edge: 44px;
|
||||||
|
|||||||
+214
-237
@@ -1,27 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* The Zwift Click, as two pods (FR-1.4, FR-9.1–9.2).
|
* The Zwift Click (FR-1.4, FR-9.1–9.2).
|
||||||
*
|
*
|
||||||
* A Click v2 is **two peripherals**, and until now the app showed one line
|
* A Click v2 is two peripherals, but it is **one controller**: connected on
|
||||||
* for both: connect, and you got whichever pod answered first, with no way to
|
* its own, the `−` pod delivers all ten buttons — its own paddle and D-pad,
|
||||||
* tell which one that was or that the other was missing entirely. Each pod
|
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
|
||||||
* now has a card of its own — its own state, battery, address and proof that
|
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
|
||||||
* its buttons arrive.
|
* this panel says so rather than presenting two halves that both look
|
||||||
|
* required. Pairing both is not merely redundant: it is the configuration in
|
||||||
|
* which the `−` pod stops reporting its own paddle.
|
||||||
|
*
|
||||||
|
* The `+` pod keeps a row of its own, because it is the fallback that matters
|
||||||
|
* when the `−` pod is flat or left in the garage.
|
||||||
*
|
*
|
||||||
* They are named for the shift paddle each carries, not for the side of the
|
* They are named for the shift paddle each carries, not for the side of the
|
||||||
* bar. Nothing a pod advertises says which end of the handlebar it is
|
* bar. Nothing a pod advertises says which end of the handlebar it is clamped
|
||||||
* clamped to, so left and right would be a guess; the paddle is printed on
|
* to, so left and right would be a guess; the paddle is printed on the pod,
|
||||||
* the pod, and pressing it settles the question on screen (`confirmed`).
|
* and pressing it settles the question on screen (`confirmed`).
|
||||||
*
|
*
|
||||||
* The second job of this panel is to say what to *do* when a pod is missing.
|
* The second job of this panel is to say what to *do* when nothing is
|
||||||
* A Click sleeps within seconds and only advertises while awake (A-4), which
|
* connected — a Click sleeps within seconds and only advertises while awake
|
||||||
* no rider can guess from the words "not connected" — and which is also why
|
* (A-4), which no rider can guess from the words "not connected". But that is
|
||||||
* connecting is not a button they have to win a race with: the running scan
|
* the only thing worth a sentence, and only while it is true: connected, this
|
||||||
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
|
* panel is two lines and a badge. The five-step drill it used to open with
|
||||||
* here are for overriding that, not for driving it.
|
* lives behind a summary now, and the pods' MAC addresses are gone — they
|
||||||
|
* identified nothing a rider could act on.
|
||||||
*/
|
*/
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
|
import { api, type Pod, type PodState } from '../lib/bridge';
|
||||||
|
|
||||||
const controller = $derived(app.controller);
|
const controller = $derived(app.controller);
|
||||||
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
|
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
|
||||||
@@ -29,7 +35,11 @@
|
|||||||
* pod stays missing — and the fix belongs next to the symptom. */
|
* pod stays missing — and the fix belongs next to the symptom. */
|
||||||
const scanning = $derived(app.devices.scanning);
|
const scanning = $derived(app.devices.scanning);
|
||||||
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
||||||
const bothConnected = $derived(pods.length === 2 && pods.every((p) => p.state === 'connected'));
|
/** The pod that speaks for the pair. Connected, this is the whole controller. */
|
||||||
|
const minusLive = $derived(controller?.minus.state === 'connected');
|
||||||
|
/** Running on the fallback: the `+` pod alone, with no `−` paddle to shift
|
||||||
|
* down with beyond its `Y` button. Worth saying out loud. */
|
||||||
|
const plusOnly = $derived(!minusLive && controller?.plus.state === 'connected');
|
||||||
const busy = $derived(pods.some((p) => p.state === 'searching'));
|
const busy = $derived(pods.some((p) => p.state === 'searching'));
|
||||||
/** A pod reporting the other's paddle: the pair may be filed the wrong way
|
/** A pod reporting the other's paddle: the pair may be filed the wrong way
|
||||||
* round, and the rider is the only one who can say. */
|
* round, and the rider is the only one who can say. */
|
||||||
@@ -53,8 +63,8 @@
|
|||||||
|
|
||||||
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
||||||
const PURPOSE: Record<Pod, string> = {
|
const PURPOSE: Record<Pod, string> = {
|
||||||
minus: 'Shift down · D-pad',
|
minus: 'All ten buttons',
|
||||||
plus: 'Shift up · A B Y Z',
|
plus: 'Fallback · shift up, A B Y Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
function connect(pod: Pod) {
|
function connect(pod: Pod) {
|
||||||
@@ -69,109 +79,94 @@
|
|||||||
<section class="click">
|
<section class="click">
|
||||||
<header>
|
<header>
|
||||||
<h2>Zwift Click</h2>
|
<h2>Zwift Click</h2>
|
||||||
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
|
|
||||||
{#if bothConnected}
|
|
||||||
Both pods connected
|
|
||||||
{:else if anyConnected}
|
|
||||||
One pod of two
|
|
||||||
{:else}
|
|
||||||
No pods connected
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if !scanning}
|
{#if !scanning}
|
||||||
<!-- Nothing can be picked up automatically while the scan is off, so
|
<!-- Nothing can be picked up automatically while the scan is off, so
|
||||||
the way to fix that sits here rather than only in the header. -->
|
the way to fix that sits here rather than only in the header. -->
|
||||||
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
||||||
{:else if !bothConnected}
|
{:else if !minusLive}
|
||||||
|
<!-- The − pod, not both: it is the one that carries the whole
|
||||||
|
controller. The + pod has its own button on its own row. -->
|
||||||
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
|
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
|
||||||
{busy ? 'Searching…' : 'Connect now'}
|
{busy ? 'Searching…' : 'Find − pod'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if anyConnected}
|
{#if anyConnected}
|
||||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||||
Disconnect both
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="lede">
|
<!-- Only while it is telling the rider something they cannot see. Connected,
|
||||||
{#if !scanning}
|
the badge above has already said it. -->
|
||||||
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
|
{#if !scanning}
|
||||||
on each pod.
|
<p class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p>
|
||||||
{:else if bothConnected}
|
{:else if plusOnly}
|
||||||
Both pods are connected and will reconnect on their own if one drops.
|
<p class="lede">
|
||||||
{:else}
|
Running on the <strong>+ pod alone</strong>: shift down with <span class="kbd">Y</span>.
|
||||||
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
|
Press a button on the − pod for the D-pad.
|
||||||
running scan connects it as soon as it does — no need to press anything here.
|
</p>
|
||||||
{/if}
|
{:else if !minusLive}
|
||||||
</p>
|
<p class="lede">
|
||||||
|
<strong>Press any button on the − pod.</strong> It only advertises while awake; the running
|
||||||
|
scan connects it as soon as it does.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="pods">
|
<div class="pods">
|
||||||
{#each pods as pod (pod.pod)}
|
{#each pods as pod (pod.pod)}
|
||||||
|
{@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
|
||||||
<article class="pod" class:live={pod.state === 'connected'}>
|
<article class="pod" class:live={pod.state === 'connected'}>
|
||||||
<div class="title">
|
<!-- The paddle glyph is the pod's identity — big enough to match
|
||||||
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
|
against the one printed on the hardware at arm's length. -->
|
||||||
<span class="what">
|
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
|
||||||
<span class="label">{pod.symbol} pod</span>
|
|
||||||
<span class="purpose">{PURPOSE[pod.pod]}</span>
|
<div class="what">
|
||||||
</span>
|
<span class="pod-name">{pod.symbol} pod</span>
|
||||||
<span class="state {STATE_TONE[pod.state]}">
|
<span class="purpose">
|
||||||
<span class="dot"></span>{STATE_TEXT[pod.state]}
|
{#if dormant}
|
||||||
|
<!-- Not a fault, and the panel must not let it read as one: this
|
||||||
|
pod is idle because the − pod is already sending its buttons. -->
|
||||||
|
Relayed by the − pod
|
||||||
|
{:else if pod.confirmed}
|
||||||
|
Confirmed — sent its own {pod.symbol} paddle
|
||||||
|
{:else if pod.state === 'connected'}
|
||||||
|
Press its {pod.symbol} paddle to confirm
|
||||||
|
{:else}
|
||||||
|
{PURPOSE[pod.pod]}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="facts">
|
<span class="facts">
|
||||||
<div>
|
{#if pod.batteryPercent != null}<span>{pod.batteryPercent}%</span>{/if}
|
||||||
<dt>Battery</dt>
|
<!-- Connected and silent looks exactly like working until you press
|
||||||
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
|
something, so the count is the honest test of the link. -->
|
||||||
</div>
|
{#if pod.state === 'connected'}
|
||||||
<div>
|
<span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
|
||||||
<dt>Buttons seen</dt>
|
{/if}
|
||||||
<!-- Connected and silent looks exactly like working until you press
|
</span>
|
||||||
something, so the count is the honest test of the link. -->
|
|
||||||
<dd>
|
|
||||||
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`}
|
|
||||||
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Address</dt>
|
|
||||||
<dd class="addr">{pod.address ?? '—'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{#if pod.confirmed}
|
<span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
|
||||||
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
|
<span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
|
||||||
{:else if pod.state === 'connected'}
|
</span>
|
||||||
<p class="note">
|
|
||||||
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pod.contradicted}
|
|
||||||
<p class="note tone-warn">
|
|
||||||
This pod sent the other paddle. If the pair is the wrong way round, swap them.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pod.error}
|
|
||||||
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
|
|
||||||
<p class="note tone-bad">{pod.error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
|
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
|
||||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
|
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
|
||||||
{:else if pod.state === 'searching'}
|
{:else if pod.state === 'searching'}
|
||||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
|
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
||||||
Look for it now
|
{dormant ? 'Connect anyway' : 'Find it'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
|
||||||
|
{#if pod.error}<p class="note tone-bad">{pod.error}</p>{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -183,11 +178,9 @@
|
|||||||
{#if mixedUp || controller?.swapped}
|
{#if mixedUp || controller?.swapped}
|
||||||
<div class="swap">
|
<div class="swap">
|
||||||
<span>
|
<span>
|
||||||
{#if mixedUp}
|
{mixedUp
|
||||||
A pod is sending the other pod's paddle — the pair may be filed the wrong way round.
|
? 'A pod is sending the other pod’s paddle — the pair may be filed the wrong way round.'
|
||||||
{:else}
|
: 'The pods are swapped from what their advertisement claims.'}
|
||||||
The pods are swapped from what their advertisement claims.
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
|
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
|
||||||
Swap + / −
|
Swap + / −
|
||||||
@@ -195,82 +188,65 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !bothConnected}
|
{#if !minusLive}
|
||||||
<!--
|
<!--
|
||||||
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
|
FR-1.8. "Not connected" on its own reads as a broken app, and the real
|
||||||
real causes — a sleeping pod and a lapsed unlock — are things only the
|
cause — a pod that is simply asleep — is something only the rider can fix.
|
||||||
rider can fix, so they are spelled out here rather than left to be
|
The lede above says that much; the rest is for the rider it did not work
|
||||||
guessed at.
|
for, and stays folded until they ask, because it is five paragraphs of
|
||||||
|
things that are usually already true.
|
||||||
-->
|
-->
|
||||||
<details class="help" open={!anyConnected}>
|
<details class="help">
|
||||||
<summary>A pod will not connect — what to try</summary>
|
<summary>Still not connecting?</summary>
|
||||||
<ol>
|
<ul>
|
||||||
|
<li><strong>Press a button.</strong> A pod that is asleep does not advertise at all.</li>
|
||||||
|
<li><strong>Keep the scan on.</strong> Auto-connect runs off it.</li>
|
||||||
|
<li><strong>Close Zwift.</strong> One app at a time holds a pod.</li>
|
||||||
|
<li><strong>Charge it.</strong> A flat pod stops advertising, and the app stops chasing.</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
|
<strong>Or use the + pod</strong> — you lose the D-pad, <span class="kbd">Y</span> still
|
||||||
A Click sleeps within seconds and only advertises while awake, so a pod that is not
|
shifts down.
|
||||||
broadcasting is the normal case, not a fault. The scan below is running: press a
|
|
||||||
button and the pod connects itself, usually within a second or two.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a
|
The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
|
||||||
stopped scan means nothing gets picked up. Restart it above.
|
never depends on a pod.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
</ul>
|
||||||
<strong>Close anything else holding the pod.</strong> One app at a time — Zwift left
|
|
||||||
running in the background keeps the link, and this app will never see the pod.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Bring it closer, or charge it.</strong> A flat pod stops advertising
|
|
||||||
altogether, and after about thirty failed attempts the app stops chasing it and says
|
|
||||||
so on the card.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p class="fallback">
|
|
||||||
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
|
|
||||||
<span class="kbd">−</span> shift, <span class="kbd">↑</span>
|
|
||||||
<span class="kbd">↓</span> trim the gradient. Press
|
|
||||||
<span class="kbd">?</span> for the full list. A ride never depends on a pod.
|
|
||||||
</p>
|
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.click {
|
.click {
|
||||||
margin: 0 var(--edge) 0.8rem;
|
margin: 0 var(--edge) 0.7rem;
|
||||||
padding: 0.9rem 1rem 1rem;
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
background: var(--bg-lift);
|
background: var(--bg-lift);
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.05rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
.lede {
|
||||||
margin: 0.55rem 0 0;
|
margin: 0.45rem 0 0;
|
||||||
font-size: 0.86rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lede strong {
|
.lede strong {
|
||||||
color: var(--ink);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -279,43 +255,44 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pods {
|
.actions .btn {
|
||||||
display: grid;
|
padding: 0.45em 0.8em;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
font-size: 0.85rem;
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod {
|
.pods {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.3rem;
|
||||||
padding: 0.8rem 0.9rem;
|
margin-top: 0.6rem;
|
||||||
border-radius: 0.55rem;
|
}
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
|
/* A row, not a card: two pods stacked read as one controller with a fallback,
|
||||||
|
which is what they are. Two equal cards read as two things to pair. */
|
||||||
|
.pod {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 0.7rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod.live {
|
.pod.live {
|
||||||
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
|
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The paddle glyph is the pod's identity — big enough to match against the
|
|
||||||
one printed on the hardware at arm's length. */
|
|
||||||
.paddle {
|
.paddle {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 2.1rem;
|
width: 1.9rem;
|
||||||
height: 2.1rem;
|
height: 1.9rem;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: var(--hairline);
|
background: var(--hairline);
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 1.3rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -329,96 +306,72 @@
|
|||||||
.what {
|
.what {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1 1 10rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
/* Not `.label`: that is app.css's uppercase, letter-spaced section label,
|
||||||
font-size: 1rem;
|
and a pod's name is a name, not a heading. */
|
||||||
|
.pod-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purpose {
|
.purpose {
|
||||||
font-size: 0.78rem;
|
font-size: 0.76rem;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
margin-left: auto;
|
font-size: 0.84rem;
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.facts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.facts div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last {
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.addr {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note strong {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-top: auto;
|
margin-left: auto;
|
||||||
padding-top: 0.15rem;
|
}
|
||||||
|
|
||||||
|
.controls .btn {
|
||||||
|
padding: 0.4em 0.7em;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap {
|
.swap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-top: 0.6rem;
|
flex-wrap: wrap;
|
||||||
padding: 0.55rem 0.75rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: rgba(255, 207, 74, 0.07);
|
background: rgba(255, 207, 74, 0.07);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 0.85rem;
|
font-size: 0.83rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap button {
|
.swap button {
|
||||||
@@ -426,34 +379,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.55rem;
|
||||||
font-size: 0.86rem;
|
font-size: 0.83rem;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help ol {
|
/* `display: flex` on a summary drops the browser's own marker, and a
|
||||||
margin: 0.5rem 0 0;
|
disclosure with nothing to disclose-looking about it does not read as one. */
|
||||||
padding-left: 1.2rem;
|
summary::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help ul {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help li {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help strong {
|
.help strong {
|
||||||
color: var(--ink);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback {
|
/* Narrow: the state and the buttons drop below the pod's name rather than
|
||||||
margin: 0.5rem 0 0;
|
crushing it. */
|
||||||
color: var(--ink-dim);
|
:global([data-size='compact']) .facts,
|
||||||
line-height: 1.6;
|
:global([data-size='compact']) .state {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-size='compact']) .controls {
|
||||||
|
order: 4;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin-left: 2.3rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
|||||||
['L', 'Insert lap marker'],
|
['L', 'Insert lap marker'],
|
||||||
['P', 'Profiles and routes'],
|
['P', 'Profiles and routes'],
|
||||||
['D', 'Device / connection screen'],
|
['D', 'Device / connection screen'],
|
||||||
|
[',', 'Rider setup — weight, FTP, units'],
|
||||||
['R', 'Ride screen'],
|
['R', 'Ride screen'],
|
||||||
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
||||||
['S', 'Save the FIT file (summary screen)'],
|
['S', 'Save the FIT file (summary screen)'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import { axisValue } from '../lib/format';
|
import { axisValue, dist, distUnit, elev, elevUnit } from '../lib/format';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
let yaml = $state('');
|
let yaml = $state('');
|
||||||
@@ -136,9 +136,11 @@
|
|||||||
<span class="label">Loaded</span>
|
<span class="label">Loaded</span>
|
||||||
<strong>{loaded.name}</strong>
|
<strong>{loaded.name}</strong>
|
||||||
<span class="sample-sub">
|
<span class="sample-sub">
|
||||||
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
|
{loaded.totalMetres ? `${dist(loaded.totalMetres, app.units, 1)} ${distUnit(app.units)}` : ''}
|
||||||
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
||||||
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
{loaded.totalAscentM != null
|
||||||
|
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} up`
|
||||||
|
: ''}
|
||||||
{loaded.looping ? '· loops' : ''}
|
{loaded.looping ? '· loops' : ''}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
<em>{b.kind}</em>
|
<em>{b.kind}</em>
|
||||||
{b.label}
|
{b.label}
|
||||||
<span class="quiet">
|
<span class="quiet">
|
||||||
{axisValue(b.unit, b.endX - b.startX)}
|
{axisValue(b.unit, b.endX - b.startX, app.units)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
sub?: string | null;
|
sub?: string | null;
|
||||||
dim?: boolean;
|
dim?: boolean;
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
|
/**
|
||||||
|
* A short tag beside the label — the effort zone, in practice.
|
||||||
|
*
|
||||||
|
* It sits next to the *label* rather than the number so the number keeps
|
||||||
|
* its full weight, and it is a word rather than a colour alone: `Z4` still
|
||||||
|
* reads on a phone in the sun and to a rider who cannot separate the amber
|
||||||
|
* from the green (see the zone note in app.css).
|
||||||
|
*/
|
||||||
|
badge?: { label: string; colour: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,11 +33,15 @@
|
|||||||
sub = null,
|
sub = null,
|
||||||
dim = false,
|
dim = false,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
badge = null,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||||
<span class="label">{label}</span>
|
<span class="head">
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{#if badge}<span class="badge" style:color={badge.colour}>{badge.label}</span>{/if}
|
||||||
|
</span>
|
||||||
<span class="value" style:color={colour}>
|
<span class="value" style:color={colour}>
|
||||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,6 +56,21 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: var(--type-label);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
/* No pill: a box around a two-character tag is more furniture than the tag
|
||||||
|
is worth, and this stylesheet earns its structure from space. */
|
||||||
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
|
|||||||
@@ -11,12 +11,19 @@
|
|||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import {
|
import {
|
||||||
clock,
|
clock,
|
||||||
|
dist,
|
||||||
|
distUnit,
|
||||||
duration,
|
duration,
|
||||||
|
elev,
|
||||||
|
elevUnit,
|
||||||
finishAt,
|
finishAt,
|
||||||
km,
|
hrZone,
|
||||||
MODE_LABEL,
|
MODE_LABEL,
|
||||||
num,
|
num,
|
||||||
|
powerZone,
|
||||||
signed,
|
signed,
|
||||||
|
speed,
|
||||||
|
speedUnit,
|
||||||
targetText,
|
targetText,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
import { viewport } from '../lib/viewport.svelte';
|
import { viewport } from '../lib/viewport.svelte';
|
||||||
@@ -33,6 +40,9 @@
|
|||||||
const show = $derived(viewport.plan.show);
|
const show = $derived(viewport.plan.show);
|
||||||
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
||||||
|
|
||||||
|
const units = $derived(app.units);
|
||||||
|
const prefs = $derived(app.prefs);
|
||||||
|
|
||||||
const frame = $derived(app.frame);
|
const frame = $derived(app.frame);
|
||||||
const snap = $derived(frame?.snapshot ?? null);
|
const snap = $derived(frame?.snapshot ?? null);
|
||||||
const d = $derived(frame?.derived ?? null);
|
const d = $derived(frame?.derived ?? null);
|
||||||
@@ -81,13 +91,20 @@
|
|||||||
const podChip = $derived.by(() => {
|
const podChip = $derived.by(() => {
|
||||||
const c = app.controller;
|
const c = app.controller;
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
|
// The − pod relays its twin, so a `+` pod that is not connected while the
|
||||||
if (missing.length === 0) return null;
|
// `−` pod is up is the *intended* configuration, not a missing device —
|
||||||
|
// and this chip was reporting it as one, contradicting the device screen.
|
||||||
|
if (c.minus.state === 'connected') return null;
|
||||||
|
const missing = c.plus.state === 'connected' ? [c.minus] : [c.minus, c.plus];
|
||||||
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
||||||
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
||||||
return {
|
return {
|
||||||
tone: searching ? 'tone-warn' : 'tone-idle',
|
tone: searching ? 'tone-warn' : 'tone-idle',
|
||||||
label: searching ? `Looking for the ${names}…` : `No ${names}`,
|
label: searching
|
||||||
|
? `Looking for the ${names}…`
|
||||||
|
: c.plus.state === 'connected'
|
||||||
|
? '+ pod only — no D-pad'
|
||||||
|
: `No ${names}`,
|
||||||
// The keyboard is always the fallback, which is what keeps a missing pod
|
// The keyboard is always the fallback, which is what keeps a missing pod
|
||||||
// an annoyance rather than the end of the ride.
|
// an annoyance rather than the end of the ride.
|
||||||
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
||||||
@@ -190,10 +207,10 @@
|
|||||||
*/
|
*/
|
||||||
const speedSub = $derived.by(() => {
|
const speedSub = $derived.by(() => {
|
||||||
if (speedFault) return speedFault;
|
if (speedFault) return speedFault;
|
||||||
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
|
const now = `now ${speed(snap?.virtual_speed_kph ?? 0, units)}`;
|
||||||
const trainer = snap?.telemetry.speed_kph;
|
const trainer = snap?.telemetry.speed_kph;
|
||||||
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
||||||
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
|
return (trainer != null ? `${now} · trainer ${speed(trainer, units)}` : now) + coasting;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusChip = $derived.by(() => {
|
const statusChip = $derived.by(() => {
|
||||||
@@ -220,13 +237,66 @@
|
|||||||
const routeSummary = $derived.by(() => {
|
const routeSummary = $derived.by(() => {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const bits: string[] = [];
|
const bits: string[] = [];
|
||||||
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
|
if (profile.totalMetres) bits.push(`${dist(profile.totalMetres, units, 1)} ${distUnit(units)}`);
|
||||||
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
||||||
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
|
if (profile.totalAscentM != null) {
|
||||||
|
bits.push(`${elev(profile.totalAscentM, units)} ${elevUnit(units)} up`);
|
||||||
|
}
|
||||||
if (profile.looping) bits.push('loops');
|
if (profile.looping) bits.push('loops');
|
||||||
return bits.join(' · ');
|
return bits.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effort as a zone, not just a number (see `powerZone` in lib/format.ts).
|
||||||
|
*
|
||||||
|
* Read off the *rolling* average rather than the instantaneous watts: at 4 Hz
|
||||||
|
* the raw figure crosses two zone boundaries every pedal stroke, and a
|
||||||
|
* colour that strobes is worse than no colour. `null` whenever the rider has
|
||||||
|
* not set an FTP — the readout then draws exactly as it always did.
|
||||||
|
*/
|
||||||
|
const pZone = $derived(powerZone(d?.rollingPowerW, prefs.ftpW));
|
||||||
|
const hZone = $derived(hrZone(snap?.telemetry.heart_rate_bpm, prefs.maxHrBpm));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gradient, as a shape.
|
||||||
|
*
|
||||||
|
* A signed decimal has to be read; a wedge is seen. The angle is exaggerated
|
||||||
|
* — real road gradients are almost flat at true scale, and 6% drawn honestly
|
||||||
|
* is indistinguishable from 3% at 40 px wide — and clamped so a profile with
|
||||||
|
* a 30% wall cannot draw a vertical cliff.
|
||||||
|
*/
|
||||||
|
const wedge = $derived.by(() => {
|
||||||
|
const clamped = Math.max(-15, Math.min(15, gradient));
|
||||||
|
// 22 px of rise across 38 px of run at the clamp, which reads as a
|
||||||
|
// recognisable hill without leaving the line box.
|
||||||
|
const rise = (clamped / 15) * 11;
|
||||||
|
return { y1: 12 + rise, y2: 12 - rise };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is coming, from the profile's own block list.
|
||||||
|
*
|
||||||
|
* The route chart says where the rider *is*; every professional app also says
|
||||||
|
* what is about to happen, because that is what decides whether to shift now
|
||||||
|
* or hold on. The data was already computed Rust-side and thrown away here.
|
||||||
|
*/
|
||||||
|
const nextUp = $derived.by(() => {
|
||||||
|
if (!profile || !d) return null;
|
||||||
|
const at = d.positionX;
|
||||||
|
const block = profile.blocks.find((b) => b.startX > at);
|
||||||
|
if (!block) return null;
|
||||||
|
const away = block.startX - at;
|
||||||
|
// Close in, the small unit reads better than a fraction of the big one:
|
||||||
|
// "in 460 m" is a distance a rider can feel, "in 0.46 km" is arithmetic.
|
||||||
|
const inWords =
|
||||||
|
block.unit !== 'metres'
|
||||||
|
? duration(away)
|
||||||
|
: away < 1000
|
||||||
|
? `${elev(away, units)} ${elevUnit(units)}`
|
||||||
|
: `${dist(away, units, 1)} ${distUnit(units)}`;
|
||||||
|
return { label: block.label, away: inWords };
|
||||||
|
});
|
||||||
|
|
||||||
const openRoutes = () => (app.showProfiles = true);
|
const openRoutes = () => (app.showProfiles = true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -257,17 +327,36 @@
|
|||||||
<span class="dot"></span>{podChip.label}
|
<span class="dot"></span>{podChip.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
<!-- One chip, not two. The mode and the target it produced are a single
|
||||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
fact — "Manual grade, +2.5%" — and splitting them cost a whole chip
|
||||||
|
of the header's width to repeat the word "target" (FR-9.8). -->
|
||||||
|
<span class="chip mode">
|
||||||
|
{MODE_LABEL[ride?.mode ?? 'ManualGrade']}
|
||||||
|
<span class="target">{targetText(ride?.target ?? null)}</span>
|
||||||
|
</span>
|
||||||
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
||||||
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
||||||
|
<!-- Hidden on a phone (see below): the setup screen is a one-off, it has
|
||||||
|
its own way in from the device screen, and the header is the one row
|
||||||
|
on this layout that cannot afford a second line. -->
|
||||||
|
<button class="btn ghost setup" title="Rider setup" onclick={() => app.openSettings()}>
|
||||||
|
Rider <span class="kbd">,</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- The hero. -->
|
<!-- The hero. -->
|
||||||
{#if show.routeChart}
|
{#if show.routeChart}
|
||||||
<section class="route">
|
<section class="route">
|
||||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
<!-- The chart owns the slack; the next-up line takes the one text row it
|
||||||
|
needs. Without this wrapper the chart is 100% of the section and the
|
||||||
|
line is clipped by the section's own `overflow: hidden`. -->
|
||||||
|
<div class="route-chart">
|
||||||
|
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||||
|
</div>
|
||||||
|
{#if nextUp}
|
||||||
|
<span class="next"><span class="label">Next</span>{nextUp.label} in {nextUp.away}</span>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -314,27 +403,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="To go"
|
label="To go"
|
||||||
value={remaining != null ? km(remaining, 2) : '—'}
|
value={remaining != null ? dist(remaining, units, 2) : '—'}
|
||||||
unit={remaining != null ? 'km' : ''}
|
unit={remaining != null ? distUnit(units) : ''}
|
||||||
size="big"
|
size="big"
|
||||||
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
|
sub={d?.distanceTotalM ? `of ${dist(d.distanceTotalM, units, 1)} ${distUnit(units)}` : null}
|
||||||
dim={remaining == null}
|
dim={remaining == null}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Speed"
|
label="Speed"
|
||||||
value={num(d?.displaySpeedKph ?? 0, 1)}
|
value={speed(d?.displaySpeedKph ?? 0, units)}
|
||||||
unit="km/h"
|
unit={speedUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
sub={speedSub}
|
sub={speedSub}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<div class="grade-cell">
|
||||||
label="Gradient"
|
<Readout
|
||||||
value={signed(gradient, 1)}
|
label="Gradient"
|
||||||
unit="%"
|
value={signed(gradient, 1)}
|
||||||
size="big"
|
unit="%"
|
||||||
colour={gradeColour}
|
size="big"
|
||||||
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
colour={gradeColour}
|
||||||
/>
|
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
||||||
|
/>
|
||||||
|
<!-- The same number as a slope. Seen, not read (see `wedge`). -->
|
||||||
|
<svg class="wedge" viewBox="0 0 40 24" aria-hidden="true" style:color={gradeColour}>
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2}" stroke="currentColor" stroke-width="2.6"
|
||||||
|
stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2} L38 22 L2 22 Z" fill="currentColor" opacity="0.14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="Gear"
|
label="Gear"
|
||||||
value={gear.value}
|
value={gear.value}
|
||||||
@@ -350,14 +447,14 @@
|
|||||||
<section class="detail">
|
<section class="detail">
|
||||||
<Readout
|
<Readout
|
||||||
label="Elevation"
|
label="Elevation"
|
||||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
value={d?.elevationM != null ? elev(d.elevationM, units) : '—'}
|
||||||
unit={d?.elevationM != null ? 'm' : ''}
|
unit={d?.elevationM != null ? elevUnit(units) : ''}
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing left"
|
label="Climbing left"
|
||||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
value={d?.ascentRemainingM != null ? elev(d.ascentRemainingM, units) : '—'}
|
||||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
unit={d?.ascentRemainingM != null ? elevUnit(units) : ''}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Compact keeps three of the five, and elapsed time is one of them: it is
|
Compact keeps three of the five, and elapsed time is one of them: it is
|
||||||
@@ -366,8 +463,16 @@
|
|||||||
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
||||||
-->
|
-->
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
|
<Readout
|
||||||
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
|
label="Covered"
|
||||||
|
value={dist(snap?.virtual_distance_m ?? 0, units, 2)}
|
||||||
|
unit={distUnit(units)}
|
||||||
|
/>
|
||||||
|
<Readout
|
||||||
|
label="Ascended"
|
||||||
|
value={elev(snap?.elevation_gain_m ?? 0, units)}
|
||||||
|
unit={elevUnit(units)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
||||||
</section>
|
</section>
|
||||||
@@ -380,8 +485,9 @@
|
|||||||
value={num(d?.rollingPowerW ?? 0, 0)}
|
value={num(d?.rollingPowerW ?? 0, 0)}
|
||||||
unit="W"
|
unit="W"
|
||||||
size="mid"
|
size="mid"
|
||||||
colour="var(--power)"
|
colour={pZone?.colour ?? 'var(--power)'}
|
||||||
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
badge={pZone ? { label: pZone.short, colour: pZone.colour } : null}
|
||||||
|
sub={`${pZone ? pZone.name + ' · ' : ''}now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Cadence"
|
label="Cadence"
|
||||||
@@ -395,6 +501,9 @@
|
|||||||
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
size="mid"
|
size="mid"
|
||||||
|
colour={hZone?.colour ?? 'var(--ink)'}
|
||||||
|
badge={hZone ? { label: hZone.short, colour: hZone.colour } : null}
|
||||||
|
sub={hZone?.name ?? null}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Averages, normalised power, work and calories are what the summary screen
|
Averages, normalised power, work and calories are what the summary screen
|
||||||
@@ -517,18 +626,60 @@
|
|||||||
background: #131b26;
|
background: #131b26;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip.mode .target {
|
||||||
|
color: var(--route);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The gradient number and its slope, side by side and sharing a colour. */
|
||||||
|
.grade-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wedge {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-size='compact']) .chips .setup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.chip.target {
|
.chip.target {
|
||||||
color: var(--route);
|
color: var(--route);
|
||||||
background: rgba(69, 208, 255, 0.1);
|
background: rgba(69, 208, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.route {
|
.route {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 2 1 0;
|
flex: 2 1 0;
|
||||||
min-height: var(--route-min);
|
min-height: var(--route-min);
|
||||||
padding: 0 var(--edge);
|
padding: 0 var(--edge);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-chart {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- pre-ride launch panel ---------------------------------------- */
|
/* ---- pre-ride launch panel ---------------------------------------- */
|
||||||
|
|
||||||
.launch {
|
.launch {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import 'uplot/dist/uPlot.min.css';
|
import 'uplot/dist/uPlot.min.css';
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { dist, elev } from '../lib/format';
|
||||||
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@
|
|||||||
|
|
||||||
let { profile, positionX, revision }: Props = $props();
|
let { profile, positionX, revision }: Props = $props();
|
||||||
|
|
||||||
|
/** Axis labels are the one part of this chart that a display preference
|
||||||
|
* reaches, so a change of units rebuilds it — see the effect below. */
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | null>(null);
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
let plot: uPlot | null = null;
|
let plot: uPlot | null = null;
|
||||||
let disposeSize: (() => void) | null = null;
|
let disposeSize: (() => void) | null = null;
|
||||||
@@ -56,12 +62,15 @@
|
|||||||
axes: [
|
axes: [
|
||||||
axis({
|
axis({
|
||||||
values: (_u, splits) =>
|
values: (_u, splits) =>
|
||||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
splits.map((v) => (isMetres ? dist(v, units, 1) : formatMinutes(v))),
|
||||||
}),
|
}),
|
||||||
axis({
|
axis({
|
||||||
side: 3,
|
side: 3,
|
||||||
size: 46,
|
size: 46,
|
||||||
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
|
// Metres of elevation follow the rider's units; a profile's own
|
||||||
|
// channel (percent, watts) is already in the unit it means.
|
||||||
|
values: (_u, splits) =>
|
||||||
|
splits.map((v) => (source?.isElevation ? elev(v, units) : v.toFixed(0))),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -122,7 +131,8 @@
|
|||||||
// is a single array walk rather than a chart teardown.
|
// is a single array walk rather than a chart teardown.
|
||||||
let builtFor: string | null = null;
|
let builtFor: string | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
const key =
|
||||||
|
host && profile ? `${profile.name}|${profile.source}|${profile.totalX}|${units}` : null;
|
||||||
if (key === builtFor) return;
|
if (key === builtFor) return;
|
||||||
builtFor = key;
|
builtFor = key;
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Rider setup (FR-7.4).
|
||||||
|
*
|
||||||
|
* This screen did not exist, and its absence was the app's largest single
|
||||||
|
* lie: `RiderConfig::default()` is a 105 kg rider on an 8 kg bike, and with
|
||||||
|
* no way to change it every rider was shown a stranger's speed, a stranger's
|
||||||
|
* ETA and a stranger's calorie count. Mass is not a preference, it is half
|
||||||
|
* the physics.
|
||||||
|
*
|
||||||
|
* Three rules hold here:
|
||||||
|
*
|
||||||
|
* 1. **Rust owns validity.** Every commit round-trips through the command,
|
||||||
|
* which validates, persists and hands back what it accepted — and that
|
||||||
|
* is what lands in the form. A field can never end up showing a value
|
||||||
|
* the engine is not using.
|
||||||
|
* 2. **Commit on change, not on a Save button.** There is no "unsaved"
|
||||||
|
* state to lose, and no button to forget to press. Fields commit when
|
||||||
|
* they are left, so a half-typed "7" on the way to "72" is never sent.
|
||||||
|
* 3. **What matters is on top.** Weight, FTP and units are the three a
|
||||||
|
* rider actually sets. Everything below them is aerodynamic and
|
||||||
|
* drivetrain detail that has a defensible default, so it is folded away
|
||||||
|
* rather than made to look equally important.
|
||||||
|
*/
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { api } from '../lib/bridge';
|
||||||
|
import { fromMass, massUnit, toMass } from '../lib/format';
|
||||||
|
import type { Preferences, RiderConfig, SafetyLimits, Units } from '../lib/types';
|
||||||
|
|
||||||
|
let rider = $state<RiderConfig | null>(null);
|
||||||
|
let limits = $state<SafetyLimits | null>(null);
|
||||||
|
const prefs = $derived(app.prefs);
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
|
/** Bumped after every commit so the inputs re-read the accepted values —
|
||||||
|
* an input the rider has typed into keeps its own DOM value otherwise, and
|
||||||
|
* a rejected 900 kg would sit there looking accepted. */
|
||||||
|
let revision = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void (async () => {
|
||||||
|
rider = await api.riderConfig();
|
||||||
|
limits = await api.safetyLimits();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the whole config, spread over what Rust last gave us.
|
||||||
|
*
|
||||||
|
* Spread rather than rebuilt: `RiderConfig` carries fields this form does not
|
||||||
|
* show (the load channel, the fixed drivetrain loss), and a form that
|
||||||
|
* reconstructs the struct would quietly reset them to serde defaults every
|
||||||
|
* time somebody changed their weight.
|
||||||
|
*/
|
||||||
|
async function commitRider(patch: Partial<RiderConfig>) {
|
||||||
|
if (!rider) return;
|
||||||
|
const next = { ...rider, ...patch };
|
||||||
|
const accepted = await app.run(() => api.setRiderConfig(next));
|
||||||
|
if (accepted) rider = accepted;
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitLimits(patch: Partial<SafetyLimits>) {
|
||||||
|
const base = limits;
|
||||||
|
if (!base) return;
|
||||||
|
const accepted = await app.run(() => api.setSafetyLimits({ ...base, ...patch }));
|
||||||
|
if (accepted) limits = accepted;
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitPrefs(patch: Partial<Preferences>) {
|
||||||
|
await app.savePrefs({ ...prefs, ...patch });
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total mass is what the physics actually uses, so it is worth seeing. */
|
||||||
|
const totalMass = $derived(rider ? rider.rider_kg + rider.bike_kg : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One field. `value` is read through `key` so that a commit — accepted or
|
||||||
|
rejected — reseats the input from the truth Rust returned.
|
||||||
|
-->
|
||||||
|
{#snippet field(
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
step: number,
|
||||||
|
unit: string,
|
||||||
|
hint: string | null,
|
||||||
|
commit: (v: number) => void,
|
||||||
|
)}
|
||||||
|
<label class="field">
|
||||||
|
<span class="name">{label}</span>
|
||||||
|
<span class="input">
|
||||||
|
{#key revision}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{step}
|
||||||
|
value={Number.isFinite(value) ? Number(value.toFixed(4)) : 0}
|
||||||
|
onchange={(e) => commit(e.currentTarget.valueAsNumber)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
<span class="unit">{unit}</span>
|
||||||
|
</span>
|
||||||
|
{#if hint}<span class="hint">{hint}</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<header>
|
||||||
|
<h1>Rider setup</h1>
|
||||||
|
<button class="btn ghost" onclick={() => app.closeSettings()}>
|
||||||
|
Done <span class="kbd">Esc</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
{#if !rider || !limits}
|
||||||
|
<p class="waiting">Reading your setup…</p>
|
||||||
|
{:else}
|
||||||
|
<section class="card">
|
||||||
|
<h2>You</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field(
|
||||||
|
'Weight',
|
||||||
|
toMass(rider.rider_kg, units),
|
||||||
|
0.5,
|
||||||
|
massUnit(units),
|
||||||
|
'Sets speed, ETA and calories',
|
||||||
|
(v) => commitRider({ rider_kg: fromMass(v, units) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Bike',
|
||||||
|
toMass(rider.bike_kg, units),
|
||||||
|
0.1,
|
||||||
|
massUnit(units),
|
||||||
|
`${toMass(totalMass, units).toFixed(1)} ${massUnit(units)} on the road`,
|
||||||
|
(v) => commitRider({ bike_kg: fromMass(v, units) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'FTP',
|
||||||
|
prefs.ftpW,
|
||||||
|
5,
|
||||||
|
'W',
|
||||||
|
// Zero is a real answer, not an empty box: it is how a rider says
|
||||||
|
// "I do not know mine", and the screen then shows plain watts
|
||||||
|
// rather than a zone measured against a guess.
|
||||||
|
prefs.ftpW ? 'Colours power by zone' : '0 — no power zones',
|
||||||
|
(v) => commitPrefs({ ftpW: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Max heart rate',
|
||||||
|
prefs.maxHrBpm,
|
||||||
|
1,
|
||||||
|
'bpm',
|
||||||
|
prefs.maxHrBpm ? 'Colours heart rate by zone' : '0 — no heart-rate zones',
|
||||||
|
(v) => commitPrefs({ maxHrBpm: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="name">Units</span>
|
||||||
|
<div class="segmented">
|
||||||
|
{#each [['metric', 'km · kg'], ['imperial', 'mi · lb']] as [value, label]}
|
||||||
|
<button
|
||||||
|
class:on={units === value}
|
||||||
|
onclick={() => commitPrefs({ units: value as Units })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="hint">Display only — rides record in SI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Everything below has a defensible default and changes the ride subtly
|
||||||
|
rather than obviously. Folded, so the three settings that matter are not
|
||||||
|
buried among nine that do not.
|
||||||
|
-->
|
||||||
|
<details class="card">
|
||||||
|
<summary><h2>Bike and physics</h2></summary>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field('CdA', rider.cda, 0.005, 'm²', 'Road position ≈ 0.32', (v) =>
|
||||||
|
commitRider({ cda: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Rolling resistance', rider.crr, 0.0005, 'Crr', 'Road tyre ≈ 0.004', (v) =>
|
||||||
|
commitRider({ crr: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Drivetrain',
|
||||||
|
rider.drivetrain_efficiency * 100,
|
||||||
|
0.5,
|
||||||
|
'%',
|
||||||
|
'Clean chain ≈ 97%',
|
||||||
|
(v) => commitRider({ drivetrain_efficiency: v / 100 }),
|
||||||
|
)}
|
||||||
|
{@render field('Air density', rider.air_density, 0.005, 'kg/m³', 'Sea level 1.225', (v) =>
|
||||||
|
commitRider({ air_density: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Wheel',
|
||||||
|
rider.wheel_circumference_m * 1000,
|
||||||
|
5,
|
||||||
|
'mm',
|
||||||
|
'700×25 ≈ 2100 mm',
|
||||||
|
(v) => commitRider({ wheel_circumference_m: v / 1000 }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Crank',
|
||||||
|
rider.crank_length_m * 1000,
|
||||||
|
2.5,
|
||||||
|
'mm',
|
||||||
|
'Road 170–175 mm',
|
||||||
|
(v) => commitRider({ crank_length_m: v / 1000 }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Real gear',
|
||||||
|
rider.physical_development_m,
|
||||||
|
0.1,
|
||||||
|
'm/rev',
|
||||||
|
'Through the Zwift Cog — 34×14 ≈ 5.1',
|
||||||
|
(v) => commitRider({ physical_development_m: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Descent floor',
|
||||||
|
rider.descent_load_floor_pct,
|
||||||
|
0.5,
|
||||||
|
'%',
|
||||||
|
'Keeps load under the pedals downhill',
|
||||||
|
(v) => commitRider({ descent_load_floor_pct: v }),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- SAF-3. These bound everything sent to the trainer, whatever asked
|
||||||
|
for it, so they are shown last and phrased as limits, not targets. -->
|
||||||
|
<details class="card">
|
||||||
|
<summary><h2>Safety limits</h2></summary>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field('Gradient floor', limits.min_gradient_pct, 0.5, '%', null, (v) =>
|
||||||
|
commitLimits({ min_gradient_pct: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Gradient ceiling', limits.max_gradient_pct, 0.5, '%', null, (v) =>
|
||||||
|
commitLimits({ max_gradient_pct: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Resistance floor', limits.min_resistance, 1, 'L', null, (v) =>
|
||||||
|
commitLimits({ min_resistance: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Resistance ceiling', limits.max_resistance, 1, 'L', null, (v) =>
|
||||||
|
commitLimits({ max_resistance: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Power floor', limits.min_power_w, 5, 'W', null, (v) =>
|
||||||
|
commitLimits({ min_power_w: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Power ceiling', limits.max_power_w, 5, 'W', 'Clamped at transmission', (v) =>
|
||||||
|
commitLimits({ max_power_w: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--edge) 3rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: var(--bg-lift);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `display: flex` on a summary drops the browser's own marker, and a
|
||||||
|
disclosure with nothing to disclose-looking about it does not read as one. */
|
||||||
|
summary::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||||
|
gap: 0.7rem 1rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--route) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
/* The spinners are 12 px of target on a screen where nothing else is under
|
||||||
|
48, and they steal the width the number needs. */
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::-webkit-outer-spin-button,
|
||||||
|
input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.on {
|
||||||
|
background: var(--route);
|
||||||
|
color: #04121a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
* urgent. It says where the file already is, whatever the rider does next.
|
* urgent. It says where the file already is, whatever the rider does next.
|
||||||
*/
|
*/
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { clock, km, num } from '../lib/format';
|
import { clock, dist, distUnit, elev, elevUnit, num } from '../lib/format';
|
||||||
import Readout from './Readout.svelte';
|
import Readout from './Readout.svelte';
|
||||||
|
|
||||||
const s = $derived(app.summary);
|
const s = $derived(app.summary);
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
/** Time not spent riding. Only worth showing when it is not zero. */
|
/** Time not spent riding. Only worth showing when it is not zero. */
|
||||||
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
||||||
@@ -64,15 +65,15 @@
|
|||||||
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
||||||
<Readout
|
<Readout
|
||||||
label="Distance"
|
label="Distance"
|
||||||
value={km(s.distanceM, 2)}
|
value={dist(s.distanceM, units, 2)}
|
||||||
unit="km"
|
unit={distUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--route)"
|
colour="var(--route)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing"
|
label="Climbing"
|
||||||
value={num(s.ascentM, 0)}
|
value={elev(s.ascentM, units)}
|
||||||
unit="m"
|
unit={elevUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import type {
|
|||||||
InputAck,
|
InputAck,
|
||||||
LapSummary,
|
LapSummary,
|
||||||
Notice,
|
Notice,
|
||||||
|
Preferences,
|
||||||
RideFrame,
|
RideFrame,
|
||||||
RideState,
|
RideState,
|
||||||
RideSummary,
|
RideSummary,
|
||||||
SampleProfile,
|
SampleProfile,
|
||||||
|
Units,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type Screen = 'connect' | 'ride' | 'summary';
|
export type Screen = 'connect' | 'ride' | 'summary' | 'settings';
|
||||||
|
|
||||||
let toastSeq = 0;
|
let toastSeq = 0;
|
||||||
|
|
||||||
@@ -40,6 +42,14 @@ class AppStore {
|
|||||||
controller = $state<ControllerStatus | null>(null);
|
controller = $state<ControllerStatus | null>(null);
|
||||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||||
revision = $state(0);
|
revision = $state(0);
|
||||||
|
/**
|
||||||
|
* Display preferences (FR-7.4). Held here rather than read where they are
|
||||||
|
* needed so that changing units or FTP redraws every readout at once —
|
||||||
|
* `format.ts` stays pure and takes them as an argument.
|
||||||
|
*/
|
||||||
|
prefs = $state<Preferences>({ ftpW: 0, maxHrBpm: 0, units: 'metric' });
|
||||||
|
/** The screen to return to when settings close. */
|
||||||
|
settingsReturn: Screen = 'connect';
|
||||||
|
|
||||||
/** Bounded chart history — raw power, rolling power. */
|
/** Bounded chart history — raw power, rolling power. */
|
||||||
readonly power = new History(2);
|
readonly power = new History(2);
|
||||||
@@ -53,6 +63,20 @@ class AppStore {
|
|||||||
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
|
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get units(): Units {
|
||||||
|
return this.prefs.units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open settings from wherever the rider is, and remember where that was. */
|
||||||
|
openSettings(): void {
|
||||||
|
if (this.screen !== 'settings') this.settingsReturn = this.screen;
|
||||||
|
this.screen = 'settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSettings(): void {
|
||||||
|
this.screen = this.settingsReturn;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter the ride screen.
|
* Enter the ride screen.
|
||||||
*
|
*
|
||||||
@@ -83,7 +107,7 @@ class AppStore {
|
|||||||
* parallel set that can drift.
|
* parallel set that can drift.
|
||||||
*/
|
*/
|
||||||
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
|
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
|
||||||
const [ride, devices, samples, summary, recovered, controller] = await Promise.all([
|
const [ride, devices, samples, summary, recovered, controller, prefs] = await Promise.all([
|
||||||
api.rideState(),
|
api.rideState(),
|
||||||
api.deviceList(),
|
api.deviceList(),
|
||||||
api.sampleProfiles(),
|
api.sampleProfiles(),
|
||||||
@@ -93,12 +117,14 @@ class AppStore {
|
|||||||
// *change*, so a webview reload with both pods already connected would
|
// *change*, so a webview reload with both pods already connected would
|
||||||
// otherwise show two empty slots.
|
// otherwise show two empty slots.
|
||||||
api.controllerStatus(),
|
api.controllerStatus(),
|
||||||
|
api.preferences(),
|
||||||
]);
|
]);
|
||||||
this.ride = ride;
|
this.ride = ride;
|
||||||
this.devices = devices;
|
this.devices = devices;
|
||||||
this.samples = samples;
|
this.samples = samples;
|
||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
|
this.prefs = prefs;
|
||||||
// A ride already in progress (a reload, or an autostart) belongs on screen
|
// A ride already in progress (a reload, or an autostart) belongs on screen
|
||||||
// immediately — nobody wants to click past a device list mid-effort.
|
// immediately — nobody wants to click past a device list mid-effort.
|
||||||
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
|
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
|
||||||
@@ -205,6 +231,17 @@ class AppStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a preference and keep the screen honest about what stuck.
|
||||||
|
*
|
||||||
|
* Rust validates and returns the accepted value, so what lands in `prefs` is
|
||||||
|
* what is actually stored — never what the input box happened to contain.
|
||||||
|
*/
|
||||||
|
async savePrefs(next: Preferences): Promise<void> {
|
||||||
|
const accepted = await this.run(() => api.setPreferences(next));
|
||||||
|
if (accepted) this.prefs = accepted;
|
||||||
|
}
|
||||||
|
|
||||||
/** Leave the summary and set up for another ride. */
|
/** Leave the summary and set up for another ride. */
|
||||||
async newRide(): Promise<void> {
|
async newRide(): Promise<void> {
|
||||||
await this.run(() => api.reset());
|
await this.run(() => api.reset());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
InputAck,
|
InputAck,
|
||||||
LapSummary,
|
LapSummary,
|
||||||
Notice,
|
Notice,
|
||||||
|
Preferences,
|
||||||
ProfileView,
|
ProfileView,
|
||||||
Recovered,
|
Recovered,
|
||||||
RideFrame,
|
RideFrame,
|
||||||
@@ -139,6 +140,8 @@ export const api = {
|
|||||||
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
||||||
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
||||||
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
||||||
|
preferences: () => call<Preferences>('preferences'),
|
||||||
|
setPreferences: (prefs: Preferences) => call<Preferences>('set_preferences', { prefs }),
|
||||||
|
|
||||||
// profiles
|
// profiles
|
||||||
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dist, elev, hrZone, percentOf, powerZone, speed, toMass, fromMass } from './format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display layer is where a rider's preference is applied and nowhere else,
|
||||||
|
* so these tests exist to pin two properties: the conversions are exact, and a
|
||||||
|
* zone is never invented from a reference the rider has not given.
|
||||||
|
*/
|
||||||
|
describe('units', () => {
|
||||||
|
it('leaves metric alone and converts imperial exactly', () => {
|
||||||
|
expect(dist(42195, 'metric', 2)).toBe('42.20');
|
||||||
|
expect(dist(1609.344, 'imperial', 3)).toBe('1.000');
|
||||||
|
expect(elev(1000, 'metric')).toBe('1000');
|
||||||
|
expect(elev(304.8, 'imperial')).toBe('1000');
|
||||||
|
expect(speed(32.18688, 'imperial')).toBe('20.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a mass through the form and back', () => {
|
||||||
|
const kg = 72.5;
|
||||||
|
expect(fromMass(toMass(kg, 'imperial'), 'imperial')).toBeCloseTo(kg, 6);
|
||||||
|
expect(toMass(kg, 'metric')).toBe(kg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing rather than NaN when there is no value', () => {
|
||||||
|
expect(dist(null, 'metric')).toBe('—');
|
||||||
|
expect(elev(undefined, 'imperial')).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effort zones', () => {
|
||||||
|
it('places power on the Coggan boundaries', () => {
|
||||||
|
const ftp = 250;
|
||||||
|
expect(powerZone(100, ftp)?.short).toBe('Z1'); // 40%
|
||||||
|
expect(powerZone(175, ftp)?.short).toBe('Z2'); // 70%
|
||||||
|
expect(powerZone(220, ftp)?.short).toBe('Z3'); // 88%
|
||||||
|
expect(powerZone(250, ftp)?.short).toBe('Z4'); // 100%
|
||||||
|
expect(powerZone(290, ftp)?.short).toBe('Z5'); // 116%
|
||||||
|
expect(powerZone(350, ftp)?.short).toBe('Z6'); // 140%
|
||||||
|
expect(powerZone(500, ftp)?.short).toBe('Z7'); // 200%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a boundary value in the lower zone', () => {
|
||||||
|
// 55% of FTP is the top of zone 1, not the bottom of zone 2.
|
||||||
|
expect(powerZone(137.5, 250)?.short).toBe('Z1');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The whole contract of `ftpW: 0` — no reference, no colour. */
|
||||||
|
it('refuses to invent a zone without a reference', () => {
|
||||||
|
expect(powerZone(300, 0)).toBeNull();
|
||||||
|
expect(hrZone(150, 0)).toBeNull();
|
||||||
|
expect(percentOf(300, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not colour a rider who is not pedalling', () => {
|
||||||
|
expect(powerZone(0, 250)).toBeNull();
|
||||||
|
expect(powerZone(null, 250)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places heart rate as a fraction of maximum', () => {
|
||||||
|
expect(hrZone(100, 190)?.short).toBe('Z1'); // 53%
|
||||||
|
expect(hrZone(125, 190)?.short).toBe('Z2'); // 66%
|
||||||
|
expect(hrZone(150, 190)?.short).toBe('Z3'); // 79%
|
||||||
|
expect(hrZone(170, 190)?.short).toBe('Z4'); // 89%
|
||||||
|
expect(hrZone(185, 190)?.short).toBe('Z5'); // 97%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the percentage the zone came from', () => {
|
||||||
|
expect(percentOf(275, 250)).toBe('110% of FTP');
|
||||||
|
});
|
||||||
|
});
|
||||||
+141
-7
@@ -1,4 +1,5 @@
|
|||||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||||
|
import type { Units } from './types';
|
||||||
|
|
||||||
const EM_DASH = '—';
|
const EM_DASH = '—';
|
||||||
|
|
||||||
@@ -32,11 +33,6 @@ export function finishAt(secondsFromNow: number | null | undefined): string {
|
|||||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function km(metres: number | null | undefined, digits = 2): string {
|
|
||||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
|
||||||
return (metres / 1000).toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function num(v: number | null | undefined, digits = 0): string {
|
export function num(v: number | null | undefined, digits = 0): string {
|
||||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||||
return v.toFixed(digits);
|
return v.toFixed(digits);
|
||||||
@@ -51,8 +47,8 @@ export function axisLabel(unit: 'seconds' | 'metres'): string {
|
|||||||
return unit === 'metres' ? 'distance' : 'time';
|
return unit === 'metres' ? 'distance' : 'time';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
export function axisValue(unit: 'seconds' | 'metres', x: number, units: Units): string {
|
||||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
return unit === 'metres' ? `${dist(x, units, 1)} ${distUnit(units)}` : clock(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rssiBars(rssi: number): number {
|
export function rssiBars(rssi: number): number {
|
||||||
@@ -102,3 +98,141 @@ export function connectionText(
|
|||||||
return { label: 'Idle', tone: 'idle' };
|
return { label: 'Idle', tone: 'idle' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Units (FR-7.4's neighbour)
|
||||||
|
//
|
||||||
|
// SI everywhere behind this line. The engine computes in metres and km/h, the
|
||||||
|
// FIT file records in metres and km/h, and a rider who prefers miles changes
|
||||||
|
// *only* what the glass says. Every function here therefore takes the unit
|
||||||
|
// system as an argument rather than reading a module-level setting: pure
|
||||||
|
// functions are what let the ride screen redraw the moment the preference
|
||||||
|
// changes, and what keeps a recorded activity independent of a display choice.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KM_PER_MILE = 1.609344;
|
||||||
|
const M_PER_FOOT = 0.3048;
|
||||||
|
const KG_PER_LB = 0.45359237;
|
||||||
|
|
||||||
|
export function distUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mi' : 'km';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elevUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'ft' : 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speedUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mph' : 'km/h';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function massUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'lb' : 'kg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres → the rider's long-distance unit. */
|
||||||
|
export function dist(metres: number | null | undefined, units: Units, digits = 2): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
const km = metres / 1000;
|
||||||
|
return (units === 'imperial' ? km / KM_PER_MILE : km).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres of climbing → metres or feet. Always whole: nobody climbs 0.4 m. */
|
||||||
|
export function elev(metres: number | null | undefined, units: Units): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? metres / M_PER_FOOT : metres).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speed(kph: number | null | undefined, units: Units, digits = 1): string {
|
||||||
|
if (kph == null || !Number.isFinite(kph)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? kph / KM_PER_MILE : kph).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kilograms → kg or lb. For the settings form, which edits SI underneath. */
|
||||||
|
export function toMass(kg: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? kg / KG_PER_LB : kg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMass(value: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? value * KG_PER_LB : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Effort zones
|
||||||
|
//
|
||||||
|
// A watt is a fact; a zone is what it *costs you*, and it is the difference
|
||||||
|
// between a number and a number that means something. Every professional
|
||||||
|
// training app colours effort this way and this one did not, so the biggest
|
||||||
|
// figure on the ride screen was the same shade of white at 90 W and at 400 W.
|
||||||
|
//
|
||||||
|
// Two rules hold here:
|
||||||
|
//
|
||||||
|
// 1. **No reference, no zone.** `ftp` or `maxHr` of zero means the rider has
|
||||||
|
// not told us, and an invented threshold would paint every ride with a
|
||||||
|
// confident lie. `null` is the honest answer and the callers draw the
|
||||||
|
// plain number.
|
||||||
|
// 2. **Colour never carries it alone.** Every zone has a short name that is
|
||||||
|
// rendered beside the swatch, so the meaning survives a colour-blind
|
||||||
|
// rider, a sun-washed phone and a black-and-white screenshot.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Zone {
|
||||||
|
/** One-based, as riders say them: "zone 4". */
|
||||||
|
index: number;
|
||||||
|
/** What it is called in a training plan. */
|
||||||
|
name: string;
|
||||||
|
/** Short form for a chip beside a number: "Z4". */
|
||||||
|
short: string;
|
||||||
|
/** A CSS custom property reference, so themes stay in the stylesheet. */
|
||||||
|
colour: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coggan's seven power zones, as fractions of FTP. The boundaries are the
|
||||||
|
* conventional ones — 55 / 75 / 90 / 105 / 120 / 150 % — which is what makes a
|
||||||
|
* "zone 3 ride" here mean the same thing it means in the rider's plan.
|
||||||
|
*/
|
||||||
|
const POWER_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.55, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.75, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 1.05, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-4)' } },
|
||||||
|
{ upto: 1.2, zone: { index: 5, name: 'VO₂ max', short: 'Z5', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: 1.5, zone: { index: 6, name: 'Anaerobic', short: 'Z6', colour: 'var(--zone-6)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 7, name: 'Sprint', short: 'Z7', colour: 'var(--zone-7)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Five heart-rate zones as fractions of maximum. */
|
||||||
|
const HR_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.6, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.7, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.8, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 5, name: 'Maximum', short: 'Z5', colour: 'var(--zone-6)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function zoneFor(
|
||||||
|
table: { upto: number; zone: Zone }[],
|
||||||
|
value: number | null | undefined,
|
||||||
|
reference: number,
|
||||||
|
): Zone | null {
|
||||||
|
// Not pedalling is not zone 1. A stopped rider in "Recovery" green is the
|
||||||
|
// screen claiming an effort that is not happening.
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
const ratio = value / reference;
|
||||||
|
return table.find((row) => ratio <= row.upto)?.zone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerZone(watts: number | null | undefined, ftp: number): Zone | null {
|
||||||
|
return zoneFor(POWER_ZONES, watts, ftp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrZone(bpm: number | null | undefined, maxHr: number): Zone | null {
|
||||||
|
return zoneFor(HR_ZONES, bpm, maxHr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentage of the reference, for the subtitle under a zoned number. */
|
||||||
|
export function percentOf(value: number | null | undefined, reference: number): string | null {
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return `${Math.round((value / reference) * 100)}% of FTP`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,10 +70,15 @@ export interface RideSnapshot {
|
|||||||
profile_progress: number | null;
|
profile_progress: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which FTMS channel the computed load is sent on. */
|
||||||
|
export type LoadChannel = 'Gradient' | 'Power';
|
||||||
|
|
||||||
export interface RiderConfig {
|
export interface RiderConfig {
|
||||||
rider_kg: number;
|
rider_kg: number;
|
||||||
bike_kg: number;
|
bike_kg: number;
|
||||||
crr: number;
|
crr: number;
|
||||||
|
/** Fixed drivetrain and flywheel loss, watts. */
|
||||||
|
rolling_loss_w: number;
|
||||||
cda: number;
|
cda: number;
|
||||||
drivetrain_efficiency: number;
|
drivetrain_efficiency: number;
|
||||||
air_density: number;
|
air_density: number;
|
||||||
@@ -83,6 +88,33 @@ export interface RiderConfig {
|
|||||||
descent_load_floor_pct: number;
|
descent_load_floor_pct: number;
|
||||||
/** Development of the real gear through the Zwift Cog, m per crank rev. */
|
/** Development of the real gear through the Zwift Cog, m per crank rev. */
|
||||||
physical_development_m: number;
|
physical_development_m: number;
|
||||||
|
load_channel: LoadChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- src-tauri/src/settings.rs -----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the rider reads, not what is recorded. Every stored value and every FIT
|
||||||
|
* field stays SI whatever this says — the conversion is the last step before
|
||||||
|
* the glass.
|
||||||
|
*/
|
||||||
|
export type Units = 'metric' | 'imperial';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display preferences (FR-7.4's neighbours). Deliberately *not* part of
|
||||||
|
* `RiderConfig`: none of this reaches the physics, and `crates/core` is the
|
||||||
|
* frozen contract the engine and the FIT writer share.
|
||||||
|
*/
|
||||||
|
export interface Preferences {
|
||||||
|
/**
|
||||||
|
* Functional threshold power, watts. **Zero means unset**, and zones are then
|
||||||
|
* not drawn at all — a zone measured against a guessed FTP is worse than no
|
||||||
|
* zone, because it looks authoritative.
|
||||||
|
*/
|
||||||
|
ftpW: number;
|
||||||
|
/** Maximum heart rate, bpm. Zero means unset, same contract as `ftpW`. */
|
||||||
|
maxHrBpm: number;
|
||||||
|
units: Units;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SafetyLimits {
|
export interface SafetyLimits {
|
||||||
|
|||||||
Reference in New Issue
Block a user