7 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 9cbc5aa292 Point the config at 0.2.0, ahead of the tag
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 13m53s
Build & Release / Run tests (push) Successful in 6m40s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m41s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 16m44s
Build & Release / Build Arch package (push) Successful in 32m53s
Build & Release / Build Android APK (push) Successful in 21m20s
Build & Release / Create release (push) Successful in 12s
The tag stays the single source of truth — ci-set-version.sh rewrites
this key on a tag build and ci-android-version-code.sh derives the
versionCode from it. Committing the same values keeps a local or
sideloaded build from reporting 0.1.0, and keeps the checked-in number
greppable and honest between releases.

1000 + major*10000 + minor*100 + patch puts 0.2.0 at 1200, which is what
CI will compute for v0.2.0. The workspace Cargo.toml is deliberately
left alone: editing it invalidates Cargo.lock and breaks every --locked
build in the same run, to change a number that appears nowhere but the
binary's own metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:26 +02:00
dtourolleandClaude Opus 5 41574f694e Record what the interface now promises
Six rows, each of which the last three commits either implement or were
written to stop being ambiguous about.

FR-7.4a says what "configurable" has to mean: reachable from the app,
validated, and durable. FR-7.4 has been a Must since the first draft and
was read as satisfied by a command that existed — which is how every
rider ended up on a 105 kg default.

FR-7.5a puts units on the display side of the line and keeps recordings
SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and
no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only
against a reference the rider gave, and gradient as a shape with the
next block announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:18 +02:00
dtourolleandClaude Opus 5 0c757a4a15 Colour effort by zone, and read distance in the rider's units
A watt is a fact; a zone is what it costs you. The biggest number on the
ride screen was the same shade of white at 90 W and at 400 W, which is a
thing no training app has done in fifteen years.

Zones, with two rules:

- **No reference, no zone.** An unset FTP draws the plain number. A zone
  measured against a guessed threshold would paint every ride with a
  confident lie.
- **Colour never carries it alone.** "Z4" renders beside the swatch, so
  the meaning survives a colour-blind rider, a phone in direct sun and a
  black-and-white screenshot.

Read off the rolling average, not the instantaneous watts: at 4 Hz the
raw figure crosses two boundaries every pedal stroke, and a colour that
strobes is worse than no colour. Not pedalling is not zone 1.

Units are a display preference 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. `format.ts` takes the unit
system as an argument rather than reading a module-level setting — pure
functions are what let every readout redraw the moment it changes. The
`km` helper is gone rather than left beside `dist`, so there is no
second way to format a distance that ignores the preference.

Rust's block labels lose their baked-in kilometres. The block already
carries start_x and end_x and the frontend renders that span in the
rider's units; a kilometre in the text sat inside a sentence saying
miles everywhere else.

Also on the ride screen:

- The gradient gets a wedge beside the number. A signed decimal has to
  be read; a slope is seen. Exaggerated and clamped, because a true-scale
  6% is indistinguishable from 3% at 40 px wide.
- What is coming, from the profile's own block list — "2.1 km at 12% in
  460 m". The chart says where the rider is; what is about to happen is
  what decides whether to shift now. The data was already computed
  Rust-side and thrown away here. Close in, the small unit reads better
  than a fraction of the big one.
- Mode and target merge into one chip. They are a single fact, and
  splitting them spent a chip of header width repeating the word
  "target".
- The pod chip no longer reports a missing `+` pod while the `−` pod is
  connected. The `−` pod relays its twin, so that is the intended
  configuration — the ride screen was calling it a fault, contradicting
  the device screen two keystrokes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:11 +02:00
dtourolleandClaude Opus 5 cdff678167 Pair roles, not radios
The device screen answered the wrong question. A rider arriving at it
has three: is the trainer on, will it take control, is the Click awake.
A list sorted by signal strength answers none of them without being
read, and it opened with a MAC address on every row — a number nobody
types, acts on, or can tell from the one below it, and on Android a
randomised one that changes anyway.

So the setup comes first, as slots: Trainer, Shifter, Heart rate. Each
says what is filling it, what is standing in the way, and the one action
that would fix it. The list is still everything FR-9.1 asks for; it is
just no longer the first thing to read.

- FR-9.3 lives in the trainer tile: green only when connected *and*
  controlling, because a trainer that is attached and uncontrollable
  will not move the resistance. Its action then is Reconnect, since FTMS
  control is requested once at connect time — offering "acquire control"
  as a button the rider had failed to press would be a lie about what
  the protocol does.
- Peripherals of no known role fold into a collapsed list. A scan in a
  flat picks up a dozen phones and a TV, and each was a full-height row
  between the rider and their trainer. The fold opens itself when no
  trainer has been identified at all, because a trainer that does not
  advertise FTMS until something connects classifies as unknown — that
  is the one case this must not swallow.
- Kind is a glyph, identity is the name. Where two rows would otherwise
  be indistinguishable — a pair of pods, a room of "(no name)" — four
  characters of the address disambiguate them and nothing more.
- Forget appears only on remembered devices. Forgetting a device that
  was never remembered is a no-op the rider had to read past on every
  row.
- The Click panel is a repair manual, so it appears when there is
  something to repair. Its five-step drill used to be `open` by default
  in exactly the state riders hit most; it is now folded behind a
  summary, and its lede renders only while it is saying something the
  tile cannot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:10 +02:00
dtourolleandClaude Opus 5 4269c5a446 Remember the rider, not only the hardware
`RiderConfig` lived in `RideInputs` and nowhere else, and nothing in the
UI ever called `set_rider_config`. So every ride was ridden as the
struct's own default — a 105 kg rider on an 8 kg bike — with no way to
say otherwise short of editing the source. Mass is not a preference: it
sets the speed a given power produces, the ETA that follows from it, the
calorie estimate, and how a 6% ramp feels. FR-7.4 is a Must, and a
command the UI never calls does not satisfy it.

- settings.rs persists rider config, safety limits and display
  preferences to app_data_dir()/settings.json, written atomically and
  read back before the first tick, so no snapshot is ever computed
  against the default. Advisory like known.rs: an unreadable file costs
  the rider their setup, never their ride.
- A stored file is refused *whole* if it fails the same checks the
  commands apply. It may predate a tightened bound or have been edited
  by hand, and a zero mass reaching the engine divides by itself on the
  next tick.
- The commands validate with instructions rather than codes — "CdA must
  be between 0.1 and 1.5 m² — a road position is about 0.32" — because
  this is now a form a rider fills in, not a struct only I ever touched.
- Preferences (FTP, maximum heart rate, units) are Tauri-side, not in
  `RiderConfig`. None of it reaches the physics, and crates/core is the
  frozen contract the engine and the FIT writer share. Zero is a real
  answer for both references and means "no zones", not "unset and
  guessed at".
- SettingsScreen commits on field-exit and reseats every input from what
  Rust returned, so a rejected value can never sit on screen looking
  accepted. Weight, FTP and units are on top; the eight settings with a
  defensible default are folded away.
- Reachable on `,` from any screen, returning to whichever screen opened
  it. Setup swallows the ride controls while it is up — a stray arrow
  key while reading the form must not trim the gradient of a ride
  happening behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:14:46 +02:00
dtourolleandClaude Opus 5 7497a5d602 Reconnect to remembered hardware instead of pairing every launch
`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as
long as the process. Every launch started from nothing: find the trainer,
press Connect, find the strap, press Connect, and only then ride. FR-1.5
has been a Should since the beginning and was never actually true.

It is a file now — devices.json in the app data directory, written when a
link actually comes up rather than when Connect is pressed. A Connect the
hardware then refuses is not a pairing, and writing one down would mean a
trainer the rider gave up on getting chased on every launch afterwards.
Forgetting is recorded too, in its own list: absent means never seen,
forgotten means the rider looked at this device and said no, and
auto-connect has to keep honouring that on the next launch as well. The
file is advisory — a corrupt one costs auto-connect, never a ride.

Auto-connect is driven by the scan rather than fired once at startup. The
hardware is asleep at startup — a trainer wakes when the cranks turn, a
strap when it is put on (A-4) — so a remembered device is reconnected the
moment it advertises, through the same path the rider's own click takes,
scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY
cooldown and cleared when the link comes up or the rider connects by
hand: an app that never stops trying can never honestly say it has
stopped (FR-1.11). A device disconnected by hand is left alone for the
rest of the session, since a disconnect that undoes itself two ticks
later is not a disconnect.

Pods now prefer the pod we know. Every Click advertises the same name and
the same type byte, so before this a rider whose partner was warming up
in the next room got whichever pod woke first. With nothing of that kind
remembered anything still goes, or there could never be a first pairing.

And the pair is one pod, not two. Confirmed on this hardware 2026-08-21:
pairing the − pod alone delivers all ten buttons, its twin's included —
which §2.3.1 had established for the frames but not for the pairing. So
take_plus_pod holds the + pod back while a known − pod may merely be
asleep, and connect_controller with no pod named means the − pod rather
than both. The wait is bounded by PLUS_GRACE, because a flat − pod should
cost the rider a D-pad and not a controller, and Buttons is untouched: it
is what makes the handover between the two configurations invisible.

Not yet tested against real hardware — nothing was advertising here. The
store, the retry budget and the pod-preference rules have unit tests, and
a seeded devices.json was confirmed to load and seed the − pod at launch,
but the connect path itself waits for a ride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:36:34 +02:00
dtourolleandClaude Opus 5 5dff2500e2 Open picked files through the content resolver, not std::fs
Loading a GPX on Android failed for every file in the picker. The dialog
plugin fires ACTION_GET_CONTENT, which returns a `content://` URI, and
`load_profile_from_path` handed that straight to `std::fs::read_to_string`
— "no such file or directory" for a file the rider is looking at. Picking
from Nextcloud makes it plainer: a document provider backed by a server
may have no local file at all until the resolver opens the stream, so
there was never a path to find.

So the command now takes a `FilePath` and reads through tauri-plugin-fs,
which opens a path directly and a URI via the resolver. The plugin is
here for `FsExt` alone; nothing in ui/ calls its commands, so the
capabilities are unchanged.

Two things that were derived from the filename can no longer be:

  - GPX is detected by content. A document id need not contain a name,
    let alone an extension. No YAML profile begins with `<`.
  - The route name falls back to the GPX's own <name>. Providers over
    real storage encode the filename in the last segment, but an opaque
    row id would have made a wretched route name.

save_fit had the same bug on the export side — PathBuf::from on a
save-dialog URI — and now writes down a resolver descriptor when handed
one.

Note for anyone rebuilding locally: gen/android/tauri.settings.gradle is
autogenerated and lists each plugin's Android project, so the new
plugin's Kotlin only reaches the APK after `cargo tauri android init` and
scripts/sync-android-sources.sh. CI already runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:08:36 +02:00
28 changed files with 3525 additions and 641 deletions
Generated
+1
View File
@@ -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
View File
@@ -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.110.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.910.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.2510.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.110.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.2510.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.1210.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) |
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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()));
+310 -26
View File
@@ -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,9 +330,16 @@ 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() {
if is_ours(&self.known, kind, &id) {
self.controller.pod_seen(pod, &id); self.controller.pod_seen(pod, &id);
} }
}
out.push(DeviceInfo { out.push(DeviceInfo {
kind, kind,
name: d.label(), name: d.label(),
@@ -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);
+311
View File
@@ -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());
}
}
+28
View File
@@ -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());
+10 -6
View File
@@ -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(),
} }
} }
+343
View File
@@ -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.0020.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.1700.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());
}
}
+3
View File
@@ -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,
+2 -2
View File
@@ -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."
+27
View File
@@ -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'}
+19
View File
@@ -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;
+208 -231
View File
@@ -1,27 +1,33 @@
<script lang="ts"> <script lang="ts">
/** /**
* The Zwift Click, as two pods (FR-1.4, FR-9.19.2). * The Zwift Click (FR-1.4, FR-9.19.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,
the badge above has already said it. -->
{#if !scanning} {#if !scanning}
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button <p class="lede"><strong>The scan is off</strong> pods will not be picked up.</p>
on each pod. {:else if plusOnly}
{:else if bothConnected} <p class="lede">
Both pods are connected and will reconnect on their own if one drops. Running on the <strong>+ pod alone</strong>: shift down with <span class="kbd">Y</span>.
{:else} Press a button on the pod for the D-pad.
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
running scan connects it as soon as it does — no need to press anything here.
{/if}
</p> </p>
{:else if !minusLive}
<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
against the one printed on the hardware at arm's length. -->
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span> <span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
<span class="what">
<span class="label">{pod.symbol} pod</span> <div class="what">
<span class="purpose">{PURPOSE[pod.pod]}</span> <span class="pod-name">{pod.symbol} pod</span>
</span> <span class="purpose">
<span class="state {STATE_TONE[pod.state]}"> {#if dormant}
<span class="dot"></span>{STATE_TEXT[pod.state]} <!-- 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>
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
</div>
<div>
<dt>Buttons seen</dt>
<!-- Connected and silent looks exactly like working until you press <!-- Connected and silent looks exactly like working until you press
something, so the count is the honest test of the link. --> something, so the count is the honest test of the link. -->
<dd> {#if pod.state === 'connected'}
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`} <span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
{#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}
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
{:else if pod.state === 'connected'}
<p class="note">
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
</p>
{/if} {/if}
</span>
{#if pod.contradicted} <span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
<p class="note tone-warn"> <span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
This pod sent the other paddle. If the pair is the wrong way round, swap them. </span>
</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 pods 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
+1
View File
@@ -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)'],
+6 -4
View File
@@ -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}
+28
View File
@@ -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="head">
<span class="label">{label}</span> <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;
+174 -23
View File
@@ -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">
<!-- 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} /> <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,19 +403,20 @@
</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}
/> />
<div class="grade-cell">
<Readout <Readout
label="Gradient" label="Gradient"
value={signed(gradient, 1)} value={signed(gradient, 1)}
@@ -335,6 +425,13 @@
colour={gradeColour} colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null} 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 -3
View File
@@ -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) {
+448
View File
@@ -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 170175 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>
+6 -5
View File
@@ -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)"
/> />
+39 -2
View File
@@ -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());
+3
View File
@@ -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 }),
+70
View File
@@ -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
View File
@@ -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`;
}
+32
View File
@@ -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 {