Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89920e782d | ||
|
|
8964a0fd74 | ||
|
|
e8d384e6fc | ||
|
|
c1c6ca390c | ||
|
|
b1e6c08d07 | ||
|
|
34861e4e04 | ||
|
|
9ab5b5530b | ||
|
|
4e400cdd3b | ||
|
|
135da73e92 | ||
|
|
92ce4ba2ae | ||
|
|
53e7cd05fc | ||
|
|
bbd11757ee | ||
|
|
9b1749d959 | ||
|
|
e589086078 | ||
|
|
9cbc5aa292 | ||
|
|
41574f694e | ||
|
|
0c757a4a15 | ||
|
|
cdff678167 | ||
|
|
4269c5a446 | ||
|
|
7497a5d602 | ||
|
|
5dff2500e2 |
Generated
+150
@@ -222,6 +222,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -255,6 +261,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -307,6 +314,8 @@ dependencies = [
|
||||
"bikecontrol-core",
|
||||
"btleplug",
|
||||
"futures",
|
||||
"p256",
|
||||
"rand_core",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -640,6 +649,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -723,6 +738,18 @@ version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -892,6 +919,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -961,6 +998,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1102,6 +1140,25 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf",
|
||||
"rand_core",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
@@ -1211,6 +1268,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1504,6 +1571,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1636,6 +1704,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
@@ -1730,6 +1809,24 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2705,6 +2802,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2915,6 +3022,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -3007,6 +3123,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3252,6 +3377,19 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3624,6 +3762,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -5526,6 +5670,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
||||
+422
-5
@@ -1,9 +1,18 @@
|
||||
# BikeControl — Requirements Specification
|
||||
|
||||
**Status:** Draft v0.4
|
||||
**Date:** 2026-08-05
|
||||
**Status:** Draft v0.5
|
||||
**Date:** 2026-08-21
|
||||
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
||||
|
||||
> **v0.5 changes — the ride leaves the app.** §5.10 adds two ways for a finished activity
|
||||
> to reach somewhere else: an Android **share sheet**, which needs no credential and is the
|
||||
> only route into Garmin Connect; and an opt-in **upload to Runalyze**, chosen because its
|
||||
> auth is a token the rider pastes in rather than an OAuth flow with a client secret to hide.
|
||||
> Strava is deliberately not in scope (RISK-11). This is the first network traffic the app
|
||||
> has ever originated, so §1.2's non-goal and NFR-6 are both amended to say precisely what is
|
||||
> and is not permitted — the short version being that nothing on the network is ever between
|
||||
> a rider and a ride.
|
||||
|
||||
> **v0.4 changes — the input decision is reversed.** Further research found that the Click
|
||||
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
|
||||
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
|
||||
@@ -41,7 +50,10 @@ D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and pro
|
||||
|
||||
### 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.
|
||||
- ANT+ support (§10.1).
|
||||
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
||||
@@ -188,6 +200,37 @@ 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 —
|
||||
> otherwise the connection screen asks the rider to swap a pair that is filed correctly.
|
||||
|
||||
> **The `+` pod is the controller — measured 2026-08-27, and this reverses the note
|
||||
> below.** Both pods on the same bench, minutes apart, `probe unlock` with no writes:
|
||||
>
|
||||
> | | `−` pod | `+` pod |
|
||||
> |---|---|---|
|
||||
> | button reporting stops | **~51 s**, every session | **never** — 133 s, 78 paddle edges |
|
||||
> | status flag `0 → 1` | yes, at the cliff | **never flipped** |
|
||||
> | key offers | 2 per session | **none** |
|
||||
>
|
||||
> The `−` pod does relay more when it works — all ten buttons, its twin's included — but
|
||||
> "when it works" is under a minute without a Zwift blessing in the last day. The `+` pod
|
||||
> alone is a complete shifter: its paddle shifts up and `Y` shifts down, both of which the
|
||||
> app already maps. Shifting is the thing a ride cannot do without, so the app now pairs the
|
||||
> `+` pod and the `−` pod is the substitute. `take_minus_pod` is the old rule with the pods
|
||||
> exchanged; opening both is still the configuration that breaks the `−` pod's own paddle,
|
||||
> so it is still one link.
|
||||
>
|
||||
> This also settles A-2 from the other end. The keep-alive that "removes the daily unlock,
|
||||
> but only for the right controller" removes nothing — the right controller never needed it.
|
||||
|
||||
> **One link is the whole controller — confirmed 2026-08-21.** Pairing the `−` pod *alone*
|
||||
> delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and
|
||||
> face buttons relayed from its twin. There is nothing a second link adds, and there is one
|
||||
> thing it takes away — connected as a pair, the `−` pod stops reporting its own paddle.
|
||||
>
|
||||
> So the app pairs the `−` pod and stops there (`controller::take_plus_pod`). The `+` pod is a
|
||||
> **substitute, not a second half**: it is connected on sight only when no `−` pod is known, or
|
||||
> when a known one has been unreachable for `PLUS_GRACE`, so a flat `−` pod costs the rider a
|
||||
> D-pad rather than a controller. `Buttons` stays exactly as it is — it is what makes the
|
||||
> handover between the two configurations invisible.
|
||||
|
||||
### 2.3.2 The D100's own Zwift service — telemetry, not shifting
|
||||
|
||||
The trainer answers the same handshake (`RideOn 00 09` → `RideOn 02 00`) on the original
|
||||
@@ -216,7 +259,96 @@ open-ended and not obviously safe. **Virtual shifting is therefore emulated app-
|
||||
> **three minutes** with button edges still arriving, so this path does not appear to hit
|
||||
> that timeout — but a long ride is the real test.
|
||||
|
||||
**Encryption — not required.** ✅ **A-3 holds for the v2.** The pod completed the handshake
|
||||
### 2.3.3 The `0xff` key-exchange frames — decoded 2026-08-27 ⚠ **A-3 is dead**
|
||||
|
||||
Captured on the tablet during a real ride. The `−` pod connected, streamed cleartext
|
||||
buttons for **fifty seconds**, and then stopped reporting its paddles while the link stayed
|
||||
up and battery frames continued. Two frames bracket the moment:
|
||||
|
||||
| Frame | Meaning |
|
||||
|-------|---------|
|
||||
| `ff 03 00` + protobuf | **key offer.** field 1 = 33 bytes, a *compressed* P-256 point (`02`/`03` prefix, fresh each session); field 2 = varint `0x02030000`, constant; field 3 = 40 or 60 bytes, contents unknown |
|
||||
| `ff 05 00` + protobuf | **status.** field 93 nests: `.1` = device id ASCII, `.2` = flag **0 → 1**, `.3` = **15 → 900**, `.4` = 8, `.5` = 9, `.6` = a small counter |
|
||||
|
||||
The pod sent a key offer at +4 s, a **second** key offer 242 ms after the last button frame,
|
||||
and the status flag flipped `0 → 1` with `.3` going `15 → 900` in the same breath. We never
|
||||
answered either offer.
|
||||
|
||||
**So A-3 does not hold.** The claim was that the pod "streams button and battery events in
|
||||
cleartext and the app has never sent a key" — true, but only for about a minute. The earlier
|
||||
note that "our unencrypted sessions ran past three minutes" and the open question of whether
|
||||
the daily unlock is needed on this path are both answered: it is, and this is the mechanism.
|
||||
|
||||
> **This is not the Play handshake of §3.5.** That one is `RideOn 01 02` + a *raw 64-byte*
|
||||
> key written to Sync RX, answered by `RideOn 00 09` + 64 bytes. Our v2 offers a
|
||||
> **compressed 33-byte** key inside protobuf on the **async** channel under type `0xff`, with
|
||||
> two fields the Play description has no room for. Implementing Makinolo's spec verbatim
|
||||
> would be implementing a different device's protocol.
|
||||
|
||||
> **The v2 answers the Play handshake — 2026-08-27.** Sent `RideOn 01 02` plus a raw
|
||||
> 64-byte key (the §3.5 format, no protobuf envelope), the pod replied
|
||||
> `52 69 64 65 4f 6e 02 03` followed by **64 zero bytes** — the Play reply shape, with the
|
||||
> key zeroed. The four protobuf-shaped variants all drew `0x3e {1: 255, 2: 5}` instead. So
|
||||
> the device understands the documented handshake and declines to complete it, which is a
|
||||
> refusal rather than a misunderstanding.
|
||||
>
|
||||
> Immediately afterwards the notification stream went to **all-zero frames** — including the
|
||||
> 7-byte button frames, at their usual ~10 Hz. Whether that is the pod encrypting against a
|
||||
> key it never agreed, or a firmware path nobody meant to reach, is unknown. It is
|
||||
> recoverable: the pod came back on its own by the next session.
|
||||
>
|
||||
> **Settled with one write, 2026-08-27.** `--variant play-rideon` on a clean session: key
|
||||
> offer at +5.31 s, our `RideOn 01 02` + 64-byte key sent, the pod's `RideOn 02 03` + 64
|
||||
> zero bytes back at +5.40 s, and the stream all zeros from +5.49 s. That single frame
|
||||
> causes both. The zero frames are **7 bytes** — the exact length of a button frame — so the
|
||||
> pod is emitting correctly-shaped frames with the contents blanked, which is a refusal
|
||||
> mode rather than a crash.
|
||||
>
|
||||
> **The gate is a credential, not a format.** The v2 understands the handshake, answers in
|
||||
> the right shape, and returns a zeroed key. No framing we can construct changes that: we
|
||||
> tried the protobuf envelope four ways and the Play format verbatim, and the device
|
||||
> declined all five in two distinct, deliberate ways. What the Zwift app presents that we
|
||||
> cannot — an account token, a signed blob, or a correctly *computed* field 3 — is the
|
||||
> thing being checked, and it cannot be inferred from the device half of the conversation.
|
||||
>
|
||||
> **So this line is closed until someone captures the app's side.** One HCI snoop of one
|
||||
> real unlock would settle it; nothing short of that will. Until then the workarounds stand:
|
||||
> the `+` pod, which reportedly never needed the blessing, or re-linking the `−` pod inside
|
||||
> its ~50 s window.
|
||||
|
||||
> **The command channel works; a keep-alive on it does not help — 2026-08-27.** Writing
|
||||
> `00 08 00` gets a `0x3c` reply carrying device metadata: name `Zwift Click`, `0B-34C45903A18E`,
|
||||
> version `B.0`, type byte `0x0B`, address. Sent every 10 s across a full run, the pod
|
||||
> answered every one — **including after the cliff**, while refusing to report a single
|
||||
> button. Last button frame 51.62 s, `flag=1 timer=900` at 51.75 s, nothing after.
|
||||
>
|
||||
> That is the sharpest framing of the fault so far: the link is healthy, the device is
|
||||
> responsive, and it is withholding *one capability* on a timer. A periodic message is not
|
||||
> what re-arms it.
|
||||
|
||||
> **Do not send `play-rideon` casually.** It reliably blanks the pod's output stream and
|
||||
> costs a recovery wait. It is kept in the probe because reproducing a finding matters, not
|
||||
> because it is safe to leave running.
|
||||
|
||||
> **Attribution is not yet sound.** All five variants went out inside six seconds and every
|
||||
> reply arrived in one burst at +11.37 s, so which write triggered the zeros is not
|
||||
> established. `probe unlock --variant <name>` sends exactly one per connection, which is
|
||||
> how that gets settled.
|
||||
|
||||
> **The stuck state is not permanent.** A pod that had opened three consecutive sessions
|
||||
> already past the cliff — `flag=1` from the first status frame, the escalated 60-byte
|
||||
> offer, and a hello bitmask of `ffc3ffff1f` with the `+` paddle bit pinned — came back
|
||||
> clean: `flag=0`, idle bitmask, `−` paddle reporting normally. No battery pull; the unit is
|
||||
> sealed and was never opened.
|
||||
|
||||
> **The missing half.** Every frame above is device → app. Nothing in any capture shows what
|
||||
> a *working* client writes back, and that is precisely what a responder has to send. It
|
||||
> cannot be inferred from these five frames. Getting it means capturing both directions of a
|
||||
> real unlock — Android's Bluetooth HCI snoop log against the official Zwift app — which is
|
||||
> the next action, not more guessing.
|
||||
|
||||
**Encryption — was thought not required.** ⚠ **Superseded by §2.3.3; A-3 held only for the
|
||||
first minute of a session.** The pod completed the handshake
|
||||
and streamed button and battery events in cleartext, and the app has never sent a key. The
|
||||
ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no
|
||||
crypto crate has been added.
|
||||
@@ -359,6 +491,7 @@ is a deliberate trade of resilience for simplicity.
|
||||
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
||||
| Charts | `uPlot` | Canvas, built for streaming series |
|
||||
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
|
||||
| HTTP | `reqwest` + `rustls-tls` | Activity upload only (FR-10), and nothing else in the app opens a socket. **Not** `native-tls`: OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is already the choice for the Click session |
|
||||
|
||||
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
|
||||
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
|
||||
@@ -372,6 +505,7 @@ bikecontrol/
|
||||
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
||||
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
||||
│ ├── fit/ # FIT encoder (ported from §3.2)
|
||||
│ ├── sync/ # activity upload — one trait, one impl per service (§5.10)
|
||||
│ └── probe/ # CLI for protocol discovery (Phase 0)
|
||||
├── src-tauri/ # Tauri shell, commands, event bridge
|
||||
└── ui/ # web frontend
|
||||
@@ -390,6 +524,8 @@ bikecontrol/
|
||||
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
|
||||
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
|
||||
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
|
||||
| FR-1.5a | Remembering survives the process: trainer, `−` pod and heart rate monitor are written to `devices.json` in the app data directory when a link actually comes up, and a device the rider forgets is recorded as refused rather than merely dropped | Should |
|
||||
| FR-1.5b | Auto-connect is driven by the scan, not by startup — the hardware is asleep at launch (A-4), so a remembered device is reconnected the moment it advertises. Bounded by `AUTO_ATTEMPTS`, so "gave up" (FR-1.11) is not contradicted two ticks later, and suspended for any device the rider disconnected by hand this session | Should |
|
||||
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
|
||||
| FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must |
|
||||
| FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
|
||||
@@ -570,6 +706,8 @@ blocks:
|
||||
| FR-7.2 | Integrate speed into distance, which drives route position | Must |
|
||||
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
|
||||
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
|
||||
| FR-7.4a | **Configurable means reachable and durable.** A setup screen in the app edits them, Rust validates and refuses values that make the engine produce nonsense, and the result is written to `settings.json` in the app data directory so it survives the process. A command the UI never calls does not satisfy FR-7.4 — it left every rider on the 105 kg default | Must |
|
||||
| FR-7.5a | Units are a **display preference** (metric or imperial), applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to | Should |
|
||||
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
|
||||
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
|
||||
|
||||
@@ -610,6 +748,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.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
|
||||
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
|
||||
| FR-9.20 | **The setup is presented as roles, not radios.** Trainer, shifter and heart rate each get a slot that says what is filling it, what is missing and the one action that would fill it; the device list of FR-9.1 sits below them. A peripheral of no known role is listed but folded away — unless no trainer has been identified at all, since a trainer that does not advertise FTMS until it is connected classifies as unknown | Should |
|
||||
| FR-9.21 | **No MAC addresses on screen.** An address is not something a rider reads, types or acts on, and on Android it is randomised. The device kind is a glyph and the identity is the name; where two rows would otherwise be indistinguishable, four characters of the address disambiguate them | Should |
|
||||
|
||||
**Ride screen**
|
||||
|
||||
@@ -627,6 +767,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.17 | **Screen size is a measured input to the layout, not an assumption.** Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own | Must |
|
||||
| FR-9.18 | **Legibility floors are absolute.** No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go | Must |
|
||||
| FR-9.22 | **Effort is shown as a zone where the rider has given a reference.** Power against FTP (Coggan's seven) and heart rate against maximum (five), applied to the rolling average rather than the instantaneous figure so the colour does not strobe at 4 Hz. **No reference, no zone**: an unset FTP draws the plain number rather than a zone measured against a guess. Colour never carries the meaning alone — the zone's short name is rendered beside it | Should |
|
||||
| FR-9.23 | **Gradient is a shape as well as a number**, and the profile's next block is announced with the distance or time to it — what is coming decides whether to shift now | Should |
|
||||
| FR-9.19 | **Touch is a first-class input on Android.** Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap | Must |
|
||||
|
||||
**Post-ride**
|
||||
@@ -638,6 +780,242 @@ tests, and removes dependence on the trainer's internal mass assumptions.
|
||||
|
||||
---
|
||||
|
||||
### 5.10 Sharing and upload (FR-10)
|
||||
|
||||
Everything in this section operates on a file that already exists. `stop_ride` writes the
|
||||
activity and *then* emits the summary (FR-8.2), so by the time a rider can ask for any of
|
||||
this, the ride is on disk and safe. Nothing here touches the ride loop, the BLE stack or
|
||||
the recorder, and nothing here may sit between a rider and a ride: the app stays fully
|
||||
usable with the network down and no account anywhere (NFR-6).
|
||||
|
||||
Two destinations, deliberately different in kind.
|
||||
|
||||
**Share** (FR-10.1–10.8) hands the file to another app on the phone. No network code, no
|
||||
credential, no service-specific knowledge and no terms of service on our side — the rider
|
||||
picks Strava, Garmin Connect, Dropbox or email from the system chooser and that app does
|
||||
the rest. It is the *only* route into Garmin Connect, whose Connect Developer Program is
|
||||
partner-approval-only and effectively closed to new applicants, and it is the cheapest
|
||||
thing in this document that a rider would use every day.
|
||||
|
||||
**Upload** (FR-10.9–10.24) sends the file to one named service over HTTPS. That service is
|
||||
**Runalyze**, chosen for what it does *not* require: no OAuth, no registered application,
|
||||
no client secret to hide inside an open-source binary, and no per-application rate ceiling
|
||||
shared across every install. Strava has all four of those problems and is out of scope
|
||||
here — RISK-11. Runalyze is the cheap case that proves the trait, the queue and the failure
|
||||
classification; a second service should then cost one new impl and nothing else.
|
||||
|
||||
#### 5.10.1 Share the activity to another app (Android)
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| FR-10.1 | Share the finished activity to any app on the device that accepts a file, from the post-ride summary (FR-9.13) and from the recovered-ride list (FR-8.4) | Must |
|
||||
| FR-10.2 | The share hands out a `content://` URI from the app's own `FileProvider` with `FLAG_GRANT_READ_URI_PERMISSION`, scoped to the receiving app for the life of the intent. The FIT is never copied out of app-private storage to make this work | Must |
|
||||
| FR-10.3 | The receiving app sees the ride's own filename — `2026-08-21T18-42-10.fit` — not a temporary name. A rider looking at Garmin Connect a week later must be able to tell which ride is which | Must |
|
||||
| FR-10.4 | Presented with `Intent.createChooser`, so the rider always gets a picker and Android never silently commits them to a default | Must |
|
||||
| FR-10.5 | The share *reads* the automatic copy in the rides directory. Nothing is moved and nothing is deleted, so a dismissed chooser costs nothing — the same contract `save_copy` already holds for FR-9.14 | Must |
|
||||
| FR-10.6 | Any ride still on disk can be shared, not only the one that just ended | Should |
|
||||
| FR-10.7 | If nothing on the device accepts the file, say so plainly and point at FR-9.14's save dialog as the way out | Must |
|
||||
| FR-10.8 | Desktop is out of scope. FR-9.14's save-to-a-chosen-location *is* the desktop share; there is no `xdg-open` handoff and no portal | — |
|
||||
|
||||
**Mechanism.**
|
||||
|
||||
The manifest already declares a `FileProvider` under `${applicationId}.fileprovider` —
|
||||
`tauri-plugin-dialog` put it there — but its generated `res/xml/file_paths.xml` grants only
|
||||
`<external-path>` and `<cache-path>`. The rides directory is neither: `app_data_dir()` on
|
||||
Android is internal, app-private storage. `file_paths.xml` therefore needs a `<files-path>`
|
||||
entry covering `rides/`, and per NFR-13 that file must become a **tracked** source under
|
||||
`src-tauri/android/src/main/res/xml/`, copied in by `scripts/sync-android-sources.sh` —
|
||||
otherwise the next `tauri android init` regenerates the two-entry version over it. This is
|
||||
precisely the silent failure NFR-13 exists for: an APK that builds, installs, runs, and
|
||||
throws `IllegalArgumentException: Failed to find configured root` the first time anyone
|
||||
presses Share.
|
||||
|
||||
The share itself is one Kotlin method on `MainActivity`, called over JNI from `android.rs`
|
||||
exactly as `requestBluetoothEnable` already is, plus one Tauri command in front of it:
|
||||
|
||||
```text
|
||||
ACTION_SEND
|
||||
type = <OQ-11>
|
||||
EXTRA_STREAM = FileProvider.getUriForFile(ctx, "$packageName.fileprovider", fit)
|
||||
addFlags(FLAG_GRANT_READ_URI_PERMISSION)
|
||||
→ startActivity(Intent.createChooser(intent, …))
|
||||
```
|
||||
|
||||
`startActivity` may only be called from the UI thread, and the thread hop belongs in Kotlin
|
||||
for the reason `requestBluetoothEnable` documents: Rust has no cheap way to get there.
|
||||
|
||||
The MIME type is the one part that cannot be settled from a desk. `application/vnd.ant.fit`
|
||||
is the registered type; `application/octet-stream` is what a good many Android apps actually
|
||||
filter on. Guessing wrong yields an *empty chooser*, not an error — OQ-11, RISK-10.
|
||||
|
||||
#### 5.10.2 Upload the activity to Runalyze
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| FR-10.9 | Upload a finished activity to Runalyze | Must |
|
||||
| FR-10.10 | The only credential ever asked for is a personal API token, generated by the rider at `runalyze.com/settings/personal-api` and pasted in. No password, no OAuth, no account of ours | Must |
|
||||
| FR-10.11 | Upload is **opt-in and off by default**. An installation that has never been given a token originates no network traffic of any kind, ever (NFR-6) | Must |
|
||||
| FR-10.12 | With auto-upload on, a finished ride is queued at the moment the activity is written — not when the rider happens to reach a screen | Must |
|
||||
| FR-10.13 | With it off, the rider can still upload a chosen ride on demand | Must |
|
||||
| FR-10.14 | The upload never blocks the summary, the UI, or the next ride. A rider who finishes and immediately starts again waits on nothing | Must |
|
||||
| FR-10.15 | The queue survives process death, a force-stop and a flight-mode ride. A ride queued in a basement uploads when the phone next sees a network, on a later launch if that is when it happens | Must |
|
||||
| FR-10.16 | Retries are bounded and backed off; the backoff is persisted, so a restart cannot reset it into a hot loop | Must |
|
||||
| FR-10.17 | Errors are classified *retryable* (DNS, TLS, timeout, 5xx, 429) or *permanent* (bad token, rejected file, other 4xx), and the two are never conflated. A bad token must not be retried for a week, and a dropped connection must not be reported as a rejected ride | Must |
|
||||
| FR-10.18 | Idempotent per (ride, service): a ride already accepted is never sent twice, whatever the rider presses | Must |
|
||||
| FR-10.19 | A duplicate reported by the service is recorded as **success**, not failure. That single rule is what makes FR-10.16's retries safe | Must |
|
||||
| FR-10.20 | `recording::prune` never deletes a ride with a non-terminal queue entry, however old it is | Must |
|
||||
| FR-10.21 | The rider can see the state of every queued upload, retry a failed one, and cancel one outright | Should |
|
||||
| FR-10.22 | The token is never written to a log, never put in a message shown to the rider, and never emitted as a `tracing` field at any level — NFR-8's "log everything" stops at the credential | Must |
|
||||
| FR-10.23 | TLS certificate verification is never disabled, and no setting exists that disables it | Must |
|
||||
| FR-10.24 | A failed upload cannot damage the ride. The rides directory is read-only to this whole subsystem | Must |
|
||||
|
||||
**The API, as we use it.** Confirmed against Runalyze's own reference client
|
||||
(`Runalyze/personal-api`, `bash/upload-activities.sh`):
|
||||
|
||||
```text
|
||||
POST https://runalyze.com/api/v1/activities/uploads
|
||||
token: <personal token>
|
||||
Content-Type: multipart/form-data
|
||||
file=@2026-08-21T18-42-10.fit
|
||||
```
|
||||
|
||||
| Element | Value | State |
|
||||
|---------|-------|-------|
|
||||
| Endpoint | `POST /api/v1/activities/uploads` | **Confirmed** |
|
||||
| Auth | a `token:` header — *not* `Authorization:` | **Confirmed** |
|
||||
| Form field | `file` | **Confirmed** |
|
||||
| Accepted formats | `fit`, `tcx`, `gpx`, `ttbin`, `fitlog` | **Confirmed** |
|
||||
| Auth failure | response body contains `No valid token` | **Confirmed** — the only error the reference client checks |
|
||||
| Success body | — | **Unknown, OQ-12** |
|
||||
| Duplicate response | — | **Unknown, OQ-12** |
|
||||
| Rate / size limits | none published | Unknown |
|
||||
|
||||
Two consequences worth stating before anything is written.
|
||||
|
||||
**Processing is asynchronous on their side.** Runalyze's own README says uploads are queued
|
||||
and do not appear immediately. There is no activity id to poll for and no "it is live"
|
||||
moment to report, so an accepted upload means *accepted* and the UI must say that — not
|
||||
"uploaded to Runalyze", as though the rider could go and look at it. This is simpler than
|
||||
Strava's upload-then-poll; the honest wording is the whole of the difference.
|
||||
|
||||
**The success and duplicate bodies must be captured, not guessed.** The reference client
|
||||
discards the body apart from one substring check, and the published documentation is behind
|
||||
a login. Before FR-10.19 can be implemented, one real upload and one deliberate re-upload of
|
||||
the same file are run by hand and both bodies pinned as fixtures — the same discipline
|
||||
§2.3.1 applied to the Click's handshake reply, which turned out to be `02 03` and not the
|
||||
documented `01 03`. Until that is done the honest classification is: a 2xx not containing
|
||||
`No valid token` is *accepted*, and duplicate detection does not exist.
|
||||
|
||||
#### 5.10.3 The upload queue
|
||||
|
||||
The design problem here is not the HTTP. It is that a rider finishes in a basement, on a
|
||||
phone with no signal, and closes the app. `stop_ride` → `POST` is wrong for exactly the
|
||||
reason `stop_ride` → save-dialog was wrong: it makes the ride's fate depend on a moment that
|
||||
may not go well. §5.8 solved that by writing the file first and offering the dialog second.
|
||||
This is the same solution one layer out — an upload is a **pending intent recorded next to
|
||||
the file**, not an action attempted once.
|
||||
|
||||
```text
|
||||
app_data_dir()/uploads.json
|
||||
{ "version": 1,
|
||||
"entries": [ { "ride": "2026-08-21T18-42-10",
|
||||
"target": "runalyze",
|
||||
"state": "pending" | "done" | "failed",
|
||||
"attempts": 3,
|
||||
"nextAttemptMs": 1755800000000,
|
||||
"detail": "No valid token" }, … ] }
|
||||
```
|
||||
|
||||
Same contract as `devices.json` and `settings.json`: versioned, an unknown version discarded
|
||||
rather than guessed at, written only when something changes, and **advisory** — an
|
||||
unreadable `uploads.json` costs the rider an upload, never a ride, so every failure there is
|
||||
logged and swallowed.
|
||||
|
||||
| From | Event | To |
|
||||
|------|-------|----|
|
||||
| *(ride written, or the rider asks)* | queued | `pending`, attempts 0 |
|
||||
| `pending` | accepted — **or duplicate** (FR-10.19) | `done`, **terminal** |
|
||||
| `pending` | retryable error, attempts left | `pending`, attempts + 1, `nextAttemptMs` pushed out |
|
||||
| `pending` | retryable error, attempts exhausted | `failed`, **terminal**, carrying the last reason |
|
||||
| `pending` | permanent error | `failed`, **terminal**, carrying the reason |
|
||||
| `failed` | explicit rider retry (FR-10.21) | `pending`, attempts 0 |
|
||||
| any | the FIT is no longer on disk | entry dropped |
|
||||
|
||||
- Backoff exponential from 30 s, capped near an hour, and **persisted** — a relaunch must
|
||||
not reset a failing entry into a tight retry loop against someone else's server.
|
||||
- Attempts capped (10). Past that the entry is `failed` carrying the last retryable reason,
|
||||
and only an explicit rider retry revives it.
|
||||
- The drain runs at launch — *after* `recover_orphans`, so a ride rebuilt from its journal
|
||||
can be queued too — and on a low-frequency timer thereafter. Never on the ride loop's
|
||||
thread, and it never holds the ride-state lock across a socket, for the reason
|
||||
`RecorderHandle` documents about holding it across an `fsync`.
|
||||
- One upload in flight at a time. Concurrency buys nothing here and costs correctness.
|
||||
- An entry whose FIT has gone (deleted by a rider, pruned by an older build) is dropped,
|
||||
not retried.
|
||||
|
||||
**Prune (FR-10.20).** `prune` currently keeps the newest 20 stems and deletes the rest. It
|
||||
gains a predicate: a stem with a non-terminal entry in `uploads.json` is never deleted. Without
|
||||
that, a laptop left off for three weeks quietly deletes the rides it was going to upload.
|
||||
|
||||
#### 5.10.4 Where the code goes
|
||||
|
||||
```text
|
||||
crates/sync/ # NEW — one trait, one impl. No Tauri, no filesystem policy.
|
||||
├── lib.rs # ActivityTarget, UploadOutcome, UploadError
|
||||
└── runalyze.rs # the multipart POST and its response classification
|
||||
src-tauri/src/
|
||||
└── uploads.rs # NEW — the store, the queue, the drain task, the commands
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct Activity<'a> { pub path: &'a Path, pub filename: &'a str }
|
||||
|
||||
pub enum UploadOutcome { Accepted { remote_id: Option<String> }, Duplicate }
|
||||
|
||||
pub enum UploadError {
|
||||
/// Try again later: DNS, TLS, timeout, 5xx, 429.
|
||||
Retryable(String),
|
||||
/// Do not try again: bad token, rejected file, any other 4xx.
|
||||
Permanent(String),
|
||||
}
|
||||
|
||||
pub trait ActivityTarget {
|
||||
fn id(&self) -> &'static str;
|
||||
async fn upload(&self, activity: Activity<'_>) -> Result<UploadOutcome, UploadError>;
|
||||
}
|
||||
```
|
||||
|
||||
`crates/sync` takes no Tauri dependency and knows nothing about where rides live, for the
|
||||
same reason `crates/fit` knows neither: it can then be tested against a local mock HTTP
|
||||
server with no app running, the way `fit` is tested by encoding with our writer and decoding
|
||||
with somebody else's parser. `Duplicate` is an outcome rather than an error precisely
|
||||
because FR-10.19 turns on it, and a variant is harder to mishandle than a string match at a
|
||||
call site.
|
||||
|
||||
Dependency: `reqwest` with `rustls-tls`, **not** `native-tls`. OpenSSL cross-compiled
|
||||
against the Android NDK is a build problem this project does not need, and pure-Rust crypto
|
||||
is the choice already made for the Click session (§4).
|
||||
|
||||
#### 5.10.5 The token
|
||||
|
||||
`devices.json` and `settings.json` are plaintext, which is right for a MAC address and a
|
||||
rider's mass. An API token is not in that class: it grants write access to somebody's
|
||||
training history for as long as they leave it valid.
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| FR-10.25 | The token is not stored in `settings.json` beside the rider's mass | Must |
|
||||
| FR-10.26 | Desktop: the OS credential store (`keyring` — Secret Service, Keychain, Credential Manager). A store that is missing or locked degrades to *no token configured* and asks the rider to paste it again; it never silently falls back to a plaintext file | Must |
|
||||
| FR-10.27 | Android: app-private storage, which the OS already isolates per app — `EncryptedSharedPreferences` if it can be reached without a Java dependency costing more than it saves | Should |
|
||||
| FR-10.28 | The UI never redisplays a stored token. It shows *configured* / *not configured*, and offers replace and remove | Must |
|
||||
|
||||
And be honest about the ceiling: on a rooted phone, or a desktop with no keyring daemon
|
||||
running, none of this hides the token from someone holding the device. What it buys is that
|
||||
the token is not sitting in a world-readable JSON file beside the rides, and is not in the
|
||||
settings file a rider pastes into a bug report.
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
@@ -647,7 +1025,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-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
|
||||
| NFR-5 | **Portability** — `core` and `fit` compile for Android with no platform-specific code |
|
||||
| NFR-6 | **Offline** — full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet |
|
||||
| NFR-6 | **Offline** — every function that is part of *riding* works with no internet connection: discovery, connection, control, profiles, physics, recording and the FIT file. The app contacts no service unless the rider has configured one (FR-10.11), and it never contacts Zwift — only the rider's separate daily unlock needs the internet. An installation with no upload token configured originates no network traffic at all |
|
||||
| NFR-7 | **Startup** — launch to scanning in under 3 seconds |
|
||||
| NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis |
|
||||
| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
|
||||
@@ -656,6 +1034,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-13 | **No untracked source** — `src-tauri/gen/` is generated and untracked, so every hand-written Android source lives in `src-tauri/android/` and is copied in after each `tauri android init`. CI fails if a source exists only under `gen/`, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written |
|
||||
| NFR-14 | **Version-locked BLE Java** — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a `NoSuchMethodError` at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in `Cargo.lock`, never from a separately published artifact |
|
||||
| NFR-15 | **Nothing on the network is on the ride path** — no ride may be blocked, delayed or degraded by a network operation. Sharing and uploading run *behind* the ride, never in front of it; the ride loop awaits neither, and a service that is down, slow or wrong costs a rider an upload and never a session |
|
||||
| NFR-16 | **Credentials are not settings** — an API token is never stored in `settings.json`, never logged at any level, and never redisplayed by the UI once stored (FR-10.22, FR-10.25–10.28). NFR-8's "log everything" stops at the credential |
|
||||
|
||||
---
|
||||
|
||||
@@ -735,6 +1115,20 @@ Three rules fall out of it:
|
||||
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
|
||||
@@ -793,6 +1187,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-4.6 (ERG), FR-9.11/9.12, Android.
|
||||
|
||||
### Phase 5 — Sharing and upload
|
||||
FR-10, plus the NFR-6 / NFR-15 / NFR-16 amendments. Two independent pieces: the share ships
|
||||
first and is blocked on nothing in the upload.
|
||||
|
||||
| Step | Work |
|
||||
|------|------|
|
||||
| **1** | **Share (FR-10.1–10.8).** `file_paths.xml` becomes a tracked source under `src-tauri/android/` with a `<files-path>` for `rides/`, copied by `sync-android-sources.sh` and checked in CI like the manifest (NFR-13). One Kotlin method on `MainActivity`, one JNI entry in `android.rs`, one command, one button on the summary. **Settles OQ-11 on a real phone against Garmin Connect and Strava** — which is the reason it goes first |
|
||||
| **2** | **Token storage (FR-10.25–10.28)**, before any code exists that has a token to lose |
|
||||
| **3** | **`crates/sync` and the Runalyze impl**, against a local mock HTTP server. Then one real upload and one deliberate re-upload by hand, both bodies pinned as fixtures — **OQ-12**, without which FR-10.19 is a guess |
|
||||
| **4** | **The queue (FR-10.12–10.21)**: `uploads.json`, the drain task, the prune predicate, the UI |
|
||||
|
||||
*Milestone: a ride reaches Garmin Connect from the phone with no cable, and reaches Runalyze
|
||||
without the rider touching anything.*
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
@@ -808,6 +1216,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-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
|
||||
| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `−` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod |
|
||||
| **RISK-10** | The FIT share MIME type is not what the receiving apps filter on | **The chooser comes up empty, with no error anywhere.** A feature that looks broken and logs nothing | OQ-11: settle it on a real phone against Garmin Connect, Strava and a file manager *before* the UI is written. `application/vnd.ant.fit` is the registered type; `application/octet-stream` is what many Android apps actually match, and is the fallback |
|
||||
| **RISK-11** | Strava is the service riders will ask for next, and its token exchange needs a `client_secret` inside an open-source native binary — it has no PKCE — against a rate limit shared by every install rather than held per rider | An obvious feature request carries policy risk and a ceiling that arrives without warning | Deferred deliberately, not forgotten. Runalyze first proves the trait, the queue and the error classification; when Strava is done, the likely shape is the rider registering their *own* API application, which moves both the secret and the rate limit onto them. Until then the share sheet reaches Strava's own app and needs none of it |
|
||||
| **RISK-12** | Runalyze's success and duplicate response bodies are undocumented — the reference client discards the body apart from one substring check, and the published docs are behind a login | FR-10.18/10.19's idempotency would rest on a guess, and a retried upload could silently double a ride | Capture both by hand and pin them as fixtures before implementing (OQ-12). Until then, treat a 2xx not containing `No valid token` as accepted and do not claim duplicate detection at all |
|
||||
| **RISK-13** | `file_paths.xml` is generated by the dialog plugin and regenerated by `tauri android init`, and its roots do not cover app-private storage | Share throws `Failed to find configured root` in a build that is otherwise perfectly healthy | Exactly the failure NFR-13 exists for. The file moves under `src-tauri/android/`, the sync script copies it, and CI checks it as it already checks the manifest |
|
||||
|
||||
### 10.1 On ANT+
|
||||
|
||||
@@ -829,6 +1241,9 @@ intolerable.
|
||||
| **OQ-6** | Which frontend framework? Svelte recommended. |
|
||||
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
|
||||
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
|
||||
| **OQ-11** | Which MIME type do Garmin Connect's and Strava's Android apps actually accept a `.fit` on — `application/vnd.ant.fit`, `application/octet-stream`, or both? Answered by trying it on a phone, not by reading (RISK-10). |
|
||||
| **OQ-12** | What do Runalyze's upload responses look like on success, and on a re-upload of a file already accepted? Both need capturing before FR-10.19 can be written (RISK-12). |
|
||||
| **OQ-13** | Is auto-upload a standing preference or a per-ride choice — and if standing, does a ride abandoned after ninety seconds get uploaded too? (Suggest: standing, with a minimum-duration floor, so a two-minute test of the trainer does not land in the training log.) |
|
||||
|
||||
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
|
||||
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
||||
@@ -849,3 +1264,5 @@ MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
||||
| **Crr** | Coefficient of rolling resistance |
|
||||
| **CdA** | Drag coefficient × frontal area |
|
||||
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
|
||||
| **FileProvider** | Android component that hands another app a `content://` URI onto a file in this app's private storage, without making the file world-readable (FR-10.2) |
|
||||
| **Personal API token** | Runalyze's credential: a string the rider generates in their own account settings and pastes in. Not OAuth — no authorisation flow, no redirect, no client secret (FR-10.10) |
|
||||
|
||||
@@ -324,6 +324,9 @@ async fn open_session(
|
||||
in_flight: &InFlight,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
|
||||
// The GATT window — see `scan::gatt_setup`. Scanners stay off the air until
|
||||
// the session is built.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
|
||||
@@ -1321,6 +1321,10 @@ async fn connect_session(
|
||||
});
|
||||
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
// Found. Everything from here to the end of service discovery is the window
|
||||
// Android kills a link for if anything else is scanning, so say so — the
|
||||
// device list watches this and stays off the air until it is over.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// From here until this function returns, cancelling the caller is the only
|
||||
// thing that can leave a link open with nobody to close it. Hand the
|
||||
// peripheral over now, before `connect()` — a cancellation lands wherever it
|
||||
|
||||
@@ -378,6 +378,8 @@ async fn open_session(
|
||||
|d| selector.matches(d),
|
||||
)
|
||||
.await?;
|
||||
// The GATT window — see `scan::gatt_setup`.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
|
||||
+152
-7
@@ -1,7 +1,9 @@
|
||||
//! BLE discovery (FR-1.1, FR-1.2).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
|
||||
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
|
||||
@@ -114,24 +116,165 @@ impl ScanKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many discovery sessions this process currently has open.
|
||||
///
|
||||
/// The adapter has **one** radio, and four independent supervisors ask it to
|
||||
/// discover: the device list (`devices::scan_loop`, a 2.5 s pass every ~2.9 s),
|
||||
/// the trainer client (15 s), the controller (20 s per pod attempt, up to 30
|
||||
/// attempts), and the heart rate monitor. Only the first two are coordinated —
|
||||
/// `DeviceRegistry::connect` suspends the list scan for a trainer connect
|
||||
/// precisely so the two do not fight (devices.rs) — and a pod search routinely
|
||||
/// runs its full 20 s because a Click only advertises after a button press.
|
||||
///
|
||||
/// So this counts. Every start and stop below logs the depth and who is
|
||||
/// holding it; a line reading `depth=2` names both competitors and is the
|
||||
/// evidence that a failed connect was a contended radio rather than a sleeping
|
||||
/// peripheral. Diagnostic only — nothing here changes what the radio does.
|
||||
static DISCOVERY_DEPTH: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Android's rule, and the one this app kept breaking: five scan *starts* in
|
||||
/// any thirty seconds and the platform stops returning results.
|
||||
const THROTTLE_WINDOW: Duration = Duration::from_secs(30);
|
||||
const THROTTLE_STARTS: usize = 5;
|
||||
|
||||
/// When each recent session was opened, for the rate check below.
|
||||
static STARTS: Mutex<VecDeque<Instant>> = Mutex::new(VecDeque::new());
|
||||
|
||||
/// Announce a discovery session opening, and return the depth including it.
|
||||
///
|
||||
/// Logs two different problems, because they have different fixes: `depth > 1`
|
||||
/// is two searches sharing one radio, and a start rate at the platform's limit
|
||||
/// is the app about to be muted by Android whether or not anything is sharing.
|
||||
fn discovery_opened(who: &str) -> usize {
|
||||
let depth = DISCOVERY_DEPTH.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
let recent = {
|
||||
let now = Instant::now();
|
||||
let mut starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
while starts.front().is_some_and(|t| now.duration_since(*t) > THROTTLE_WINDOW) {
|
||||
starts.pop_front();
|
||||
}
|
||||
starts.push_back(now);
|
||||
starts.len()
|
||||
};
|
||||
|
||||
if recent >= THROTTLE_STARTS {
|
||||
tracing::warn!(
|
||||
who,
|
||||
recent,
|
||||
depth,
|
||||
"discovery: {recent} scan starts in the last 30 s — at Android's limit, \
|
||||
where further scans return nothing at all"
|
||||
);
|
||||
} else if depth > 1 {
|
||||
tracing::warn!(
|
||||
who,
|
||||
depth,
|
||||
recent,
|
||||
"discovery: sharing the radio with another search — connects may time out"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(who, depth, recent, "discovery: start");
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
fn discovery_closed(who: &str, outcome: &str) {
|
||||
let depth = DISCOVERY_DEPTH.fetch_sub(1, Ordering::SeqCst).saturating_sub(1);
|
||||
tracing::debug!(who, depth, outcome, "discovery: stop");
|
||||
}
|
||||
|
||||
/// How many links are being built right now.
|
||||
///
|
||||
/// The distinction that matters on Android: two *scans* overlapping is
|
||||
/// wasteful, but a scan overlapping a **GATT setup** — connect, then discover
|
||||
/// services — is what the platform kills, with `Disconnected while discovering
|
||||
/// services`. So this marks that narrower window, and the device list stays off
|
||||
/// the air only for it.
|
||||
///
|
||||
/// Not the search that precedes it. A trainer that is asleep is searched for
|
||||
/// every few seconds for minutes on end (`RECONNECT_ATTEMPTS`), and suspending
|
||||
/// the list scan for all of that starves every *other* device of the discovery
|
||||
/// it needs — a dropped pod could never be seen again, which is exactly what
|
||||
/// happened on the tablet when the suspension was drawn around the whole
|
||||
/// reconnect instead of around this.
|
||||
static GATT_SETUP: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Marks a link as under construction until dropped. See [`GATT_SETUP`].
|
||||
#[must_use = "the window lasts as long as the guard is held"]
|
||||
pub struct GattSetup(());
|
||||
|
||||
pub fn gatt_setup() -> GattSetup {
|
||||
GATT_SETUP.fetch_add(1, Ordering::SeqCst);
|
||||
GattSetup(())
|
||||
}
|
||||
|
||||
impl Drop for GattSetup {
|
||||
fn drop(&mut self) {
|
||||
GATT_SETUP.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// True while any link is being built. Scanners must stay off the air.
|
||||
pub fn gatt_setup_active() -> bool {
|
||||
GATT_SETUP.load(Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Open a discovery session and leave it open.
|
||||
///
|
||||
/// Paired with [`end`], and sampled meanwhile with [`peek`]. The three exist
|
||||
/// separately because **starting a scan is the expensive act on Android**, not
|
||||
/// running one: since Android 7 the platform counts an app's scan *starts* and
|
||||
/// refuses it with `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` after five in thirty
|
||||
/// seconds — silently, as far as btleplug is concerned, so the app goes on
|
||||
/// asking and simply stops being told about anything.
|
||||
///
|
||||
/// A caller that wants a live list therefore has to hold one session and
|
||||
/// sample it, rather than restart one per refresh. See `devices::scan_loop`.
|
||||
pub async fn begin(adapter: &Adapter, kind: ScanKind, who: &str) -> Result<(), FtmsError> {
|
||||
adapter.start_scan(kind.filter()).await?;
|
||||
discovery_opened(who);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything the open session has seen so far. Does not touch the session.
|
||||
pub async fn peek(adapter: &Adapter, kind: ScanKind) -> Result<Vec<DiscoveredDevice>, FtmsError> {
|
||||
let peripherals = adapter.peripherals().await?;
|
||||
Ok(collect(peripherals, kind).await)
|
||||
}
|
||||
|
||||
/// Close a session opened by [`begin`]. Best effort: a failure to stop is worth
|
||||
/// a line in the log and nothing more, since the next start subsumes it.
|
||||
pub async fn end(adapter: &Adapter, who: &str, outcome: &str) {
|
||||
if let Err(e) = adapter.stop_scan().await {
|
||||
tracing::debug!(error = %e, "stop_scan failed");
|
||||
}
|
||||
discovery_closed(who, outcome);
|
||||
}
|
||||
|
||||
/// Scan for `duration` and return everything seen.
|
||||
///
|
||||
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is
|
||||
/// pedalled. An empty result means "nothing was advertising", not "no such
|
||||
/// device exists"; FR-1.8 requires the UI to say so.
|
||||
///
|
||||
/// One session per call, so this is for one-shot callers — the probe tool, a
|
||||
/// test. Anything refreshing on a timer wants [`begin`]/[`peek`]/[`end`].
|
||||
pub async fn scan(
|
||||
adapter: &Adapter,
|
||||
duration: Duration,
|
||||
kind: ScanKind,
|
||||
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
|
||||
adapter.start_scan(kind.filter()).await?;
|
||||
begin(adapter, kind, "device list").await?;
|
||||
tokio::time::sleep(duration).await;
|
||||
let peripherals = adapter.peripherals().await?;
|
||||
// Stopping the scan is best effort; a failure here must not lose results.
|
||||
if let Err(e) = adapter.stop_scan().await {
|
||||
tracing::debug!(error = %e, "stop_scan failed");
|
||||
end(adapter, "device list", "listed").await;
|
||||
|
||||
Ok(collect(peripherals, kind).await)
|
||||
}
|
||||
|
||||
/// Describe, filter and rank what the radio handed back.
|
||||
async fn collect(peripherals: Vec<Peripheral>, kind: ScanKind) -> Vec<DiscoveredDevice> {
|
||||
let mut out = Vec::with_capacity(peripherals.len());
|
||||
for p in peripherals {
|
||||
if let Some(d) = describe(&p).await {
|
||||
@@ -143,7 +286,7 @@ pub async fn scan(
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN)));
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
/// Convenience wrapper: scan the default adapter for trainers.
|
||||
@@ -261,6 +404,7 @@ pub async fn find_matching(
|
||||
matches: impl Fn(&DiscoveredDevice) -> bool,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
adapter.start_scan(kind.filter()).await?;
|
||||
discovery_opened(what);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let poll = Duration::from_millis(400);
|
||||
@@ -291,6 +435,7 @@ pub async fn find_matching(
|
||||
if let Err(e) = adapter.stop_scan().await {
|
||||
tracing::debug!(error = %e, "stop_scan failed");
|
||||
}
|
||||
discovery_closed(what, if found.is_some() { "matched" } else { "timed out" });
|
||||
|
||||
found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
|
||||
}
|
||||
|
||||
@@ -18,3 +18,9 @@ uuid = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# Protocol discovery only (§9): the pod offers a compressed P-256 point and we
|
||||
# need a real one to answer with. Pure Rust, and deliberately confined to the
|
||||
# probe until an unlock candidate is proven — the app takes no crypto
|
||||
# dependency on a guess.
|
||||
p256 = { version = "0.13", default-features = false, features = ["ecdh", "arithmetic"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
+71
-1
@@ -20,6 +20,14 @@ SUBCOMMANDS:
|
||||
inspect <ADDR> Connect and dump every service, characteristic and capability
|
||||
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
||||
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
||||
unlock <ADDR> Answer the pod's key offer and see whether its paddles survive
|
||||
past the ~50 s cliff (--candidate ours|play|echo; omit for a
|
||||
control run that answers nothing). --sweep sends every known
|
||||
variant in one connection and compares the pod's replies —
|
||||
no button presses needed. --variant <name> sends exactly one
|
||||
frame; --keepalive <secs> sends it on a timer all run
|
||||
(variants: info, param770, compressed+ours, compressed+none,
|
||||
compressed+trailer, uncompressed+ours, play-rideon)
|
||||
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
||||
subscribe to everything and print each notification's
|
||||
@@ -33,7 +41,7 @@ TARGET (for `set`):
|
||||
|
||||
OPTIONS:
|
||||
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
|
||||
scan 6, monitor 30, set 15, zwift 60)
|
||||
scan 6, monitor 30, set 15, zwift 60, unlock 150)
|
||||
--all `scan`: list every peripheral, not just fitness machines
|
||||
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
||||
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
|
||||
@@ -89,6 +97,26 @@ pub enum Command {
|
||||
/// until it is greeted, so listening alone hears silence from one.
|
||||
handshake: bool,
|
||||
},
|
||||
/// Does answering the pod's key offer keep its paddles alive?
|
||||
///
|
||||
/// One candidate reply per run, then two minutes of watching the paddle
|
||||
/// bits. See `unlock.rs` for what is being tested and why a guess is
|
||||
/// affordable here.
|
||||
Unlock {
|
||||
device: Device,
|
||||
duration: Duration,
|
||||
/// Which field-2 marker to answer with; `None` sends nothing and is the
|
||||
/// control run.
|
||||
candidate: Option<String>,
|
||||
/// Send every variant in one connection and compare the pod's replies.
|
||||
/// Needs no button presses, so it works on a pod that has stopped
|
||||
/// reporting them.
|
||||
sweep: bool,
|
||||
/// Send exactly one named variant, so its effect is unambiguous.
|
||||
variant: Option<String>,
|
||||
/// Send that variant on a timer for the whole run, rather than once.
|
||||
keepalive: Option<Duration>,
|
||||
},
|
||||
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||
/// it — a Click, or the trainer itself.
|
||||
Zwift {
|
||||
@@ -124,6 +152,10 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
let mut no_handshake = false;
|
||||
let mut buttons_only = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut candidate: Option<String> = None;
|
||||
let mut sweep = false;
|
||||
let mut variant: Option<String> = None;
|
||||
let mut keepalive: Option<u64> = None;
|
||||
let mut help = false;
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
|
||||
@@ -147,6 +179,34 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--keepalive" => {
|
||||
i += 1;
|
||||
let v = args
|
||||
.get(i)
|
||||
.ok_or_else(|| anyhow!("--keepalive needs a value in seconds"))?
|
||||
.clone();
|
||||
keepalive = Some(
|
||||
v.parse()
|
||||
.map_err(|_| anyhow!("--keepalive expects whole seconds, got {v:?}"))?,
|
||||
);
|
||||
}
|
||||
"--variant" => {
|
||||
i += 1;
|
||||
variant = Some(
|
||||
args.get(i)
|
||||
.ok_or_else(|| anyhow!("--variant needs a value"))?
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
"--candidate" => {
|
||||
i += 1;
|
||||
candidate = Some(
|
||||
args.get(i)
|
||||
.ok_or_else(|| anyhow!("--candidate needs a value"))?
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
"--name" => {
|
||||
i += 1;
|
||||
name = Some(
|
||||
@@ -214,6 +274,16 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||
handshake: !no_handshake,
|
||||
},
|
||||
"unlock" => Command::Unlock {
|
||||
device: device(&positional, 1)?,
|
||||
// Long enough to cross the ~50 s cliff twice over: a candidate that
|
||||
// merely delays the failure must not read as one that fixed it.
|
||||
duration: Duration::from_secs(secs.unwrap_or(150)),
|
||||
candidate: candidate.clone(),
|
||||
sweep,
|
||||
variant: variant.clone(),
|
||||
keepalive: keepalive.map(Duration::from_secs),
|
||||
},
|
||||
"zwift" => Command::Zwift {
|
||||
device: device(&positional, 1)?,
|
||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||
|
||||
@@ -18,7 +18,7 @@ use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
|
||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
||||
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
||||
use bikecontrol_core::types::ControlTarget;
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
|
||||
use btleplug::platform::Peripheral;
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
@@ -724,6 +724,7 @@ pub async fn zwift_cmd(
|
||||
// Only meaningful in --buttons mode: the mask as of the previous frame, so
|
||||
// the ~10 Hz repeat while a button is held collapses to one line.
|
||||
let mut last_mask: Option<u32> = None;
|
||||
|
||||
let mut presses: u64 = 0;
|
||||
|
||||
loop {
|
||||
@@ -966,6 +967,387 @@ fn pressed(b: bool) -> &'static str {
|
||||
}
|
||||
|
||||
/// Name a UUID, checking Zwift's custom space as well as the SIG's.
|
||||
/// `probe unlock` — answer the pod's key offer and see whether the paddles live.
|
||||
///
|
||||
/// The experiment, not an implementation: see `crate::unlock` for the
|
||||
/// hypothesis and why one guess per run is affordable.
|
||||
pub async fn unlock_cmd(
|
||||
device: &Device,
|
||||
duration: Duration,
|
||||
candidate: Option<&str>,
|
||||
sweep: bool,
|
||||
variant: Option<&str>,
|
||||
keepalive: Option<Duration>,
|
||||
scan_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
use crate::unlock;
|
||||
|
||||
let candidate = match candidate {
|
||||
None => None,
|
||||
Some(name) => Some(unlock::candidate(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown candidate {name:?}. Known: {}",
|
||||
unlock::CANDIDATES
|
||||
.iter()
|
||||
.map(|c| c.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?),
|
||||
};
|
||||
|
||||
match candidate {
|
||||
Some(c) => println!("Candidate {:?}: field 2 = 0x{:08x}\n {}\n", c.name, c.marker, c.why),
|
||||
None => println!(
|
||||
"Control run: answering nothing, to measure the cliff this pod actually has.\n"
|
||||
),
|
||||
}
|
||||
|
||||
let single = match variant {
|
||||
None => None,
|
||||
Some(name) => Some(unlock::variant(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown variant {name:?}. Known: {}",
|
||||
unlock::VARIANTS.iter().map(|v| v.name).collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
})?),
|
||||
};
|
||||
match (single, keepalive) {
|
||||
(Some(v), Some(every)) => println!(
|
||||
"Keep-alive run: sending {} every {}s for the whole run.\n\
|
||||
If the paddles are still reporting at the end, that is the fix.\n",
|
||||
v.name,
|
||||
every.as_secs()
|
||||
),
|
||||
(Some(v), None) => println!("Sending exactly one frame this run: {}\n", v.name),
|
||||
(None, Some(_)) => anyhow::bail!("--keepalive needs --variant to say what to send"),
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
let peripheral = connect(device, scan_timeout).await?;
|
||||
if let Some(d) = scan::describe(&peripheral).await {
|
||||
println!("Connected to {} ({})\n", d.address, d.label());
|
||||
}
|
||||
|
||||
let service = zwift::SERVICES
|
||||
.iter()
|
||||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want))
|
||||
.ok_or_else(|| anyhow::anyhow!("this peripheral exposes no known Zwift service"))?;
|
||||
|
||||
let mut notifications = peripheral.notifications().await?;
|
||||
for ch in service
|
||||
.characteristics
|
||||
.iter()
|
||||
.filter(|c| c.properties.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE))
|
||||
{
|
||||
if let Err(e) = peripheral.subscribe(ch).await {
|
||||
println!("Could not subscribe to {}: {e}", ch.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
let sync_rx = writable(&service)
|
||||
.ok_or_else(|| anyhow::anyhow!("nothing in this service is writable — cannot answer"))?;
|
||||
|
||||
let start = Instant::now();
|
||||
handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
|
||||
|
||||
let local = unlock::local_key();
|
||||
println!(
|
||||
"Our P-256 point: {}\n\nWatching for {} s. Work the paddles and the D-pad throughout —\n\
|
||||
the question is whether the paddles are still reporting at the end.\n",
|
||||
hex(&local.compressed),
|
||||
duration.as_secs()
|
||||
);
|
||||
|
||||
let mut verdict = unlock::Verdict::new(start);
|
||||
let mut answered = 0u32;
|
||||
let mut offers = 0u32;
|
||||
let mut last_status: Option<unlock::Status> = None;
|
||||
// When the pod flipped its status flag, which is the cliff this run is
|
||||
// measured against — better than a constant, because the pod says so.
|
||||
let mut sent: Vec<&'static str> = Vec::new();
|
||||
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
|
||||
let mut zeros: u64 = 0;
|
||||
let mut beats: u64 = 0;
|
||||
let mut last_mask: Option<u32> = None;
|
||||
// When the pod flipped its status flag, which is the cliff this run is
|
||||
// measured against — better than a constant, because the pod says so.
|
||||
let mut flip_at: Option<Duration> = None;
|
||||
|
||||
let deadline = tokio::time::sleep(duration);
|
||||
tokio::pin!(deadline);
|
||||
|
||||
// A-2 calls the thing that removes the daily unlock a *keep-alive*. That is
|
||||
// a periodic message, not a credential, and the Ride write-up gives the
|
||||
// shape of one. So: send it on a timer and let the paddles answer.
|
||||
let mut heartbeat = keepalive.map(|every| {
|
||||
let mut t = tokio::time::interval(every);
|
||||
t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
t
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut deadline => break,
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
println!("\nInterrupted.");
|
||||
break;
|
||||
}
|
||||
_ = async { heartbeat.as_mut().unwrap().tick().await }, if heartbeat.is_some() => {
|
||||
let Some(v) = single else { continue };
|
||||
let frame = (v.build)(&local, &unlock::KeyOffer::default());
|
||||
let at = start.elapsed().as_secs_f32();
|
||||
match write_frame(&peripheral, &sync_rx, &frame).await {
|
||||
Ok(()) => {
|
||||
beats += 1;
|
||||
println!("[{at:7.2}s] KEEPALIVE #{beats} {} {}", v.name, hex(&frame));
|
||||
}
|
||||
Err(e) => println!("[{at:7.2}s] KEEPALIVE !! {e}"),
|
||||
}
|
||||
}
|
||||
n = notifications.next() => {
|
||||
let Some(n) = n else {
|
||||
println!("\nThe device disconnected — the run is void, not a failure.");
|
||||
break;
|
||||
};
|
||||
let at = start.elapsed().as_secs_f32();
|
||||
|
||||
if let Some(offer) = unlock::parse_key_offer(&n.value) {
|
||||
offers += 1;
|
||||
println!(
|
||||
"[{at:7.2}s] KEY OFFER #{offers} key={} marker=0x{:08x} trailer={}B",
|
||||
hex(&offer.public_key),
|
||||
offer.marker,
|
||||
offer.trailer.len()
|
||||
);
|
||||
match unlock::shared_secret(&local, &offer.public_key) {
|
||||
// Logged, not used: agreeing a secret proves the point
|
||||
// is real P-256, which is worth knowing before anyone
|
||||
// writes a responder around it.
|
||||
Ok(secret) => println!(
|
||||
" ECDH agrees, shared secret starts {}",
|
||||
hex(&secret[..8.min(secret.len())])
|
||||
),
|
||||
Err(e) => println!(" !! {e}"),
|
||||
}
|
||||
if keepalive.is_some() {
|
||||
// The heartbeat is the experiment; firing again here
|
||||
// would confound which write did what.
|
||||
} else if let Some(one) = single {
|
||||
// One write per connection. The sweep's replies came
|
||||
// back in a single burst six seconds after the first
|
||||
// write, so "attributed to the last thing sent" was a
|
||||
// label, not a measurement. This is how you learn which
|
||||
// frame does what.
|
||||
let frame = (one.build)(&local, &offer);
|
||||
println!("\n -> {:<20} {}", one.name, hex(&frame));
|
||||
println!(" {}\n", one.why);
|
||||
match write_frame(&peripheral, &sync_rx, &frame).await {
|
||||
Ok(()) => {
|
||||
answered += 1;
|
||||
sent.push(one.name);
|
||||
}
|
||||
Err(e) => println!(" !! could not send: {e}"),
|
||||
}
|
||||
} else if sweep {
|
||||
// One connection, every variant, because the pod hands
|
||||
// back a reason for each. Spaced so a late reply cannot
|
||||
// be attributed to the next thing we sent.
|
||||
for v in unlock::VARIANTS {
|
||||
let frame = (v.build)(&local, &offer);
|
||||
println!("\n -> {:<20} {}", v.name, hex(&frame));
|
||||
println!(" {}", v.why);
|
||||
if let Err(e) = write_frame(&peripheral, &sync_rx, &frame).await {
|
||||
println!(" !! could not send: {e}");
|
||||
continue;
|
||||
}
|
||||
answered += 1;
|
||||
sent.push(v.name);
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
}
|
||||
println!();
|
||||
} else if let Some(c) = candidate {
|
||||
let frame = unlock::reply_frame(&local.compressed, c.marker);
|
||||
match write_frame(&peripheral, &sync_rx, &frame).await {
|
||||
Ok(()) => {
|
||||
answered += 1;
|
||||
println!(" answered with {}", hex(&frame));
|
||||
}
|
||||
Err(e) => println!(" !! could not answer: {e}"),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(status) = unlock::parse_status(&n.value) {
|
||||
if last_status != Some(status) {
|
||||
println!(
|
||||
"[{at:7.2}s] STATUS flag={} timer={}",
|
||||
status.flag, status.timer
|
||||
);
|
||||
// The pod telling us, in its own words, that whatever
|
||||
// grace it was extending has ended. Everything before
|
||||
// this is preamble; the run is only evidence from here.
|
||||
// A *transition*, not merely a first sighting. These
|
||||
// runs opened with flag already 1 — the pod remembers
|
||||
// being past the cliff across reconnects — and calling
|
||||
// that "the cliff at 2.3s" is a reading, not a fact.
|
||||
let was_zero = last_status.is_some_and(|s| s.flag == 0);
|
||||
if status.flag == 1 && was_zero && flip_at.is_none() {
|
||||
flip_at = Some(start.elapsed());
|
||||
println!(
|
||||
"\n >>> THE CLIFF. Keep pressing both paddles and the D-pad for\n >>> another 60 s — everything before this line proves nothing.\n"
|
||||
);
|
||||
}
|
||||
last_status = Some(status);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(r) = unlock::parse_response(&n.value) {
|
||||
let to = sent.last().copied().unwrap_or("(unsolicited)");
|
||||
println!("[{at:7.2}s] REPLY code={} detail={} <- {to}", r.code, r.detail);
|
||||
responses.push((to, r));
|
||||
continue;
|
||||
}
|
||||
|
||||
if !n.value.is_empty() && n.value.iter().all(|b| *b == 0) {
|
||||
zeros += 1;
|
||||
if zeros == 1 {
|
||||
println!(
|
||||
"[{at:7.2}s] ZEROS the stream has gone to all-zero frames \
|
||||
— counting from here"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if button_mask(&n.value).is_none()
|
||||
&& unlock::parse_key_offer(&n.value).is_none()
|
||||
&& unlock::parse_status(&n.value).is_none()
|
||||
&& unlock::parse_response(&n.value).is_none()
|
||||
{
|
||||
println!("[{at:7.2}s] other {}", hex(&n.value));
|
||||
}
|
||||
|
||||
if let Some(mask) = button_mask(&n.value) {
|
||||
// Printed, not merely counted. An operator pressing buttons
|
||||
// into a silent terminal cannot tell a working run from a
|
||||
// dead pod, and will reasonably conclude the latter.
|
||||
if last_mask != Some(mask.raw) {
|
||||
last_mask = Some(mask.raw);
|
||||
let paddles = mask.raw & ((1 << 8) | (1 << 12));
|
||||
let which = match paddles {
|
||||
p if p == (1 << 8) | (1 << 12) => "",
|
||||
p if p & (1 << 8) == 0 => " <- − PADDLE",
|
||||
_ => " <- + PADDLE",
|
||||
};
|
||||
println!(
|
||||
"[{at:7.2}s] buttons 0x{:08x}{}{}",
|
||||
mask.raw,
|
||||
if mask.is_idle() { " (idle)" } else { "" },
|
||||
which
|
||||
);
|
||||
}
|
||||
verdict.observe(mask.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pod's own flip where we saw it; otherwise the ~50 s the captures show.
|
||||
let cliff = flip_at.unwrap_or(Duration::from_secs(50));
|
||||
println!("\n=== verdict ===");
|
||||
match (flip_at, last_status) {
|
||||
(Some(t), _) => println!(" cliff (flag 0->1): {:.1}s", t.as_secs_f32()),
|
||||
(None, Some(s)) if s.flag == 1 => println!(
|
||||
" cliff: already past it when we connected — the pod kept\n \x20 that state across the reconnect"
|
||||
),
|
||||
_ => println!(" cliff: never flipped"),
|
||||
}
|
||||
println!(" key offers seen: {offers}");
|
||||
if beats > 0 {
|
||||
println!(" keep-alives sent: {beats}");
|
||||
}
|
||||
if zeros > 0 {
|
||||
println!(" all-zero frames: {zeros} <- the stream stopped carrying data");
|
||||
}
|
||||
println!(" answered: {answered}");
|
||||
println!(
|
||||
" paddle edges: {} (last at {})",
|
||||
verdict.paddle_edges,
|
||||
verdict.last_paddle.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
|
||||
);
|
||||
println!(
|
||||
" other edges: {} (last at {})",
|
||||
verdict.other_edges,
|
||||
verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
|
||||
);
|
||||
|
||||
if sweep {
|
||||
println!("\n variant reply");
|
||||
for (name, r) in &responses {
|
||||
println!(" {name:<22} code={} detail={}", r.code, r.detail);
|
||||
}
|
||||
let distinct: std::collections::BTreeSet<_> =
|
||||
responses.iter().map(|(_, r)| (r.code, r.detail)).collect();
|
||||
if responses.is_empty() {
|
||||
println!("\n The pod answered none of them, which is itself a change from\n the runs where it answered `ff 03 00` frames.");
|
||||
} else if distinct.len() == 1 {
|
||||
println!(
|
||||
"\n Every variant drew the same reply, so none of the things varied —\n the marker, the trailer, the key encoding, the envelope — is what\n it is objecting to."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"\n The reply MOVED. Whichever variant differs is the thread to pull:\n that is the first time this device has told us we got warmer."
|
||||
);
|
||||
}
|
||||
disconnect(&peripheral).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if verdict.paddle_edges == 0 && verdict.other_edges == 0 {
|
||||
println!(
|
||||
"\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\
|
||||
a pod nobody touched proves nothing."
|
||||
);
|
||||
} else if verdict.paddles_look_dead(cliff) {
|
||||
println!(
|
||||
"\n FAILED — the D-pad still reports and the paddles stopped.\n\
|
||||
That is the §2.3.3 signature, so this candidate did not hold them open."
|
||||
);
|
||||
} else if verdict.last_paddle.is_some_and(|t| t > cliff) {
|
||||
println!(
|
||||
"\n HELD — a paddle edge arrived {:.1}s past the cliff.\n\
|
||||
Worth repeating before believing: run it again, and run the control.",
|
||||
(verdict.last_paddle.unwrap() - cliff).as_secs_f32()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"\n INCONCLUSIVE — nothing was pressed after the cliff at {:.1}s.\n\
|
||||
The run has to keep going, with fingers on the buttons, well past it.",
|
||||
cliff.as_secs_f32()
|
||||
);
|
||||
}
|
||||
|
||||
disconnect(&peripheral).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write to the pod, preferring write-without-response where offered.
|
||||
async fn write_frame(
|
||||
peripheral: &Peripheral,
|
||||
ch: &Characteristic,
|
||||
frame: &[u8],
|
||||
) -> Result<(), btleplug::Error> {
|
||||
let kind = if ch.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) {
|
||||
WriteType::WithoutResponse
|
||||
} else {
|
||||
WriteType::WithResponse
|
||||
};
|
||||
peripheral.write(ch, frame, kind).await
|
||||
}
|
||||
|
||||
fn zwift_named(uuid: Uuid) -> String {
|
||||
zwift::well_known_name(uuid)
|
||||
.map(|n| format!(" ({n})"))
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod unlock;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -54,6 +55,25 @@ async fn main() -> Result<()> {
|
||||
duration,
|
||||
handshake,
|
||||
} => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await,
|
||||
cli::Command::Unlock {
|
||||
device,
|
||||
duration,
|
||||
candidate,
|
||||
sweep,
|
||||
variant,
|
||||
keepalive,
|
||||
} => {
|
||||
commands::unlock_cmd(
|
||||
&device,
|
||||
duration,
|
||||
candidate.as_deref(),
|
||||
sweep,
|
||||
variant.as_deref(),
|
||||
keepalive,
|
||||
SCAN_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
cli::Command::Zwift {
|
||||
device,
|
||||
duration,
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
//! `probe unlock` — does answering the pod's key offer keep the paddles alive?
|
||||
//!
|
||||
//! ## The question
|
||||
//!
|
||||
//! A Click v2 streams button state in cleartext for about fifty seconds and
|
||||
//! then stops reporting its **paddles** — the D-pad keeps working, and the two
|
||||
//! paddle bits of the bitmask freeze. Bracketing that moment, the pod sends a
|
||||
//! `0xff 03 00` frame carrying a compressed P-256 public key, and flips a flag
|
||||
//! in its `0xff 05 00` status frame (REQUIREMENTS §2.3.3). We never answer.
|
||||
//!
|
||||
//! The hypothesis this command tests: **the pod is asking for a key exchange
|
||||
//! and giving up on us when we do not reply.** If so, replying keeps the
|
||||
//! paddles alive past the cliff, and the shape of a working reply is the thing
|
||||
//! we do not have — every capture is device → app.
|
||||
//!
|
||||
//! ## Why a guess is affordable here
|
||||
//!
|
||||
//! The v2's offer looks like the handshake we already know, moved into a
|
||||
//! protobuf envelope. Its field 2 is the varint `0x02030000`, and
|
||||
//! `zwift::RESPONSE_START` — the pod's confirmed cleartext reply marker — is
|
||||
//! `[0x02, 0x03]`. That is not a coincidence, and it makes the client side a
|
||||
//! short list rather than a search: our own marker (`0x00090000`), Play's
|
||||
//! client marker (`0x01020000`), or the pod's own echoed back.
|
||||
//!
|
||||
//! And the device is a perfect oracle. Either the paddle bits still change at
|
||||
//! T+120 s or they do not, and it says so every run, in two minutes, with no
|
||||
//! APK and no tablet.
|
||||
//!
|
||||
//! ## What this is not
|
||||
//!
|
||||
//! Not an implementation. Nothing here derives a session key or decrypts
|
||||
//! anything: it sends one candidate and watches. If a candidate holds the
|
||||
//! paddles open, *then* the full ECDH → HKDF → AES-CCM responder is worth
|
||||
//! writing, against a known-good handshake instead of a hopeful one.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// A candidate for the two-byte marker the client puts in field 2, carried in
|
||||
/// the same big-endian-ish layout the pod uses for its own.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Candidate {
|
||||
pub name: &'static str,
|
||||
pub marker: u32,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
pub const CANDIDATES: &[Candidate] = &[
|
||||
Candidate {
|
||||
name: "ours",
|
||||
marker: 0x0009_0000,
|
||||
why: "the marker we already write in the confirmed cleartext handshake \
|
||||
(zwift::REQUEST_START = 00 09)",
|
||||
},
|
||||
Candidate {
|
||||
name: "play",
|
||||
marker: 0x0102_0000,
|
||||
why: "the client marker documented for the 2023 Play controllers (01 02)",
|
||||
},
|
||||
Candidate {
|
||||
name: "echo",
|
||||
marker: 0x0203_0000,
|
||||
why: "the pod's own marker echoed back, in case field 2 names the suite \
|
||||
rather than the speaker",
|
||||
},
|
||||
];
|
||||
|
||||
/// One thing to send, and what it is testing.
|
||||
///
|
||||
/// The sweep exists because the pod **answers**: a `0x3e` frame came back
|
||||
/// 90 ms after our first write, in both runs, carrying `{1: 255, 2: 5}`. That
|
||||
/// is a rejection with a reason, which makes this a conversation rather than a
|
||||
/// guess — vary one thing, watch the reason move.
|
||||
///
|
||||
/// It needs no button presses, which matters: the pod this was written for has
|
||||
/// stopped reporting buttons entirely, and the paddle oracle is unavailable
|
||||
/// until it recovers.
|
||||
pub struct Variant {
|
||||
pub name: &'static str,
|
||||
pub why: &'static str,
|
||||
/// Built from our public key and, where it matters, the pod's own offer.
|
||||
pub build: fn(&LocalKey, &KeyOffer) -> Vec<u8>,
|
||||
}
|
||||
|
||||
/// Fuzzing a Click is not fuzzing a trainer. §2.3 refused to fuzz unknown
|
||||
/// writes to the D100 because it puts resistance under a rider; a pod has no
|
||||
/// actuator, and the worst it can do is ignore us. The OAD characteristics stay
|
||||
/// untouched — those *can* brick it, and it is sealed.
|
||||
pub fn variant(name: &str) -> Option<&'static Variant> {
|
||||
VARIANTS.iter().find(|v| v.name == name)
|
||||
}
|
||||
|
||||
/// A command, in the form the Zwift Ride protocol write-up documents: the byte
|
||||
/// `0x00`, then protobuf field 1 carrying the parameter.
|
||||
///
|
||||
/// This is the shape we should have been writing all along. Our `0xff …` frames
|
||||
/// were being read as *command 255*, and `0x3e {1: 255, 2: 5}` was the device
|
||||
/// saying so — the command id echoed back with a status, not a rejected key.
|
||||
pub fn command(param: u64) -> Vec<u8> {
|
||||
let mut frame = vec![0x00];
|
||||
field_varint(&mut frame, 1, param);
|
||||
frame
|
||||
}
|
||||
|
||||
pub const VARIANTS: &[Variant] = &[
|
||||
Variant {
|
||||
name: "info",
|
||||
why: "the documented information request, `00 08 00` — if the command channel \
|
||||
works at all, this is what proves it",
|
||||
build: |_, _| command(0),
|
||||
},
|
||||
Variant {
|
||||
name: "param770",
|
||||
why: "`00 08 82 06`, the other documented command; 770 is 0x0302, which is the \
|
||||
pod's own RideOn marker read as a number",
|
||||
build: |_, _| command(770),
|
||||
},
|
||||
Variant {
|
||||
name: "compressed+ours",
|
||||
why: "what we have already sent twice — the control for the sweep",
|
||||
build: |k, _| reply_frame(&k.compressed, 0x0009_0000),
|
||||
},
|
||||
Variant {
|
||||
name: "compressed+none",
|
||||
why: "field 1 alone, in case field 2 is the objection",
|
||||
build: |k, _| {
|
||||
let mut body = Vec::new();
|
||||
field_bytes(&mut body, 1, &k.compressed);
|
||||
envelope(body)
|
||||
},
|
||||
},
|
||||
Variant {
|
||||
name: "compressed+trailer",
|
||||
why: "the pod's own field 3 echoed back — if the trailer is load-bearing, this is the cheapest way to find out",
|
||||
build: |k, offer| {
|
||||
let mut body = Vec::new();
|
||||
field_bytes(&mut body, 1, &k.compressed);
|
||||
field_varint(&mut body, 2, 0x0009_0000);
|
||||
field_bytes(&mut body, 3, &offer.trailer);
|
||||
envelope(body)
|
||||
},
|
||||
},
|
||||
Variant {
|
||||
name: "uncompressed+ours",
|
||||
why: "a 65-byte SEC1 point, since the Play generation exchanged uncompressed keys",
|
||||
build: |k, _| reply_frame(&k.uncompressed, 0x0009_0000),
|
||||
},
|
||||
Variant {
|
||||
name: "play-rideon",
|
||||
why: "the documented 2023 Play handshake verbatim: RideOn 01 02 + a raw 64-byte key, no protobuf envelope at all",
|
||||
build: |k, _| {
|
||||
let mut frame = Vec::with_capacity(72);
|
||||
frame.extend_from_slice(b"RideOn");
|
||||
frame.extend_from_slice(&[0x01, 0x02]);
|
||||
// SEC1 uncompressed minus the 0x04 tag, which is how Play carried it.
|
||||
frame.extend_from_slice(&k.uncompressed[1..]);
|
||||
frame
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// `ff 03 00` around a protobuf body.
|
||||
fn envelope(body: Vec<u8>) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(body.len() + 3);
|
||||
frame.extend_from_slice(&[0xff, 0x03, 0x00]);
|
||||
frame.extend_from_slice(&body);
|
||||
frame
|
||||
}
|
||||
|
||||
/// The pod's answer to something we sent. `0x3e`, two varints.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
pub code: u64,
|
||||
pub detail: u64,
|
||||
}
|
||||
|
||||
pub fn parse_response(raw: &[u8]) -> Option<Response> {
|
||||
if raw.first() != Some(&0x3e) {
|
||||
return None;
|
||||
}
|
||||
let mut i = 1;
|
||||
let mut r = Response { code: 0, detail: 0 };
|
||||
while i < raw.len() {
|
||||
let tag = read_varint(raw, &mut i)?;
|
||||
let v = match tag & 7 {
|
||||
0 => read_varint(raw, &mut i)?,
|
||||
_ => return None,
|
||||
};
|
||||
match tag >> 3 {
|
||||
1 => r.code = v,
|
||||
2 => r.detail = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
pub fn candidate(name: &str) -> Option<Candidate> {
|
||||
CANDIDATES.iter().find(|c| c.name == name).copied()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal protobuf, write side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn varint(out: &mut Vec<u8>, mut v: u64) {
|
||||
loop {
|
||||
let byte = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(byte);
|
||||
return;
|
||||
}
|
||||
out.push(byte | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn field_bytes(out: &mut Vec<u8>, field: u32, value: &[u8]) {
|
||||
varint(out, u64::from(field) << 3 | 2);
|
||||
varint(out, value.len() as u64);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn field_varint(out: &mut Vec<u8>, field: u32, value: u64) {
|
||||
varint(out, u64::from(field) << 3);
|
||||
varint(out, value);
|
||||
}
|
||||
|
||||
/// The reply, shaped exactly like the offer we are answering.
|
||||
///
|
||||
/// `ff 03 00` then `{1: our compressed public key, 2: marker}`. Field 3 of the
|
||||
/// pod's own offer — 40 or 60 bytes, unexplained — is deliberately omitted: if
|
||||
/// it turns out to be load-bearing, no candidate will hold the paddles open and
|
||||
/// that is itself the finding.
|
||||
pub fn reply_frame(public_key: &[u8], marker: u32) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(48);
|
||||
field_bytes(&mut body, 1, public_key);
|
||||
field_varint(&mut body, 2, u64::from(marker));
|
||||
|
||||
let mut frame = Vec::with_capacity(body.len() + 3);
|
||||
frame.extend_from_slice(&[0xff, 0x03, 0x00]);
|
||||
frame.extend_from_slice(&body);
|
||||
frame
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal protobuf, read side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn read_varint(b: &[u8], i: &mut usize) -> Option<u64> {
|
||||
let mut v = 0u64;
|
||||
let mut shift = 0;
|
||||
loop {
|
||||
let byte = *b.get(*i)?;
|
||||
*i += 1;
|
||||
v |= u64::from(byte & 0x7f) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(v);
|
||||
}
|
||||
shift += 7;
|
||||
if shift > 63 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pod's key offer, as much of it as we can name.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct KeyOffer {
|
||||
/// Field 1 — 33 bytes, a compressed P-256 point.
|
||||
pub public_key: Vec<u8>,
|
||||
/// Field 2 — `0x02030000` on every capture so far.
|
||||
pub marker: u64,
|
||||
/// Field 3 — 40 or 60 bytes, meaning unknown.
|
||||
pub trailer: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Recognise `ff 03 00` + protobuf. Returns `None` for anything else.
|
||||
pub fn parse_key_offer(raw: &[u8]) -> Option<KeyOffer> {
|
||||
if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x03 {
|
||||
return None;
|
||||
}
|
||||
let mut i = 3;
|
||||
let mut offer = KeyOffer {
|
||||
public_key: Vec::new(),
|
||||
marker: 0,
|
||||
trailer: Vec::new(),
|
||||
};
|
||||
while i < raw.len() {
|
||||
let tag = read_varint(raw, &mut i)?;
|
||||
let (field, wire) = (tag >> 3, tag & 7);
|
||||
match wire {
|
||||
0 => {
|
||||
let v = read_varint(raw, &mut i)?;
|
||||
if field == 2 {
|
||||
offer.marker = v;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let len = read_varint(raw, &mut i)? as usize;
|
||||
let end = i.checked_add(len)?;
|
||||
let value = raw.get(i..end)?.to_vec();
|
||||
i = end;
|
||||
match field {
|
||||
1 => offer.public_key = value,
|
||||
3 => offer.trailer = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Nothing in the captures uses the other wire types; bail rather
|
||||
// than mis-parse and report a confident wrong answer.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
(!offer.public_key.is_empty()).then_some(offer)
|
||||
}
|
||||
|
||||
/// The pod's status frame — `ff 05 00`, field 93 nested. `.2` flips 0 → 1 and
|
||||
/// `.3` goes 15 → 900 as the paddles die (§2.3.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Status {
|
||||
pub flag: u64,
|
||||
pub timer: u64,
|
||||
}
|
||||
|
||||
pub fn parse_status(raw: &[u8]) -> Option<Status> {
|
||||
if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x05 {
|
||||
return None;
|
||||
}
|
||||
let mut i = 3;
|
||||
let tag = read_varint(raw, &mut i)?;
|
||||
if tag >> 3 != 93 || tag & 7 != 2 {
|
||||
return None;
|
||||
}
|
||||
let len = read_varint(raw, &mut i)? as usize;
|
||||
let end = i.checked_add(len)?;
|
||||
let inner = raw.get(i..end)?;
|
||||
|
||||
let mut j = 0;
|
||||
let mut status = Status { flag: 0, timer: 0 };
|
||||
while j < inner.len() {
|
||||
let tag = read_varint(inner, &mut j)?;
|
||||
let (field, wire) = (tag >> 3, tag & 7);
|
||||
match wire {
|
||||
0 => {
|
||||
let v = read_varint(inner, &mut j)?;
|
||||
match field {
|
||||
2 => status.flag = v,
|
||||
3 => status.timer = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let len = read_varint(inner, &mut j)? as usize;
|
||||
j = j.checked_add(len)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The verdict
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which bits the paddles occupy, confirmed 2026-08-05 (§2.3.1).
|
||||
const PADDLE_MINUS: u32 = 1 << 8;
|
||||
const PADDLE_PLUS: u32 = 1 << 12;
|
||||
const PADDLES: u32 = PADDLE_MINUS | PADDLE_PLUS;
|
||||
|
||||
/// Tracks the one thing this experiment is for: are the paddles still alive?
|
||||
pub struct Verdict {
|
||||
start: Instant,
|
||||
pub paddle_edges: u32,
|
||||
pub last_paddle: Option<Duration>,
|
||||
pub other_edges: u32,
|
||||
pub last_other: Option<Duration>,
|
||||
last_mask: Option<u32>,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn new(start: Instant) -> Self {
|
||||
Self {
|
||||
start,
|
||||
paddle_edges: 0,
|
||||
last_paddle: None,
|
||||
other_edges: 0,
|
||||
last_other: None,
|
||||
last_mask: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a button bitmask. Only *changes* count, since the pod repeats at
|
||||
/// ~10 Hz while anything is held.
|
||||
pub fn observe(&mut self, mask: u32) {
|
||||
let Some(previous) = self.last_mask.replace(mask) else {
|
||||
return;
|
||||
};
|
||||
let changed = previous ^ mask;
|
||||
let at = self.start.elapsed();
|
||||
if changed & PADDLES != 0 {
|
||||
self.paddle_edges += 1;
|
||||
self.last_paddle = Some(at);
|
||||
}
|
||||
if changed & !PADDLES != 0 {
|
||||
self.other_edges += 1;
|
||||
self.last_other = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pod is *reachable* — the D-pad still reports — but the paddles have
|
||||
/// gone quiet. That, and not a dropped link, is the failure being chased.
|
||||
pub fn paddles_look_dead(&self, cliff: Duration) -> bool {
|
||||
let alive_elsewhere = self.last_other.is_some_and(|t| t > cliff);
|
||||
let paddles_quiet = self.last_paddle.is_none_or(|t| t <= cliff);
|
||||
alive_elsewhere && paddles_quiet
|
||||
}
|
||||
}
|
||||
|
||||
/// A P-256 keypair, and the compressed point to put on the wire.
|
||||
pub struct LocalKey {
|
||||
pub secret: p256::ecdh::EphemeralSecret,
|
||||
pub compressed: Vec<u8>,
|
||||
/// SEC1 uncompressed, `04 ‖ X ‖ Y`, 65 bytes.
|
||||
pub uncompressed: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn local_key() -> LocalKey {
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
let secret = p256::ecdh::EphemeralSecret::random(&mut rand_core::OsRng);
|
||||
let public = secret.public_key();
|
||||
let compressed = public.to_encoded_point(true).as_bytes().to_vec();
|
||||
let uncompressed = public.to_encoded_point(false).as_bytes().to_vec();
|
||||
LocalKey {
|
||||
secret,
|
||||
compressed,
|
||||
uncompressed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort ECDH against the pod's offered point, for the log. A key we
|
||||
/// cannot agree on is a candidate we can stop testing.
|
||||
pub fn shared_secret(local: &LocalKey, peer: &[u8]) -> Result<Vec<u8>> {
|
||||
use p256::elliptic_curve::sec1::FromEncodedPoint;
|
||||
let point = p256::EncodedPoint::from_bytes(peer)
|
||||
.map_err(|e| anyhow::anyhow!("the pod's point is not a valid SEC1 encoding: {e}"))?;
|
||||
let public = Option::<p256::PublicKey>::from(p256::PublicKey::from_encoded_point(&point))
|
||||
.ok_or_else(|| anyhow::anyhow!("the pod's point is not on P-256"))?;
|
||||
Ok(local
|
||||
.secret
|
||||
.diffie_hellman(&public)
|
||||
.raw_secret_bytes()
|
||||
.to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The five frames captured on the tablet, 2026-08-27.
|
||||
const OFFER: &str = "ff03000a21026d059e761978c34f8a13eedbff764a34f0e48577d90fb5dea76ef6238eae8580108080\
|
||||
8c101a285e71479dbe97b0c9bf3c754d594d67ca40792345b69a921905489ec5a3cd0b1e5bb5e36e8cb3e683";
|
||||
|
||||
fn bytes(h: &str) -> Vec<u8> {
|
||||
(0..h.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_captured_offer_parses() {
|
||||
let offer = parse_key_offer(&bytes(OFFER)).expect("captured frame should parse");
|
||||
assert_eq!(offer.public_key.len(), 33);
|
||||
// Compressed SEC1: 0x02 or 0x03 then the x coordinate.
|
||||
assert!(matches!(offer.public_key[0], 0x02 | 0x03));
|
||||
assert_eq!(offer.marker, 0x0203_0000);
|
||||
assert_eq!(offer.trailer.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_flag_and_timer_are_read() {
|
||||
// Before the paddles died, and after.
|
||||
let before = bytes("ff0500ea05180a0c3334433435393033413138451000180f200828093020");
|
||||
let after = bytes("ff0500ea05190a0c3334433435393033413138451001188407200828093020");
|
||||
assert_eq!(parse_status(&before).unwrap(), Status { flag: 0, timer: 15 });
|
||||
assert_eq!(parse_status(&after).unwrap(), Status { flag: 1, timer: 900 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_frame_is_not_mistaken_for_a_key_offer() {
|
||||
assert!(parse_key_offer(&bytes("2308ffffffff0f")).is_none());
|
||||
assert!(parse_status(&bytes("2308ffffffff0f")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_reply_is_shaped_like_the_offer_it_answers() {
|
||||
let key = local_key();
|
||||
assert_eq!(key.compressed.len(), 33);
|
||||
let frame = reply_frame(&key.compressed, 0x0009_0000);
|
||||
let echoed = parse_key_offer(&frame).expect("our own frame should parse");
|
||||
assert_eq!(echoed.public_key, key.compressed);
|
||||
assert_eq!(echoed.marker, 0x0009_0000);
|
||||
assert!(echoed.trailer.is_empty());
|
||||
}
|
||||
|
||||
/// The oracle: the D-pad still moving while the paddles do not is the
|
||||
/// signature being chased, and neither silence alone nor a live paddle is.
|
||||
#[test]
|
||||
fn dead_paddles_need_a_live_d_pad_to_be_evidence() {
|
||||
let start = Instant::now();
|
||||
let cliff = Duration::from_secs(0);
|
||||
|
||||
let mut nothing_at_all = Verdict::new(start);
|
||||
nothing_at_all.observe(0xffff_ffff);
|
||||
assert!(!nothing_at_all.paddles_look_dead(cliff));
|
||||
|
||||
let mut d_pad_only = Verdict::new(start);
|
||||
d_pad_only.observe(0xffff_ffff);
|
||||
d_pad_only.observe(0xffff_fffe); // `left`
|
||||
assert!(d_pad_only.paddles_look_dead(cliff));
|
||||
|
||||
let mut healthy = Verdict::new(start);
|
||||
healthy.observe(0xffff_ffff);
|
||||
healthy.observe(0xffff_fffe);
|
||||
healthy.observe(0xffff_feff); // `−` paddle
|
||||
assert!(!healthy.paddles_look_dead(cliff));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ bikecontrol-fit = { workspace = true }
|
||||
|
||||
tauri = { version = "2", features = [] }
|
||||
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 }
|
||||
chrono = { workspace = true }
|
||||
|
||||
+176
-27
@@ -4,12 +4,13 @@
|
||||
//! 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).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||||
|
||||
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
|
||||
/// claiming success against a path it merely proposed.
|
||||
#[tauri::command]
|
||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
||||
let dest = PathBuf::from(&path);
|
||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||||
let source = {
|
||||
let inner = state.lock();
|
||||
let summary = inner
|
||||
@@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
||||
PathBuf::from(&summary.fit_path)
|
||||
};
|
||||
|
||||
recording::save_copy(&source, &dest)?;
|
||||
let written = dest.display().to_string();
|
||||
match &path {
|
||||
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() {
|
||||
summary.saved_path = Some(written.clone());
|
||||
}
|
||||
@@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
||||
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]
|
||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().reset_ride();
|
||||
@@ -412,10 +441,19 @@ pub fn set_rider_config(
|
||||
state: State<'_, AppState>,
|
||||
config: RiderConfig,
|
||||
) -> Cmd<RiderConfig> {
|
||||
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
|
||||
return Err("Rider and bike mass must be positive and realistic".into());
|
||||
// Every bound refused here is one that makes the engine produce nonsense
|
||||
// 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);
|
||||
Ok(config)
|
||||
}
|
||||
@@ -431,14 +469,40 @@ pub fn set_safety_limits(
|
||||
state: State<'_, AppState>,
|
||||
limits: SafetyLimits,
|
||||
) -> Cmd<SafetyLimits> {
|
||||
if limits.min_gradient_pct >= limits.max_gradient_pct {
|
||||
return Err("Gradient limits are inverted".into());
|
||||
crate::settings::validate_limits(&limits)?;
|
||||
{
|
||||
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);
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
/// else is treated as the YAML profile format.
|
||||
/// Read a file the rider picked, wherever it actually lives.
|
||||
///
|
||||
/// `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]
|
||||
pub fn load_profile_from_path(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
path: FilePath,
|
||||
) -> Cmd<ProfileView> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let stem = std::path::Path::new(&path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
let text = read_picked_file(&app, &path)?;
|
||||
let is_gpx = looks_like_gpx(&path, &text);
|
||||
let name = picked_file_stem(&path)
|
||||
.or_else(|| gpx_name(&text))
|
||||
.unwrap_or_else(|| "Profile".into());
|
||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
||||
let (view, geom) = profile_view::build(&profile, path);
|
||||
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||||
state.lock().set_profile(profile, view.clone(), geom);
|
||||
emit_ride_state(&app);
|
||||
notify(
|
||||
@@ -620,7 +763,14 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
|
||||
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
|
||||
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
||||
@@ -645,12 +795,11 @@ pub fn connect_controller(
|
||||
if address.is_some() {
|
||||
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.
|
||||
// The `+` pod, since 2026-08-27: it is the one that keeps
|
||||
// reporting (§2.3.3). Naming no pod means "connect the
|
||||
// controller", and the controller is the pod that works.
|
||||
let known = state.lock().devices.click_pod_addresses();
|
||||
for id in PodId::BOTH {
|
||||
controller.connect(id, known.get(&id).cloned());
|
||||
}
|
||||
controller.connect(PodId::Plus, known.get(&PodId::Plus).cloned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+232
-35
@@ -62,18 +62,36 @@ const STALE_AFTER: Duration = Duration::from_secs(180);
|
||||
/// How long a *live* link may go without ever carrying a button before we stop
|
||||
/// believing in it.
|
||||
///
|
||||
/// This is not "the rider has not shifted lately" — that is normal, and tearing
|
||||
/// down a working link over it would strand a pod that only advertises while
|
||||
/// awake. It is the narrower and much stranger case from §7.1: frames arriving
|
||||
/// steadily, battery every five seconds, and not one press since the link came
|
||||
/// up. A healthy pod proves itself with its first button and is then never
|
||||
/// touched by this; a wedged one never does, and recycling costs a few seconds
|
||||
/// against a pod that is otherwise useless for the whole ride.
|
||||
/// The §7.1 case: frames arriving steadily, battery every five seconds, and not
|
||||
/// one press in all that time. Recycling costs a few seconds against a pod that
|
||||
/// is otherwise useless for the rest of the ride.
|
||||
///
|
||||
/// It is measured from the last press rather than from the connect, because a
|
||||
/// link can wedge *after* working — sixty presses and then nothing, tablet,
|
||||
/// 2026-08-27 — and an exemption earned by the first button was an exemption
|
||||
/// for the whole ride.
|
||||
///
|
||||
/// The cost of being wrong is a rider who genuinely has not shifted for this
|
||||
/// long losing shifting for the few seconds a reconnect takes, so the window is
|
||||
/// deliberately longer than any climb's worth of steady pedalling.
|
||||
const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
|
||||
/// Upper bound on closing the controller links at exit. Shorter than the
|
||||
/// 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).
|
||||
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.
|
||||
///
|
||||
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
|
||||
@@ -379,6 +397,14 @@ enum Cmd {
|
||||
/// Connect one pod. `address` is used when the scanner has already seen it,
|
||||
/// which is both faster and unambiguous.
|
||||
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 whole difficulty with a Click is that it advertises for only a few
|
||||
@@ -456,6 +482,14 @@ impl ControllerHandle {
|
||||
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).
|
||||
///
|
||||
/// Called from the device scan on every pass. Cheap and idempotent: the
|
||||
@@ -530,6 +564,9 @@ struct Slot {
|
||||
/// frames arriving, and not one press among them.
|
||||
connected_at: Option<tokio::time::Instant>,
|
||||
buttons_this_link: u32,
|
||||
/// When this link last carried a press. `None` until it carries one, which
|
||||
/// is why the silence test below falls back to `connected_at`.
|
||||
last_button: Option<tokio::time::Instant>,
|
||||
/// May this pod be connected the moment the scan sees it?
|
||||
///
|
||||
/// True until the rider disconnects it by hand, because a pod that
|
||||
@@ -546,6 +583,7 @@ impl Default for Slot {
|
||||
events: None,
|
||||
connected_at: None,
|
||||
buttons_this_link: 0,
|
||||
last_button: None,
|
||||
cancel: None,
|
||||
generation: 0,
|
||||
last_seen: None,
|
||||
@@ -587,6 +625,13 @@ async fn run(
|
||||
let mut swapped = false;
|
||||
// One press is one press, whichever pods report it (see `Buttons`).
|
||||
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;
|
||||
// Whether the "+ pod is redundant" refusal has already been logged for the
|
||||
// current state of affairs. See the `Seen` arm.
|
||||
let mut plus_declined = false;
|
||||
|
||||
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||
@@ -601,10 +646,36 @@ async fn run(
|
||||
};
|
||||
match cmd {
|
||||
Cmd::Connect { pod, address } => {
|
||||
// The rule `Cmd::Seen` has always enforced, applied
|
||||
// here too: with the `−` pod up there is nothing for a
|
||||
// `+` link to add and one specific thing for it to
|
||||
// break — connected as a pair, the `−` pod stops
|
||||
// reporting its own paddle (§2.3.1).
|
||||
//
|
||||
// Only the scan-driven path was guarded, so an explicit
|
||||
// request walked straight past it. Housekeeping then
|
||||
// closed the redundant link a second or so later, which
|
||||
// reads as the app handling it and is not the same
|
||||
// thing at all: on the tablet, 2026-08-27 15:55:39, the
|
||||
// pair was joined for 1.4 s and the `−` pod did not
|
||||
// report a press again for the rest of the session.
|
||||
//
|
||||
// `auto` is still armed below, so the fallback stands:
|
||||
// the moment the `−` pod goes away, the `+` pod is
|
||||
// taken on its next advertisement.
|
||||
let plus_up = !plus.idle();
|
||||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||||
// Asking for it by hand re-arms auto-connect, whatever
|
||||
// came before.
|
||||
slot.auto = true;
|
||||
if pod == PodId::Minus && plus_up {
|
||||
tracing::info!(
|
||||
"controller: refusing a − pod link while the + pod is up — \
|
||||
the + pod is the controller, and joining both stops the \
|
||||
− pod reporting its own paddle anyway"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// A second click while a search is running means
|
||||
// "connect", which is what is already happening.
|
||||
if slot.connecting() {
|
||||
@@ -618,30 +689,55 @@ async fn run(
|
||||
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
||||
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
||||
start_attempt(slot, pod, selector, &attempt_tx);
|
||||
if pod == PodId::Plus {
|
||||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||
}
|
||||
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 } => {
|
||||
// One link is the whole controller.
|
||||
//
|
||||
// The `−` pod relays its twin: connected on its own it
|
||||
// delivers its own paddle and D-pad *and* the `+`
|
||||
// paddle and face buttons — all ten buttons, measured
|
||||
// over 445 frames on one characteristic (§2.3.1). So
|
||||
// the `+` pod is not connected while the `−` pod is
|
||||
// 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`
|
||||
// One link is the whole controller — see
|
||||
// `take_plus_pod`, which owns this rule. The short of
|
||||
// it: the `−` pod relays its twin, so all ten buttons
|
||||
// arrive over it alone (§2.3.1, confirmed in the field
|
||||
// 2026-08-21), and a `+` link is a substitute for a `−`
|
||||
// pod that is missing rather than the other half of a
|
||||
// pair. Connecting both is what the merge in `Buttons`
|
||||
// exists to paper over, and it is also the
|
||||
// configuration in which the `−` pod stops reporting
|
||||
// its own paddle — the failure that cost an evening.
|
||||
// Not opening the second link removes both.
|
||||
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
|
||||
if pod == PodId::Minus
|
||||
&& !take_minus_pod(
|
||||
plus.idle(),
|
||||
status_tx.borrow().plus.address.is_some(),
|
||||
tokio::time::Instant::now() >= plus_gate,
|
||||
)
|
||||
{
|
||||
// Once per run of refusals, not once per sighting.
|
||||
// The device list republishes several times a
|
||||
// second and every pass re-reports a visible pod,
|
||||
// so this logged twice a second for as long as the
|
||||
// + pod was in the room — 274 lines in five minutes
|
||||
// on the tablet, burying the connect failures we
|
||||
// were reading the log for.
|
||||
if !plus_declined {
|
||||
tracing::debug!(
|
||||
"controller: + pod seen but the − pod is already speaking for it"
|
||||
"controller: − pod seen; the + pod is the controller and \
|
||||
is up (silenced until this changes)"
|
||||
);
|
||||
plus_declined = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||||
@@ -653,6 +749,9 @@ async fn run(
|
||||
// already found are the seconds it goes back to sleep in.
|
||||
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
||||
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| {
|
||||
let p = s.get_mut(pod);
|
||||
p.address = Some(address);
|
||||
@@ -738,25 +837,37 @@ async fn run(
|
||||
}
|
||||
|
||||
_ = 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 !plus.idle() {
|
||||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||
} else {
|
||||
// The − pod is no longer speaking for the pair, so the next
|
||||
// refusal — if there is one — is news again.
|
||||
plus_declined = false;
|
||||
}
|
||||
|
||||
// Converge on one link. The `+` pod may have connected first —
|
||||
// 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
|
||||
// and is exactly the configuration that breaks the `−` paddle.
|
||||
// Dropping it leaves `auto` alone, so if the `−` pod later goes
|
||||
// away the `+` pod is picked up again on its next advertisement.
|
||||
if !minus.idle() && plus.client.is_some() {
|
||||
if !plus.idle() && minus.client.is_some() {
|
||||
tracing::info!(
|
||||
"controller: − pod is up and relays the pair; closing the redundant + link"
|
||||
"controller: + pod is up and is the controller; closing the − link, \
|
||||
which is the pairing that stops the − pod reporting its own paddle"
|
||||
);
|
||||
forget(PodId::Plus, &mut buttons, &input_tx);
|
||||
plus.generation += 1;
|
||||
plus.events = None;
|
||||
plus.last_seen = None;
|
||||
plus.connected_at = None;
|
||||
if let Some(client) = plus.client.take() {
|
||||
forget(PodId::Minus, &mut buttons, &input_tx);
|
||||
minus.generation += 1;
|
||||
minus.events = None;
|
||||
minus.last_seen = None;
|
||||
minus.connected_at = None;
|
||||
if let Some(client) = minus.client.take() {
|
||||
tokio::spawn(async move { client.shutdown().await });
|
||||
}
|
||||
status_tx.send_modify(|s| s.get_mut(PodId::Plus).reset_link(PodState::Idle));
|
||||
status_tx.send_modify(|s| s.get_mut(PodId::Minus).reset_link(PodState::Idle));
|
||||
}
|
||||
|
||||
// A link that is plainly alive and has never carried a button
|
||||
@@ -773,13 +884,27 @@ async fn run(
|
||||
let slot = slot_mut(&mut minus, &mut plus, id);
|
||||
let alive = slot.client.is_some()
|
||||
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
|
||||
let mute = slot.buttons_this_link == 0
|
||||
&& slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
|
||||
// Silence *since the last press*, not "never pressed".
|
||||
//
|
||||
// This used to exempt any link that had ever carried a
|
||||
// button, on the reasoning that a healthy pod proves itself
|
||||
// once and should never be disturbed again. The tablet
|
||||
// disproved it on 2026-08-27: a link carried sixty presses,
|
||||
// wedged at 15:47:19, and went on streaming battery every
|
||||
// five seconds while ignoring every press for the rest of
|
||||
// the ride. Exempt for life meant dead for the ride.
|
||||
//
|
||||
// So the clock starts at the last press instead, and falls
|
||||
// back to the connect for a link that never carried one.
|
||||
let quiet_since = slot.last_button.or(slot.connected_at);
|
||||
let mute = quiet_since.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
|
||||
if alive && mute && slot.auto {
|
||||
tracing::warn!(
|
||||
pod = id.as_str(),
|
||||
"controller: link is alive but has never carried a button; \
|
||||
recycling it (see REQUIREMENTS §7.1)"
|
||||
presses = slot.buttons_this_link,
|
||||
"controller: link is alive but has carried no button for \
|
||||
{}s; recycling it (see REQUIREMENTS §7.1)",
|
||||
NO_INPUT_AFTER.as_secs()
|
||||
);
|
||||
slot.generation += 1;
|
||||
slot.connected_at = None;
|
||||
@@ -820,6 +945,35 @@ async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// May a `−` pod the scan has just seen be connected?
|
||||
///
|
||||
/// **The `+` pod is the controller.** This is the reverse of what this module
|
||||
/// assumed until 2026-08-27, and the measurement that turned it over is in
|
||||
/// §2.3.3: the `−` pod stops reporting buttons about fifty seconds into every
|
||||
/// session — a status flag flips, and the link stays up and healthy and mute —
|
||||
/// while the `+` pod ran 133 s with 78 paddle edges, no key offer and no flag,
|
||||
/// on the same bench, minutes apart.
|
||||
///
|
||||
/// The `−` pod does deliver more when it works: all ten buttons, its twin's
|
||||
/// relayed. But "when it works" is under a minute without the daily Zwift
|
||||
/// blessing, and the `+` pod alone is a complete shifter — its paddle shifts
|
||||
/// up, `Y` shifts down — which is the thing a ride cannot do without.
|
||||
///
|
||||
/// So the `−` pod is now the substitute, and this is the old rule with the
|
||||
/// pods exchanged. Opening both is still the configuration that breaks the `−`
|
||||
/// pod's own paddle, so still only one link.
|
||||
///
|
||||
/// - `plus_idle` — false when the `+` pod is connected or being connected.
|
||||
/// Nothing else matters then: it is the controller and it is up.
|
||||
/// - `plus_known` — we have an address for a `+` pod. With none there is
|
||||
/// nothing to wait for, and the `−` pod is all there is.
|
||||
/// - `gate_expired` — the `+` pod has been unreachable for [`PLUS_GRACE`].
|
||||
/// A flat `+` pod must not cost the rider the fifty working seconds the `−`
|
||||
/// pod still offers.
|
||||
fn take_minus_pod(plus_idle: bool, plus_known: bool, gate_expired: bool) -> bool {
|
||||
plus_idle && (!plus_known || gate_expired)
|
||||
}
|
||||
|
||||
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
||||
match pod {
|
||||
PodId::Minus => minus,
|
||||
@@ -895,6 +1049,7 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<Co
|
||||
// nothing about this one.
|
||||
slot.connected_at = Some(tokio::time::Instant::now());
|
||||
slot.buttons_this_link = 0;
|
||||
slot.last_button = None;
|
||||
status_tx.send_modify(|s| {
|
||||
let p = s.get_mut(pod);
|
||||
p.state = PodState::Connected;
|
||||
@@ -983,6 +1138,7 @@ fn handle_event(
|
||||
// This link has now proven it carries input, which puts it
|
||||
// beyond the no-input watchdog for as long as it lasts.
|
||||
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
|
||||
slot.last_button = Some(tokio::time::Instant::now());
|
||||
}
|
||||
// A frame is proof the link is up, and it is the *only* proof that
|
||||
// ever arrives — the pod does not announce that it has started
|
||||
@@ -1574,6 +1730,47 @@ mod tests {
|
||||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_link_is_the_whole_controller_and_it_is_the_plus_pod() {
|
||||
// Measured 2026-08-27, both pods on the same bench minutes apart: the −
|
||||
// pod goes mute ~51 s into every session while its link stays up, and
|
||||
// the + pod ran 133 s with 78 paddle edges and never flipped a flag
|
||||
// (§2.3.3). The − pod relays more; the + pod keeps working. Shifting is
|
||||
// what a ride cannot do without, so the + pod is the controller.
|
||||
|
||||
// The + pod is up, or on its way up. Nothing else matters.
|
||||
assert!(!take_minus_pod(false, true, true));
|
||||
assert!(!take_minus_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_minus_pod(true, true, false));
|
||||
|
||||
// No + pod has ever been seen or remembered: this rider's − pod is all
|
||||
// there is, fifty working seconds and all.
|
||||
assert!(take_minus_pod(true, false, false));
|
||||
|
||||
// The + pod is known but has stayed out of reach. Flat, or left in the
|
||||
// garage. Fifty seconds of shifting beats none.
|
||||
assert!(take_minus_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]
|
||||
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()));
|
||||
|
||||
+414
-46
@@ -19,7 +19,8 @@
|
||||
//! point is refused.
|
||||
|
||||
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::uuids;
|
||||
@@ -31,16 +32,55 @@ use uuid::Uuid;
|
||||
|
||||
use crate::controller::{ControllerHandle, PodState};
|
||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||
use crate::known::KnownDevices;
|
||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||
|
||||
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
||||
/// enough that the list feels live.
|
||||
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
|
||||
/// How long one discovery session is held before it is recycled.
|
||||
///
|
||||
/// **This is a rate limit, not a dwell time.** Since Android 7 the platform
|
||||
/// counts an app's scan *starts* and blocks it with
|
||||
/// `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` after five in any thirty seconds — and
|
||||
/// it does so silently, so the app keeps asking and simply stops being told
|
||||
/// about anything. This loop used to open a session every 2.9 s: **ten starts
|
||||
/// per thirty seconds, double the limit, all by itself**, before the trainer's
|
||||
/// 15 s search or a pod's 20 s search asked for one too. A rider who woke their
|
||||
/// pods first spent the pods' search window pushing the count over, and then
|
||||
/// the trainer could not be found — not because it was not advertising, but
|
||||
/// because the app was no longer allowed to hear it.
|
||||
///
|
||||
/// One session per twenty seconds is three starts a minute with the connect
|
||||
/// paths included, which leaves headroom under the limit on the worst day.
|
||||
const SCAN_WINDOW: Duration = Duration::from_secs(20);
|
||||
/// How often the open session is sampled. The list is republished each time, so
|
||||
/// this — not [`SCAN_WINDOW`] — is how live the screen feels, and it is now
|
||||
/// four times quicker than the old whole-pass cadence while starting the radio
|
||||
/// a seventh as often.
|
||||
const SCAN_SAMPLE: Duration = Duration::from_millis(700);
|
||||
/// Poll interval while scanning is switched off.
|
||||
const IDLE_POLL: Duration = Duration::from_millis(400);
|
||||
/// How often to re-check whether a link has finished being built, while the
|
||||
/// scanner is holding off for one (see `scan::gatt_setup`).
|
||||
const GATT_YIELD: Duration = Duration::from_millis(250);
|
||||
/// 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.
|
||||
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
|
||||
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
||||
@@ -126,6 +166,14 @@ pub struct PollResult {
|
||||
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.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ScanSnapshot {
|
||||
@@ -146,8 +194,23 @@ pub struct DeviceRegistry {
|
||||
hr: HeartRateHandle,
|
||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||
scan_on: watch::Sender<bool>,
|
||||
forgotten: HashSet<String>,
|
||||
remembered: HashSet<String>,
|
||||
/// Everything the rider has paired with, and everything they have refused.
|
||||
/// 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.
|
||||
published: Vec<DeviceInfo>,
|
||||
last_trainer: TrainerStatus,
|
||||
@@ -173,8 +236,10 @@ impl DeviceRegistry {
|
||||
hr,
|
||||
scan_rx,
|
||||
scan_on,
|
||||
forgotten: HashSet::new(),
|
||||
remembered: HashSet::new(),
|
||||
known: KnownDevices::default(),
|
||||
auto_off: HashSet::new(),
|
||||
auto: HashMap::new(),
|
||||
seen_now: HashSet::new(),
|
||||
published: Vec::new(),
|
||||
scanning: false,
|
||||
scan_suspended: false,
|
||||
@@ -182,6 +247,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
|
||||
/// our own bookkeeping in both directions.
|
||||
pub fn start_scan(&mut self) {
|
||||
@@ -224,11 +312,16 @@ impl DeviceRegistry {
|
||||
self.set_scanning(true);
|
||||
}
|
||||
|
||||
|
||||
let next = self.build(&trainer);
|
||||
let transitions = state_transitions(&self.published, &next);
|
||||
let changed = next != self.published;
|
||||
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 {
|
||||
changed,
|
||||
transitions,
|
||||
@@ -244,10 +337,12 @@ impl DeviceRegistry {
|
||||
|
||||
let controller = self.controller.status();
|
||||
|
||||
self.seen_now.clear();
|
||||
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
||||
for d in &snapshot.devices {
|
||||
let id = d.address.clone();
|
||||
if self.forgotten.contains(&id) {
|
||||
self.seen_now.insert(id.clone());
|
||||
if self.known.is_forgotten(&id) {
|
||||
continue;
|
||||
}
|
||||
let kind = classify(d);
|
||||
@@ -257,9 +352,16 @@ impl DeviceRegistry {
|
||||
// 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
|
||||
// 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 is_ours(&self.known, kind, &id) {
|
||||
self.controller.pod_seen(pod, &id);
|
||||
}
|
||||
}
|
||||
out.push(DeviceInfo {
|
||||
kind,
|
||||
name: d.label(),
|
||||
@@ -272,7 +374,9 @@ impl DeviceRegistry {
|
||||
},
|
||||
control_acquired: false,
|
||||
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,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
@@ -296,7 +400,7 @@ impl DeviceRegistry {
|
||||
state: ConnectionState::Idle,
|
||||
control_acquired: false,
|
||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||
remembered: true,
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
@@ -308,7 +412,6 @@ impl DeviceRegistry {
|
||||
device.kind = DeviceKind::Trainer;
|
||||
device.state = trainer.state.clone();
|
||||
device.control_acquired = trainer.control_acquired;
|
||||
device.remembered = true;
|
||||
device.error = trainer.error.clone().or_else(|| {
|
||||
trainer
|
||||
.stale
|
||||
@@ -329,7 +432,7 @@ impl DeviceRegistry {
|
||||
let Some(address) = pod.address.clone() else {
|
||||
continue;
|
||||
};
|
||||
if self.forgotten.contains(&address) {
|
||||
if self.known.is_forgotten(&address) {
|
||||
continue;
|
||||
}
|
||||
let kind = match id {
|
||||
@@ -348,7 +451,7 @@ impl DeviceRegistry {
|
||||
state: ConnectionState::Idle,
|
||||
control_acquired: false,
|
||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||
remembered: true,
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
@@ -360,7 +463,6 @@ impl DeviceRegistry {
|
||||
device.kind = kind;
|
||||
device.battery_pct = pod.battery_percent;
|
||||
device.error = pod.error.clone();
|
||||
device.remembered = true;
|
||||
device.state = match pod.state {
|
||||
PodState::Connected => ConnectionState::Connected,
|
||||
PodState::Searching => ConnectionState::Connecting,
|
||||
@@ -380,7 +482,7 @@ impl DeviceRegistry {
|
||||
// link.
|
||||
let hr = self.hr.status();
|
||||
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) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
@@ -396,7 +498,7 @@ impl DeviceRegistry {
|
||||
state: ConnectionState::Idle,
|
||||
control_acquired: false,
|
||||
services: vec![describe_service(HEART_RATE_SERVICE)],
|
||||
remembered: true,
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
@@ -409,7 +511,6 @@ impl DeviceRegistry {
|
||||
device.battery_pct = hr.battery_percent;
|
||||
device.heart_rate_bpm = hr.bpm;
|
||||
device.error = hr.error.clone();
|
||||
device.remembered = true;
|
||||
if let Some(name) = &hr.name {
|
||||
device.name = name.clone();
|
||||
}
|
||||
@@ -429,9 +530,95 @@ impl DeviceRegistry {
|
||||
.then(b.rssi.cmp(&a.rssi))
|
||||
.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
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.published.clone()
|
||||
}
|
||||
@@ -449,13 +636,12 @@ impl DeviceRegistry {
|
||||
// controller supervisor rather than handled here, so the device list
|
||||
// and the connection screen drive the same one link per pod (FR-1.4).
|
||||
if let Some(pod) = device.kind.pod_id() {
|
||||
self.remembered.insert(id.to_string());
|
||||
self.forgotten.remove(id);
|
||||
self.wanted(&device.address);
|
||||
self.controller.connect(pod, Some(device.address.clone()));
|
||||
let mut info = device;
|
||||
info.state = ConnectionState::Connecting;
|
||||
info.error = None;
|
||||
info.remembered = true;
|
||||
info.remembered = self.known.contains(&info.address);
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
@@ -463,13 +649,12 @@ impl DeviceRegistry {
|
||||
// the heart rate supervisor so this list and whatever else drives the
|
||||
// link stay one path.
|
||||
if device.kind == DeviceKind::HeartRate {
|
||||
self.remembered.insert(id.to_string());
|
||||
self.forgotten.remove(id);
|
||||
self.wanted(&device.address);
|
||||
self.hr.connect(Some(device.address.clone()));
|
||||
let mut info = device;
|
||||
info.state = ConnectionState::Connecting;
|
||||
info.error = None;
|
||||
info.remembered = true;
|
||||
info.remembered = self.known.contains(&info.address);
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
@@ -508,8 +693,7 @@ impl DeviceRegistry {
|
||||
// soon as the trainer is no longer attached (FR-1.12).
|
||||
self.set_scanning(false);
|
||||
self.scan_suspended = true;
|
||||
self.remembered.insert(id.to_string());
|
||||
self.forgotten.remove(id);
|
||||
self.wanted(&device.address);
|
||||
self.trainer
|
||||
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
||||
|
||||
@@ -517,7 +701,7 @@ impl DeviceRegistry {
|
||||
info.state = ConnectionState::Connecting;
|
||||
info.control_acquired = false;
|
||||
info.error = None;
|
||||
info.remembered = true;
|
||||
info.remembered = self.known.contains(&info.address);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
@@ -525,6 +709,11 @@ impl DeviceRegistry {
|
||||
let mut device = self
|
||||
.get(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 {
|
||||
// SAF-2 runs inside the supervisor before the link drops.
|
||||
self.trainer.disconnect();
|
||||
@@ -550,12 +739,29 @@ impl DeviceRegistry {
|
||||
if device.kind == DeviceKind::HeartRate {
|
||||
self.hr.disconnect();
|
||||
}
|
||||
self.remembered.remove(id);
|
||||
self.forgotten.insert(id.to_string());
|
||||
self.known.forget(&device.address);
|
||||
self.auto_off.remove(id);
|
||||
self.auto.remove(id);
|
||||
self.published.retain(|d| d.id != id);
|
||||
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).
|
||||
///
|
||||
/// Connecting by address is both faster and unambiguous: the pods share a
|
||||
@@ -616,6 +822,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?
|
||||
///
|
||||
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
|
||||
@@ -686,30 +919,92 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
|
||||
// FR-1.1 lists every peripheral, not only fitness machines: a trainer
|
||||
// is not obliged to advertise FTMS, and the rider needs to see what is
|
||||
// in the room to know the scan is working at all.
|
||||
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
|
||||
generation += 1;
|
||||
let snapshot = match result {
|
||||
Ok(devices) => ScanSnapshot {
|
||||
devices,
|
||||
error: None,
|
||||
generation,
|
||||
},
|
||||
Err(e) => {
|
||||
// Wait out any link still being built before opening a session, for the
|
||||
// same reason the sample loop below yields to one.
|
||||
while scan::gatt_setup_active() {
|
||||
tokio::time::sleep(GATT_YIELD).await;
|
||||
}
|
||||
|
||||
if let Err(e) = scan::begin(&adapter, ScanKind::All, "device list").await {
|
||||
// Debug as well as Display, because the useful half of a BLE
|
||||
// failure is usually in the source chain that Display drops.
|
||||
// "bluetooth error: JNI call failed" was the *entire* symptom of
|
||||
// a detached-thread bug; the Debug form said
|
||||
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have
|
||||
// named it outright.
|
||||
tracing::warn!(error = %e, cause = ?e, "scan failed");
|
||||
ScanSnapshot {
|
||||
// "bluetooth error: JNI call failed" was the *entire* symptom of a
|
||||
// detached-thread bug; the Debug form said
|
||||
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have named
|
||||
// it outright.
|
||||
tracing::warn!(error = %e, cause = ?e, "scan failed to start");
|
||||
generation += 1;
|
||||
let _ = tx.send(ScanSnapshot {
|
||||
devices: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
generation,
|
||||
});
|
||||
tokio::time::sleep(ADAPTER_RETRY).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sample the open session until the window is up — or until the scan is
|
||||
// switched off, which must be honoured *now* rather than at the end of
|
||||
// the window. A connect suspends this loop precisely so the two do not
|
||||
// fight over the radio, and a suspension that took twenty seconds to
|
||||
// land would be no suspension at all.
|
||||
let window_ends = tokio::time::Instant::now() + SCAN_WINDOW;
|
||||
let mut outcome = "recycled";
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SCAN_SAMPLE) => {}
|
||||
changed = on.changed() => {
|
||||
if changed.is_err() || !*on.borrow() {
|
||||
outcome = "suspended";
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = tx.send(snapshot);
|
||||
}
|
||||
generation += 1;
|
||||
match scan::peek(&adapter, ScanKind::All).await {
|
||||
Ok(devices) => {
|
||||
let _ = tx.send(ScanSnapshot {
|
||||
devices,
|
||||
error: None,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, cause = ?e, "scan sample failed");
|
||||
let _ = tx.send(ScanSnapshot {
|
||||
devices: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
generation,
|
||||
});
|
||||
outcome = "failed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Somebody is building a link (`scan::gatt_setup`). Android drops a
|
||||
// GATT connection that is discovering services while a scan runs —
|
||||
// on the tablet that was every automatic reconnect failing with
|
||||
// `Disconnected while discovering services`. So give the radio up
|
||||
// for the second or two it takes, and pick it up again after.
|
||||
//
|
||||
// Only for that window, deliberately. The suspension that covered
|
||||
// the whole reconnect — search and backoff included — kept this
|
||||
// loop off the air for as long as the trainer was asleep, which
|
||||
// starved every other device of discovery: a pod that dropped could
|
||||
// never be seen again.
|
||||
if scan::gatt_setup_active() {
|
||||
outcome = "yielding to a connect";
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= window_ends {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Recycled rather than held forever: a peripheral that stops
|
||||
// advertising stays in the backend's map until the session ends, so
|
||||
// without this the list would accumulate devices that left the room.
|
||||
// Twenty seconds is how stale a departed device may look.
|
||||
scan::end(&adapter, "device list", outcome).await;
|
||||
tokio::time::sleep(IDLE_POLL).await;
|
||||
}
|
||||
}
|
||||
@@ -819,6 +1114,79 @@ mod tests {
|
||||
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]
|
||||
fn well_known_services_are_named_and_zwift_is_recognised() {
|
||||
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Devices the rider has paired before, remembered across launches (FR-1.5).
|
||||
//!
|
||||
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
|
||||
//! which meant it lasted exactly as long as the process. Every launch started
|
||||
//! from nothing: find the trainer, press Connect, find the strap, press
|
||||
//! Connect, and only then ride. This is that set written down.
|
||||
//!
|
||||
//! ```text
|
||||
//! app_data_dir()/devices.json
|
||||
//! { "version": 1,
|
||||
//! "devices": [ { address, name, kind }, … ],
|
||||
//! "forgotten": [ address, … ] }
|
||||
//! ```
|
||||
//!
|
||||
//! Two lists rather than one, because *forgotten* is not merely "absent".
|
||||
//! Absent means never seen; forgotten means the rider looked at this device and
|
||||
//! said no, and auto-connect has to keep honouring that on the next launch too.
|
||||
//!
|
||||
//! The file is small and written only when something actually changes — a pair,
|
||||
//! an unpair, a name learned — so this never lands in the ride loop's path. It
|
||||
//! is also *advisory*: a corrupt or unreadable file costs the rider their
|
||||
//! auto-connect, never their ride, so every failure here is logged and
|
||||
//! swallowed rather than propagated.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::devices::DeviceKind;
|
||||
|
||||
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||
/// we do not know is discarded rather than guessed at.
|
||||
const VERSION: u32 = 1;
|
||||
const FILE: &str = "devices.json";
|
||||
|
||||
/// One device the rider has paired with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnownDevice {
|
||||
/// As the adapter reports it. Matching is case-insensitive — see [`key`] —
|
||||
/// but what is written down is what we were told.
|
||||
pub address: String,
|
||||
pub name: Option<String>,
|
||||
pub kind: DeviceKind,
|
||||
}
|
||||
|
||||
/// The file on disk. Kept separate from the in-memory form so the indexes below
|
||||
/// are never serialised.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct Stored {
|
||||
version: u32,
|
||||
devices: Vec<KnownDevice>,
|
||||
forgotten: Vec<String>,
|
||||
}
|
||||
|
||||
/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
|
||||
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
|
||||
/// but not a platform is not much of a pairing.
|
||||
fn key(address: &str) -> String {
|
||||
address.trim().to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Everything the app remembers about the hardware in the room, plus where to
|
||||
/// write it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KnownDevices {
|
||||
devices: BTreeMap<String, KnownDevice>,
|
||||
forgotten: BTreeSet<String>,
|
||||
/// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
|
||||
/// is an `AppHandle` to ask for a data directory, so the registry spends
|
||||
/// the first moments of the process with an in-memory-only store.
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl KnownDevices {
|
||||
/// Read the file, or start empty if it is missing, unreadable or from a
|
||||
/// version we do not understand.
|
||||
pub fn load(path: PathBuf) -> Self {
|
||||
let mut out = Self {
|
||||
path: Some(path.clone()),
|
||||
..Self::default()
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
|
||||
return out;
|
||||
}
|
||||
};
|
||||
let stored: Stored = match serde_json::from_str(&text) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
|
||||
return out;
|
||||
}
|
||||
};
|
||||
if stored.version != VERSION {
|
||||
tracing::warn!(
|
||||
found = stored.version,
|
||||
expected = VERSION,
|
||||
"remembered devices are from another version; starting fresh"
|
||||
);
|
||||
return out;
|
||||
}
|
||||
for device in stored.devices {
|
||||
out.devices.insert(key(&device.address), device);
|
||||
}
|
||||
out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.devices.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.devices.is_empty()
|
||||
}
|
||||
|
||||
/// Has this device been paired with before?
|
||||
pub fn contains(&self, address: &str) -> bool {
|
||||
self.devices.contains_key(&key(address))
|
||||
}
|
||||
|
||||
/// Did the rider say no to this device?
|
||||
pub fn is_forgotten(&self, address: &str) -> bool {
|
||||
self.forgotten.contains(&key(address))
|
||||
}
|
||||
|
||||
/// The remembered device of this kind, if there is one. Used to prefer
|
||||
/// *our* Click over the identically-named one in the next room.
|
||||
pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
|
||||
self.devices.values().find(|d| d.kind == kind)
|
||||
}
|
||||
|
||||
pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
|
||||
self.first_of(kind).is_some()
|
||||
}
|
||||
|
||||
/// Record a pairing. No-op — and no write — when nothing changed, which is
|
||||
/// the common case: this is called from the device poll, four times a
|
||||
/// second.
|
||||
pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
|
||||
// Nothing useful to auto-connect to, and a list full of every anonymous
|
||||
// peripheral in the building helps nobody.
|
||||
if kind == DeviceKind::Unknown || address.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = key(address);
|
||||
let entry = KnownDevice {
|
||||
address: address.to_string(),
|
||||
name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
|
||||
kind,
|
||||
};
|
||||
let unchanged = self.devices.get(&id) == Some(&entry);
|
||||
let was_forgotten = self.forgotten.remove(&id);
|
||||
if unchanged && !was_forgotten {
|
||||
return;
|
||||
}
|
||||
tracing::info!(address, name, ?kind, "remembering device");
|
||||
self.devices.insert(id, entry);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// The rider said no. Both halves matter: drop the pairing *and* record the
|
||||
/// refusal, so the next launch does not helpfully connect it again.
|
||||
pub fn forget(&mut self, address: &str) {
|
||||
let id = key(address);
|
||||
let removed = self.devices.remove(&id).is_some();
|
||||
let added = self.forgotten.insert(id);
|
||||
if removed || added {
|
||||
tracing::info!(address, "forgetting device");
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit connect outranks an earlier refusal.
|
||||
pub fn unforget(&mut self, address: &str) {
|
||||
if self.forgotten.remove(&key(address)) {
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the file, atomically: a half-written `devices.json` would be
|
||||
/// discarded whole on the next launch, and losing the pairings because the
|
||||
/// power went out mid-`write` is exactly the failure this module exists to
|
||||
/// prevent.
|
||||
fn save(&self) {
|
||||
let Some(path) = &self.path else {
|
||||
return;
|
||||
};
|
||||
let stored = Stored {
|
||||
version: VERSION,
|
||||
devices: self.devices.values().cloned().collect(),
|
||||
forgotten: self.forgotten.iter().cloned().collect(),
|
||||
};
|
||||
if let Err(e) = write_atomic(path, &stored) {
|
||||
// Advisory, not fatal: the rider loses auto-connect on the next
|
||||
// launch, never this ride.
|
||||
tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||
let text = serde_json::to_string_pretty(stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Where the remembered devices live: beside the recorded rides, in the app's
|
||||
/// own data directory.
|
||||
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||
Ok(dir.join(FILE))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bikecontrol-known-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(FILE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pairing_survives_a_reload() {
|
||||
let path = temp();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut known = KnownDevices::load(path.clone());
|
||||
known.remember(
|
||||
"F4:C4:59:03:A1:8E",
|
||||
Some("Zwift Click"),
|
||||
DeviceKind::ClickMinus,
|
||||
);
|
||||
|
||||
let again = KnownDevices::load(path);
|
||||
assert!(again.contains("F4:C4:59:03:A1:8E"));
|
||||
// The same peripheral, as Android spells it.
|
||||
assert!(again.contains("f4:c4:59:03:a1:8e"));
|
||||
assert_eq!(
|
||||
again
|
||||
.first_of(DeviceKind::ClickMinus)
|
||||
.unwrap()
|
||||
.name
|
||||
.as_deref(),
|
||||
Some("Zwift Click")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_outlives_the_process_too() {
|
||||
// The whole point: "no" has to be remembered as firmly as "yes", or the
|
||||
// next launch connects the neighbour's trainer again.
|
||||
let path = temp().with_extension("forget.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut known = KnownDevices::load(path.clone());
|
||||
known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
|
||||
known.forget("aa:bb:cc:dd:ee:ff");
|
||||
|
||||
let again = KnownDevices::load(path);
|
||||
assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
|
||||
assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||
assert!(!again.any_of_kind(DeviceKind::Trainer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_again_undoes_a_refusal() {
|
||||
let mut known = KnownDevices::default();
|
||||
known.forget("AA:BB:CC:DD:EE:FF");
|
||||
assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
|
||||
assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||
assert!(known.contains("AA:BB:CC:DD:EE:FF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unidentified_peripheral_is_not_worth_remembering() {
|
||||
let mut known = KnownDevices::default();
|
||||
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
|
||||
assert!(known.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
|
||||
let path = temp().with_extension("corrupt.json");
|
||||
std::fs::write(&path, b"{ this is not json").unwrap();
|
||||
let known = KnownDevices::load(path);
|
||||
assert!(known.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub mod derive;
|
||||
pub mod devices;
|
||||
pub mod events;
|
||||
pub mod heart_rate;
|
||||
pub mod known;
|
||||
pub mod settings;
|
||||
pub mod profile_view;
|
||||
pub mod recording;
|
||||
pub mod samples;
|
||||
@@ -66,6 +68,7 @@ pub fn run() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// ride lifecycle
|
||||
@@ -96,6 +99,8 @@ pub fn run() {
|
||||
commands::set_rider_config,
|
||||
commands::safety_limits,
|
||||
commands::set_safety_limits,
|
||||
commands::preferences,
|
||||
commands::set_preferences,
|
||||
// profiles
|
||||
commands::load_profile_from_path,
|
||||
commands::load_profile_from_text,
|
||||
@@ -119,6 +124,29 @@ pub fn run() {
|
||||
])
|
||||
.setup(|app| {
|
||||
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.
|
||||
handle.state::<AppState>().lock().devices.start_scan();
|
||||
state::spawn_ride_loop(handle.clone());
|
||||
|
||||
@@ -333,13 +333,17 @@ fn block_label(block: &Block) -> String {
|
||||
amplitude,
|
||||
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 } => {
|
||||
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
|
||||
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
|
||||
}
|
||||
Block::Terrain { points } => {
|
||||
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
|
||||
format!("terrain · {:.1} km", d / 1000.0)
|
||||
format!(
|
||||
"{} segment{}",
|
||||
segments.len(),
|
||||
if segments.len() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Block::Terrain { .. } => "terrain".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Rider setup, remembered across launches (FR-7.4).
|
||||
//!
|
||||
//! Two things were wrong before this module existed, and they were the same
|
||||
//! thing twice.
|
||||
//!
|
||||
//! `RiderConfig` lived only in [`crate::backend::RideInputs`], which meant it
|
||||
//! lived exactly as long as the process — and nothing in the UI ever called
|
||||
//! `set_rider_config`, so in practice every ride was ridden as a **105 kg
|
||||
//! rider on an 8 kg bike**, the struct's own defaults. Mass is not a cosmetic
|
||||
//! setting: it sets the speed a given power produces, the ETA that follows from
|
||||
//! it, the calorie estimate, and how a 6% ramp feels. A 62 kg rider was being
|
||||
//! shown somebody else's ride.
|
||||
//!
|
||||
//! ```text
|
||||
//! app_data_dir()/settings.json
|
||||
//! { "version": 1, "rider": {…}, "limits": {…}, "prefs": {…} }
|
||||
//! ```
|
||||
//!
|
||||
//! Written whole on every change — it is three small structs, and settings are
|
||||
//! changed by hand at human speed, so this never lands in the ride loop's path.
|
||||
//! Like [`crate::known`] it is *advisory*: an unreadable file costs the rider
|
||||
//! their setup, never their ride, so every failure is logged and swallowed.
|
||||
//!
|
||||
//! ## Why `prefs` is here and not in `RiderConfig`
|
||||
//!
|
||||
//! FTP, maximum heart rate and the unit system change nothing about the
|
||||
//! physics — they decide how a number is *drawn*. `bikecontrol_core::types` is
|
||||
//! the frozen contract the engine and the FIT writer share, and a display
|
||||
//! preference has no business in it. The file keeps them side by side because
|
||||
//! that is where the rider expects to find them; the types stay apart.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bikecontrol_core::types::{RiderConfig, SafetyLimits};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||
/// we do not know is discarded rather than guessed at.
|
||||
const VERSION: u32 = 1;
|
||||
const FILE: &str = "settings.json";
|
||||
|
||||
/// Which units the rider reads. Everything is *stored* and *recorded* in SI
|
||||
/// regardless — this is the last conversion before the glass, so a FIT file
|
||||
/// never depends on what the screen was set to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Units {
|
||||
#[default]
|
||||
Metric,
|
||||
Imperial,
|
||||
}
|
||||
|
||||
/// Display preferences. Not physics — see the module note.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Preferences {
|
||||
/// Functional threshold power, watts. The reference every power zone is a
|
||||
/// fraction of; zero means the rider has not set one and zones are not
|
||||
/// drawn at all, which is honest — a zone against a guessed FTP is worse
|
||||
/// than no zone.
|
||||
pub ftp_w: u16,
|
||||
/// Maximum heart rate, bpm. Same contract: zero means no HR zones.
|
||||
pub max_hr_bpm: u16,
|
||||
pub units: Units,
|
||||
}
|
||||
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Deliberately no default FTP: an invented threshold would colour
|
||||
// every ride wrong and look authoritative doing it.
|
||||
ftp_w: 0,
|
||||
max_hr_bpm: 0,
|
||||
units: Units::Metric,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file on disk.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct Stored {
|
||||
version: u32,
|
||||
rider: RiderConfig,
|
||||
limits: SafetyLimits,
|
||||
prefs: Preferences,
|
||||
}
|
||||
|
||||
/// The rider's setup, plus where to write it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Settings {
|
||||
pub prefs: Preferences,
|
||||
/// `None` before [`Settings::attach`] — `AppState::new` runs before there
|
||||
/// is an `AppHandle` to ask for a data directory, so the first moments of
|
||||
/// the process are in-memory only.
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Point at the file and read it back over the defaults already in
|
||||
/// `rider` and `limits`.
|
||||
///
|
||||
/// Applied by `&mut` rather than returned because a partial application is
|
||||
/// the one outcome that must not be possible: the rider's mass and the
|
||||
/// safety clamps that bound what can be sent to the trainer come from the
|
||||
/// same file and are adopted in the same breath.
|
||||
pub fn attach(&mut self, path: PathBuf, rider: &mut RiderConfig, limits: &mut SafetyLimits) {
|
||||
self.path = Some(path.clone());
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "could not read settings");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stored: Stored = match serde_json::from_str(&text) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "settings are unreadable; using defaults");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if stored.version != VERSION {
|
||||
tracing::warn!(
|
||||
found = stored.version,
|
||||
expected = VERSION,
|
||||
"settings are from another version; using defaults"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A stored file that fails the same checks the commands apply is not
|
||||
// trusted just because it is on disk — it may predate a tightened
|
||||
// bound, or have been edited by hand.
|
||||
if validate_rider(&stored.rider).is_err() || validate_limits(&stored.limits).is_err() {
|
||||
tracing::warn!("stored settings are out of range; using defaults");
|
||||
return;
|
||||
}
|
||||
*rider = stored.rider;
|
||||
*limits = stored.limits;
|
||||
self.prefs = stored.prefs;
|
||||
tracing::info!(rider_kg = stored.rider.rider_kg, "settings restored");
|
||||
}
|
||||
|
||||
/// Write the file, atomically. A half-written `settings.json` is discarded
|
||||
/// whole on the next launch, which would silently put the rider back on a
|
||||
/// 105 kg default — the exact failure this module exists to prevent.
|
||||
pub fn save(&self, rider: &RiderConfig, limits: &SafetyLimits) {
|
||||
let Some(path) = &self.path else {
|
||||
return;
|
||||
};
|
||||
let stored = Stored {
|
||||
version: VERSION,
|
||||
rider: *rider,
|
||||
limits: *limits,
|
||||
prefs: self.prefs,
|
||||
};
|
||||
if let Err(e) = write_atomic(path, &stored) {
|
||||
tracing::warn!(path = %path.display(), error = %e, "could not save settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds worth refusing, with the reason a rider can act on.
|
||||
///
|
||||
/// These are not taste. Each one is a value that makes the ride engine produce
|
||||
/// nonsense rather than merely something unusual — a zero mass divides, a zero
|
||||
/// wheel circumference divides, an efficiency above 1 invents power.
|
||||
pub fn validate_rider(c: &RiderConfig) -> Result<(), String> {
|
||||
let check = |ok: bool, msg: &str| if ok { Ok(()) } else { Err(msg.to_string()) };
|
||||
check(
|
||||
(20.0..=250.0).contains(&c.rider_kg),
|
||||
"Rider mass must be between 20 and 250 kg.",
|
||||
)?;
|
||||
check(
|
||||
(1.0..=50.0).contains(&c.bike_kg),
|
||||
"Bike mass must be between 1 and 50 kg.",
|
||||
)?;
|
||||
check(
|
||||
(0.0005..=0.05).contains(&c.crr),
|
||||
"Rolling resistance is typically 0.002–0.010 for road tyres.",
|
||||
)?;
|
||||
check(
|
||||
(0.1..=1.5).contains(&c.cda),
|
||||
"CdA must be between 0.1 and 1.5 m² — a road position is about 0.32.",
|
||||
)?;
|
||||
check(
|
||||
(0.5..=1.0).contains(&c.drivetrain_efficiency),
|
||||
"Drivetrain efficiency is a fraction between 0.5 and 1.0 — about 0.97 for a clean chain.",
|
||||
)?;
|
||||
check(
|
||||
(0.5..=1.6).contains(&c.air_density),
|
||||
"Air density must be between 0.5 and 1.6 kg/m³ — sea level is 1.225.",
|
||||
)?;
|
||||
check(
|
||||
(0.5..=3.5).contains(&c.wheel_circumference_m),
|
||||
"Wheel circumference must be between 0.5 and 3.5 m — a 700×25 is about 2.1.",
|
||||
)?;
|
||||
check(
|
||||
(0.1..=0.25).contains(&c.crank_length_m),
|
||||
"Crank length must be between 0.10 and 0.25 m — road cranks are 0.170–0.175.",
|
||||
)?;
|
||||
check(
|
||||
(0.5..=20.0).contains(&c.physical_development_m),
|
||||
"Physical development must be between 0.5 and 20 m per crank revolution.",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_limits(l: &SafetyLimits) -> Result<(), String> {
|
||||
if l.min_gradient_pct >= l.max_gradient_pct {
|
||||
return Err("Gradient limits are inverted.".into());
|
||||
}
|
||||
if l.min_resistance >= l.max_resistance {
|
||||
return Err("Resistance limits are inverted.".into());
|
||||
}
|
||||
if l.min_power_w >= l.max_power_w {
|
||||
return Err("Power limits are inverted.".into());
|
||||
}
|
||||
if l.max_power_w > 2000 {
|
||||
return Err("Maximum power above 2000 W is not a limit, it is a hazard.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_prefs(p: &Preferences) -> Result<(), String> {
|
||||
// Zero is the "not set" case for both, and must stay reachable: a rider who
|
||||
// does not know their FTP is better served by no zones than by a guess.
|
||||
if p.ftp_w != 0 && !(50..=600).contains(&p.ftp_w) {
|
||||
return Err("FTP must be between 50 and 600 W, or 0 for no zones.".into());
|
||||
}
|
||||
if p.max_hr_bpm != 0 && !(100..=230).contains(&p.max_hr_bpm) {
|
||||
return Err("Maximum heart rate must be between 100 and 230 bpm, or 0 for no zones.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||
let text = serde_json::to_string_pretty(stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Beside the remembered devices and the recorded rides.
|
||||
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||
Ok(dir.join(FILE))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("bikecontrol-settings-test-{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rider_survives_the_process() {
|
||||
let path = temp("roundtrip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut settings = Settings::default();
|
||||
let mut rider = RiderConfig::default();
|
||||
let mut limits = SafetyLimits::default();
|
||||
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||
rider.rider_kg = 62.0;
|
||||
settings.prefs.ftp_w = 240;
|
||||
settings.save(&rider, &limits);
|
||||
|
||||
let mut again = Settings::default();
|
||||
let mut rider2 = RiderConfig::default();
|
||||
let mut limits2 = SafetyLimits::default();
|
||||
again.attach(path.clone(), &mut rider2, &mut limits2);
|
||||
assert_eq!(rider2.rider_kg, 62.0);
|
||||
assert_eq!(again.prefs.ftp_w, 240);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_leaves_the_defaults_alone() {
|
||||
let path = temp("missing");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut settings = Settings::default();
|
||||
let mut rider = RiderConfig::default();
|
||||
let mut limits = SafetyLimits::default();
|
||||
settings.attach(path, &mut rider, &mut limits);
|
||||
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||
assert_eq!(settings.prefs, Preferences::default());
|
||||
}
|
||||
|
||||
/// A file someone edited by hand must not be able to put a zero mass into
|
||||
/// the engine, which would divide by it on the next tick.
|
||||
#[test]
|
||||
fn an_out_of_range_file_is_refused_whole() {
|
||||
let path = temp("nonsense");
|
||||
let stored = Stored {
|
||||
version: VERSION,
|
||||
rider: RiderConfig {
|
||||
rider_kg: 0.0,
|
||||
..RiderConfig::default()
|
||||
},
|
||||
limits: SafetyLimits::default(),
|
||||
prefs: Preferences {
|
||||
ftp_w: 300,
|
||||
..Preferences::default()
|
||||
},
|
||||
};
|
||||
write_atomic(&path, &stored).unwrap();
|
||||
|
||||
let mut settings = Settings::default();
|
||||
let mut rider = RiderConfig::default();
|
||||
let mut limits = SafetyLimits::default();
|
||||
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||
// Refused whole: the preferences in the same file do not sneak through.
|
||||
assert_eq!(settings.prefs.ftp_w, 0);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_means_no_zones_rather_than_an_invalid_ftp() {
|
||||
assert!(validate_prefs(&Preferences::default()).is_ok());
|
||||
assert!(validate_prefs(&Preferences {
|
||||
ftp_w: 20,
|
||||
..Preferences::default()
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ pub struct Inner {
|
||||
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
||||
/// the webview asks for them.
|
||||
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_m: f64,
|
||||
lap_power_sum: f64,
|
||||
@@ -104,6 +106,7 @@ impl Inner {
|
||||
laps: Vec::new(),
|
||||
last_summary: None,
|
||||
recovered: Vec::new(),
|
||||
settings: crate::settings::Settings::default(),
|
||||
lap_start_ms: 0,
|
||||
lap_start_m: 0.0,
|
||||
lap_power_sum: 0.0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "BikeControl",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.6",
|
||||
"identifier": "paris.tourolle.bikecontrol",
|
||||
"build": {
|
||||
"frontendDist": "../ui/dist",
|
||||
@@ -43,7 +43,7 @@
|
||||
],
|
||||
"category": "Utility",
|
||||
"android": {
|
||||
"versionCode": 1100
|
||||
"versionCode": 1206
|
||||
},
|
||||
"shortDescription": "Indoor cycling trainer control",
|
||||
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import HelpOverlay from './components/HelpOverlay.svelte';
|
||||
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
||||
import RideScreen from './components/RideScreen.svelte';
|
||||
import SettingsScreen from './components/SettingsScreen.svelte';
|
||||
import SummaryScreen from './components/SummaryScreen.svelte';
|
||||
import Toasts from './components/Toasts.svelte';
|
||||
|
||||
@@ -39,6 +40,23 @@
|
||||
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:
|
||||
// nudging the gradient of a ride that has ended is meaningless, and space
|
||||
// would silently start a new one out from under the summary.
|
||||
@@ -151,6 +169,13 @@
|
||||
if (!input.pressed) return;
|
||||
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
|
||||
// face buttons carry that screen's own two actions instead. Leaving `a` on
|
||||
// toggle-pause here would restart the ride the rider just finished.
|
||||
@@ -211,6 +236,8 @@
|
||||
</div>
|
||||
{:else if !ready}
|
||||
<div class="boot"><span class="label">Starting…</span></div>
|
||||
{:else if app.screen === 'settings'}
|
||||
<SettingsScreen />
|
||||
{:else if app.screen === 'summary'}
|
||||
<SummaryScreen />
|
||||
{:else if app.screen === 'ride'}
|
||||
|
||||
@@ -39,6 +39,25 @@
|
||||
--power: #dfe8f3;
|
||||
--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>. */
|
||||
--gap: 24px;
|
||||
--edge: 44px;
|
||||
|
||||
+223
-232
@@ -1,27 +1,33 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The Zwift Click, as two pods (FR-1.4, FR-9.1–9.2).
|
||||
* The Zwift Click (FR-1.4, FR-9.1–9.2).
|
||||
*
|
||||
* A Click v2 is **two peripherals**, and until now the app showed one line
|
||||
* for both: connect, and you got whichever pod answered first, with no way to
|
||||
* tell which one that was or that the other was missing entirely. Each pod
|
||||
* now has a card of its own — its own state, battery, address and proof that
|
||||
* its buttons arrive.
|
||||
* A Click v2 is two peripherals, but it is **one controller**: connected on
|
||||
* its own, the `−` pod delivers all ten buttons — its own paddle and D-pad,
|
||||
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
|
||||
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
|
||||
* 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
|
||||
* bar. Nothing a pod advertises says which end of the handlebar it is
|
||||
* clamped to, so left and right would be a guess; the paddle is printed on
|
||||
* the pod, and pressing it settles the question on screen (`confirmed`).
|
||||
* bar. Nothing a pod advertises says which end of the handlebar it is clamped
|
||||
* to, so left and right would be a guess; the paddle is printed on the pod,
|
||||
* 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.
|
||||
* A Click sleeps within seconds and only advertises while awake (A-4), which
|
||||
* no rider can guess from the words "not connected" — and which is also why
|
||||
* connecting is not a button they have to win a race with: the running scan
|
||||
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
|
||||
* here are for overriding that, not for driving it.
|
||||
* The second job of this panel is to say what to *do* when nothing is
|
||||
* connected — a Click sleeps within seconds and only advertises while awake
|
||||
* (A-4), which no rider can guess from the words "not connected". But that is
|
||||
* the only thing worth a sentence, and only while it is true: connected, this
|
||||
* panel is two lines and a badge. The five-step drill it used to open with
|
||||
* 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 { 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 pods = $derived(controller ? [controller.minus, controller.plus] : []);
|
||||
@@ -29,7 +35,12 @@
|
||||
* pod stays missing — and the fix belongs next to the symptom. */
|
||||
const scanning = $derived(app.devices.scanning);
|
||||
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
||||
const bothConnected = $derived(pods.length === 2 && pods.every((p) => p.state === 'connected'));
|
||||
/** The controller. Measured 2026-08-27: the `+` pod keeps reporting where the
|
||||
* `−` pod goes mute about fifty seconds in (§2.3.3). */
|
||||
const plusLive = $derived(controller?.plus.state === 'connected');
|
||||
/** Running on the fallback: the `−` pod, which relays everything but stops
|
||||
* reporting about fifty seconds in unless Zwift has blessed it today. */
|
||||
const minusOnly = $derived(!plusLive && controller?.minus.state === 'connected');
|
||||
const busy = $derived(pods.some((p) => p.state === 'searching'));
|
||||
/** 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. */
|
||||
@@ -53,8 +64,8 @@
|
||||
|
||||
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
||||
const PURPOSE: Record<Pod, string> = {
|
||||
minus: 'Shift down · D-pad',
|
||||
plus: 'Shift up · A B Y Z',
|
||||
minus: 'All ten buttons, for ~50 s',
|
||||
};
|
||||
|
||||
function connect(pod: Pod) {
|
||||
@@ -69,109 +80,101 @@
|
||||
<section class="click">
|
||||
<header>
|
||||
<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">
|
||||
{#if !scanning}
|
||||
<!-- 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. -->
|
||||
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
||||
{:else if !bothConnected}
|
||||
{:else if !plusLive}
|
||||
<!-- The + pod, not both: joining the pair is what stops the − pod
|
||||
reporting its own paddle, and the + pod is the one that lasts. -->
|
||||
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
|
||||
{busy ? 'Searching…' : 'Connect now'}
|
||||
{busy ? 'Searching…' : 'Find + pod'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if anyConnected}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||
Disconnect both
|
||||
Disconnect
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</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}
|
||||
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
|
||||
on each pod.
|
||||
{:else if bothConnected}
|
||||
Both pods are connected and will reconnect on their own if one drops.
|
||||
{:else}
|
||||
<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 class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p>
|
||||
{:else if minusOnly}
|
||||
<p class="lede">
|
||||
Running on the <strong>− pod</strong>, which relays all ten buttons but stops reporting
|
||||
after about a minute unless Zwift has blessed it today. Press a button on the
|
||||
<strong>+ pod</strong> for shifting that lasts.
|
||||
</p>
|
||||
{:else if !plusLive}
|
||||
<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. Its paddle shifts up and
|
||||
<span class="kbd">Y</span> shifts down.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="pods">
|
||||
{#each pods as pod (pod.pod)}
|
||||
{@const dormant = pod.pod === 'minus' && plusLive && 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="what">
|
||||
<span class="label">{pod.symbol} pod</span>
|
||||
<span class="purpose">{PURPOSE[pod.pod]}</span>
|
||||
</span>
|
||||
<span class="state {STATE_TONE[pod.state]}">
|
||||
<span class="dot"></span>{STATE_TEXT[pod.state]}
|
||||
|
||||
<div class="what">
|
||||
<span class="pod-name">{pod.symbol} pod</span>
|
||||
<span class="purpose">
|
||||
{#if dormant}
|
||||
<!-- Not a fault, and the panel must not let it read as one: this
|
||||
pod is idle because joining the pair is what breaks it. -->
|
||||
Held back — the + pod is the controller
|
||||
{: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>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
<div>
|
||||
<dt>Battery</dt>
|
||||
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Buttons seen</dt>
|
||||
<span class="facts">
|
||||
{#if pod.batteryPercent != null}<span>{pod.batteryPercent}%</span>{/if}
|
||||
<!-- Connected and silent looks exactly like working until you press
|
||||
something, so the count is the honest test of the link. -->
|
||||
<dd>
|
||||
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`}
|
||||
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Address</dt>
|
||||
<dd class="addr">{pod.address ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if pod.confirmed}
|
||||
<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 pod.state === 'connected'}
|
||||
<span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if pod.contradicted}
|
||||
<p class="note tone-warn">
|
||||
This pod sent the other paddle. If the pair is the wrong way round, swap them.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pod.error}
|
||||
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
|
||||
<p class="note tone-bad">{pod.error}</p>
|
||||
{/if}
|
||||
<span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
|
||||
<span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
|
||||
</span>
|
||||
|
||||
<div class="controls">
|
||||
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
|
||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
|
||||
{: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 if dormant}
|
||||
<!-- No "connect anyway". Joining the pair is the one action that
|
||||
stops the − pod reporting its own paddle (§2.3.1), and offering
|
||||
it beside a working controller put the failure one tap from a
|
||||
rider hunting for a fix. Rust refuses it too. The fallback is
|
||||
not lost: the moment the − pod goes, this pod is taken. -->
|
||||
<span class="held">Held in reserve</span>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
||||
Look for it now
|
||||
</button>
|
||||
<button class="btn ghost" onclick={() => connect(pod.pod)}>Find it</button>
|
||||
{/if}
|
||||
</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>
|
||||
{/each}
|
||||
|
||||
@@ -183,11 +186,9 @@
|
||||
{#if mixedUp || controller?.swapped}
|
||||
<div class="swap">
|
||||
<span>
|
||||
{#if mixedUp}
|
||||
A pod is sending the other pod's paddle — the pair may be filed the wrong way round.
|
||||
{:else}
|
||||
The pods are swapped from what their advertisement claims.
|
||||
{/if}
|
||||
{mixedUp
|
||||
? 'A pod is sending the other pod’s paddle — the pair may be filed the wrong way round.'
|
||||
: 'The pods are swapped from what their advertisement claims.'}
|
||||
</span>
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
|
||||
Swap + / −
|
||||
@@ -195,82 +196,65 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !bothConnected}
|
||||
{#if !plusLive}
|
||||
<!--
|
||||
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
|
||||
real causes — a sleeping pod and a lapsed unlock — are things only the
|
||||
rider can fix, so they are spelled out here rather than left to be
|
||||
guessed at.
|
||||
FR-1.8. "Not connected" on its own reads as a broken app, and the real
|
||||
cause — a pod that is simply asleep — is something only the rider can fix.
|
||||
The lede above says that much; the rest is for the rider it did not work
|
||||
for, and stays folded until they ask, because it is five paragraphs of
|
||||
things that are usually already true.
|
||||
-->
|
||||
<details class="help" open={!anyConnected}>
|
||||
<summary>A pod will not connect — what to try</summary>
|
||||
<ol>
|
||||
<details class="help">
|
||||
<summary>Still not connecting?</summary>
|
||||
<ul>
|
||||
<li><strong>Press a button on the + pod.</strong> An asleep pod does not advertise.</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>
|
||||
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
|
||||
A Click sleeps within seconds and only advertises while awake, so a pod that is not
|
||||
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.
|
||||
<strong>Or use the − pod</strong> — it relays all ten buttons, but goes quiet after
|
||||
about a minute unless Zwift has blessed it in the last day.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a
|
||||
stopped scan means nothing gets picked up. Restart it above.
|
||||
The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
|
||||
never depends on a pod.
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.click {
|
||||
margin: 0 var(--edge) 0.8rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
margin: 0 var(--edge) 0.7rem;
|
||||
padding: 0.75rem 0.9rem 0.85rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-soft);
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--ink);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -279,43 +263,44 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.8rem;
|
||||
.actions .btn {
|
||||
padding: 0.45em 0.8em;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pod {
|
||||
.pods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid var(--hairline);
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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 {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.45rem;
|
||||
background: var(--hairline);
|
||||
color: var(--ink-dim);
|
||||
font-size: 1.3rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
@@ -329,96 +314,78 @@
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 1rem;
|
||||
/* Not `.label`: that is app.css's uppercase, letter-spaced section label,
|
||||
and a pod's name is a name, not a heading. */
|
||||
.pod-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.76rem;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
margin-left: auto;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.15rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.controls .btn {
|
||||
padding: 0.4em 0.7em;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.held {
|
||||
align-self: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.note {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.swap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 207, 74, 0.07);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.swap button {
|
||||
@@ -426,34 +393,58 @@
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-soft);
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-dim);
|
||||
font-weight: 600;
|
||||
min-height: var(--touch-min);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.help ol {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
/* `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);
|
||||
}
|
||||
|
||||
.help ul {
|
||||
margin: 0.2rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.help strong {
|
||||
color: var(--ink);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.fallback {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.6;
|
||||
/* Narrow: the state and the buttons drop below the pod's name rather than
|
||||
crushing it. */
|
||||
:global([data-size='compact']) .facts,
|
||||
:global([data-size='compact']) .state {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
:global([data-size='compact']) .controls {
|
||||
order: 4;
|
||||
flex: 1 1 100%;
|
||||
margin-left: 2.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
||||
['L', 'Insert lap marker'],
|
||||
['P', 'Profiles and routes'],
|
||||
['D', 'Device / connection screen'],
|
||||
[',', 'Rider setup — weight, FTP, units'],
|
||||
['R', 'Ride screen'],
|
||||
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
||||
['S', 'Save the FIT file (summary screen)'],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { app } from '../lib/app.svelte';
|
||||
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';
|
||||
|
||||
let yaml = $state('');
|
||||
@@ -136,9 +136,11 @@
|
||||
<span class="label">Loaded</span>
|
||||
<strong>{loaded.name}</strong>
|
||||
<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.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
||||
{loaded.totalAscentM != null
|
||||
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} up`
|
||||
: ''}
|
||||
{loaded.looping ? '· loops' : ''}
|
||||
</span>
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||
@@ -174,7 +176,7 @@
|
||||
<em>{b.kind}</em>
|
||||
{b.label}
|
||||
<span class="quiet">
|
||||
{axisValue(b.unit, b.endX - b.startX)}
|
||||
{axisValue(b.unit, b.endX - b.startX, app.units)}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
sub?: string | null;
|
||||
dim?: boolean;
|
||||
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 {
|
||||
@@ -24,11 +33,15 @@
|
||||
sub = null,
|
||||
dim = false,
|
||||
align = 'start',
|
||||
badge = null,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||
<span class="head">
|
||||
<span class="label">{label}</span>
|
||||
{#if badge}<span class="badge" style:color={badge.colour}>{badge.label}</span>{/if}
|
||||
</span>
|
||||
<span class="value" style:color={colour}>
|
||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</span>
|
||||
@@ -43,6 +56,21 @@
|
||||
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 {
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
|
||||
@@ -11,12 +11,19 @@
|
||||
import { api } from '../lib/bridge';
|
||||
import {
|
||||
clock,
|
||||
dist,
|
||||
distUnit,
|
||||
duration,
|
||||
elev,
|
||||
elevUnit,
|
||||
finishAt,
|
||||
km,
|
||||
hrZone,
|
||||
MODE_LABEL,
|
||||
num,
|
||||
powerZone,
|
||||
signed,
|
||||
speed,
|
||||
speedUnit,
|
||||
targetText,
|
||||
} from '../lib/format';
|
||||
import { viewport } from '../lib/viewport.svelte';
|
||||
@@ -33,6 +40,9 @@
|
||||
const show = $derived(viewport.plan.show);
|
||||
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
||||
|
||||
const units = $derived(app.units);
|
||||
const prefs = $derived(app.prefs);
|
||||
|
||||
const frame = $derived(app.frame);
|
||||
const snap = $derived(frame?.snapshot ?? null);
|
||||
const d = $derived(frame?.derived ?? null);
|
||||
@@ -81,13 +91,20 @@
|
||||
const podChip = $derived.by(() => {
|
||||
const c = app.controller;
|
||||
if (!c) return null;
|
||||
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
|
||||
if (missing.length === 0) return null;
|
||||
// The − pod relays its twin, so a `+` pod that is not connected while the
|
||||
// `−` 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 searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
||||
return {
|
||||
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
|
||||
// an annoyance rather than the end of the ride.
|
||||
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
||||
@@ -190,10 +207,10 @@
|
||||
*/
|
||||
const speedSub = $derived.by(() => {
|
||||
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 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(() => {
|
||||
@@ -220,13 +237,66 @@
|
||||
const routeSummary = $derived.by(() => {
|
||||
if (!profile) return null;
|
||||
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.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');
|
||||
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);
|
||||
</script>
|
||||
|
||||
@@ -257,17 +327,36 @@
|
||||
<span class="dot"></span>{podChip.label}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
||||
<!-- One chip, not two. The mode and the target it produced are a single
|
||||
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 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>
|
||||
</header>
|
||||
|
||||
<!-- The hero. -->
|
||||
{#if show.routeChart}
|
||||
<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} />
|
||||
</div>
|
||||
{#if nextUp}
|
||||
<span class="next"><span class="label">Next</span>{nextUp.label} in {nextUp.away}</span>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -314,19 +403,20 @@
|
||||
</div>
|
||||
<Readout
|
||||
label="To go"
|
||||
value={remaining != null ? km(remaining, 2) : '—'}
|
||||
unit={remaining != null ? 'km' : ''}
|
||||
value={remaining != null ? dist(remaining, units, 2) : '—'}
|
||||
unit={remaining != null ? distUnit(units) : ''}
|
||||
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}
|
||||
/>
|
||||
<Readout
|
||||
label="Speed"
|
||||
value={num(d?.displaySpeedKph ?? 0, 1)}
|
||||
unit="km/h"
|
||||
value={speed(d?.displaySpeedKph ?? 0, units)}
|
||||
unit={speedUnit(units)}
|
||||
size="big"
|
||||
sub={speedSub}
|
||||
/>
|
||||
<div class="grade-cell">
|
||||
<Readout
|
||||
label="Gradient"
|
||||
value={signed(gradient, 1)}
|
||||
@@ -335,6 +425,13 @@
|
||||
colour={gradeColour}
|
||||
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
||||
/>
|
||||
<!-- The same number as a slope. Seen, not read (see `wedge`). -->
|
||||
<svg class="wedge" viewBox="0 0 40 24" aria-hidden="true" style:color={gradeColour}>
|
||||
<path d="M2 {wedge.y1} L38 {wedge.y2}" stroke="currentColor" stroke-width="2.6"
|
||||
stroke-linecap="round" fill="none" />
|
||||
<path d="M2 {wedge.y1} L38 {wedge.y2} L38 22 L2 22 Z" fill="currentColor" opacity="0.14" />
|
||||
</svg>
|
||||
</div>
|
||||
<Readout
|
||||
label="Gear"
|
||||
value={gear.value}
|
||||
@@ -350,14 +447,14 @@
|
||||
<section class="detail">
|
||||
<Readout
|
||||
label="Elevation"
|
||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
||||
unit={d?.elevationM != null ? 'm' : ''}
|
||||
value={d?.elevationM != null ? elev(d.elevationM, units) : '—'}
|
||||
unit={d?.elevationM != null ? elevUnit(units) : ''}
|
||||
colour="var(--climb)"
|
||||
/>
|
||||
<Readout
|
||||
label="Climbing left"
|
||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
||||
value={d?.ascentRemainingM != null ? elev(d.ascentRemainingM, units) : '—'}
|
||||
unit={d?.ascentRemainingM != null ? elevUnit(units) : ''}
|
||||
/>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
{#if !compact}
|
||||
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
|
||||
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
|
||||
<Readout
|
||||
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}
|
||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
||||
</section>
|
||||
@@ -380,8 +485,9 @@
|
||||
value={num(d?.rollingPowerW ?? 0, 0)}
|
||||
unit="W"
|
||||
size="mid"
|
||||
colour="var(--power)"
|
||||
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||
colour={pZone?.colour ?? 'var(--power)'}
|
||||
badge={pZone ? { label: pZone.short, colour: pZone.colour } : null}
|
||||
sub={`${pZone ? pZone.name + ' · ' : ''}now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||
/>
|
||||
<Readout
|
||||
label="Cadence"
|
||||
@@ -395,6 +501,9 @@
|
||||
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
||||
unit="bpm"
|
||||
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
|
||||
@@ -517,18 +626,60 @@
|
||||
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 {
|
||||
color: var(--route);
|
||||
background: rgba(69, 208, 255, 0.1);
|
||||
}
|
||||
|
||||
.route {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2 1 0;
|
||||
min-height: var(--route-min);
|
||||
padding: 0 var(--edge);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.route-chart {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- pre-ride launch panel ---------------------------------------- */
|
||||
|
||||
.launch {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot from 'uplot';
|
||||
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 type { ProfileView } from '../lib/types';
|
||||
|
||||
@@ -24,6 +26,10 @@
|
||||
|
||||
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 plot: uPlot | null = null;
|
||||
let disposeSize: (() => void) | null = null;
|
||||
@@ -56,12 +62,15 @@
|
||||
axes: [
|
||||
axis({
|
||||
values: (_u, splits) =>
|
||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
||||
splits.map((v) => (isMetres ? dist(v, units, 1) : formatMinutes(v))),
|
||||
}),
|
||||
axis({
|
||||
side: 3,
|
||||
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: [
|
||||
@@ -122,7 +131,8 @@
|
||||
// is a single array walk rather than a chart teardown.
|
||||
let builtFor: string | null = null;
|
||||
$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;
|
||||
builtFor = key;
|
||||
if (key) {
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Rider setup (FR-7.4).
|
||||
*
|
||||
* This screen did not exist, and its absence was the app's largest single
|
||||
* lie: `RiderConfig::default()` is a 105 kg rider on an 8 kg bike, and with
|
||||
* no way to change it every rider was shown a stranger's speed, a stranger's
|
||||
* ETA and a stranger's calorie count. Mass is not a preference, it is half
|
||||
* the physics.
|
||||
*
|
||||
* Three rules hold here:
|
||||
*
|
||||
* 1. **Rust owns validity.** Every commit round-trips through the command,
|
||||
* which validates, persists and hands back what it accepted — and that
|
||||
* is what lands in the form. A field can never end up showing a value
|
||||
* the engine is not using.
|
||||
* 2. **Commit on change, not on a Save button.** There is no "unsaved"
|
||||
* state to lose, and no button to forget to press. Fields commit when
|
||||
* they are left, so a half-typed "7" on the way to "72" is never sent.
|
||||
* 3. **What matters is on top.** Weight, FTP and units are the three a
|
||||
* rider actually sets. Everything below them is aerodynamic and
|
||||
* drivetrain detail that has a defensible default, so it is folded away
|
||||
* rather than made to look equally important.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { fromMass, massUnit, toMass } from '../lib/format';
|
||||
import type { Preferences, RiderConfig, SafetyLimits, Units } from '../lib/types';
|
||||
|
||||
let rider = $state<RiderConfig | null>(null);
|
||||
let limits = $state<SafetyLimits | null>(null);
|
||||
const prefs = $derived(app.prefs);
|
||||
const units = $derived(app.units);
|
||||
|
||||
/** Bumped after every commit so the inputs re-read the accepted values —
|
||||
* an input the rider has typed into keeps its own DOM value otherwise, and
|
||||
* a rejected 900 kg would sit there looking accepted. */
|
||||
let revision = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
void (async () => {
|
||||
rider = await api.riderConfig();
|
||||
limits = await api.safetyLimits();
|
||||
})();
|
||||
});
|
||||
|
||||
/**
|
||||
* Send the whole config, spread over what Rust last gave us.
|
||||
*
|
||||
* Spread rather than rebuilt: `RiderConfig` carries fields this form does not
|
||||
* show (the load channel, the fixed drivetrain loss), and a form that
|
||||
* reconstructs the struct would quietly reset them to serde defaults every
|
||||
* time somebody changed their weight.
|
||||
*/
|
||||
async function commitRider(patch: Partial<RiderConfig>) {
|
||||
if (!rider) return;
|
||||
const next = { ...rider, ...patch };
|
||||
const accepted = await app.run(() => api.setRiderConfig(next));
|
||||
if (accepted) rider = accepted;
|
||||
revision++;
|
||||
}
|
||||
|
||||
async function commitLimits(patch: Partial<SafetyLimits>) {
|
||||
const base = limits;
|
||||
if (!base) return;
|
||||
const accepted = await app.run(() => api.setSafetyLimits({ ...base, ...patch }));
|
||||
if (accepted) limits = accepted;
|
||||
revision++;
|
||||
}
|
||||
|
||||
async function commitPrefs(patch: Partial<Preferences>) {
|
||||
await app.savePrefs({ ...prefs, ...patch });
|
||||
revision++;
|
||||
}
|
||||
|
||||
/** Total mass is what the physics actually uses, so it is worth seeing. */
|
||||
const totalMass = $derived(rider ? rider.rider_kg + rider.bike_kg : 0);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
One field. `value` is read through `key` so that a commit — accepted or
|
||||
rejected — reseats the input from the truth Rust returned.
|
||||
-->
|
||||
{#snippet field(
|
||||
label: string,
|
||||
value: number,
|
||||
step: number,
|
||||
unit: string,
|
||||
hint: string | null,
|
||||
commit: (v: number) => void,
|
||||
)}
|
||||
<label class="field">
|
||||
<span class="name">{label}</span>
|
||||
<span class="input">
|
||||
{#key revision}
|
||||
<input
|
||||
type="number"
|
||||
{step}
|
||||
value={Number.isFinite(value) ? Number(value.toFixed(4)) : 0}
|
||||
onchange={(e) => commit(e.currentTarget.valueAsNumber)}
|
||||
/>
|
||||
{/key}
|
||||
<span class="unit">{unit}</span>
|
||||
</span>
|
||||
{#if hint}<span class="hint">{hint}</span>{/if}
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
<div class="screen">
|
||||
<header>
|
||||
<h1>Rider setup</h1>
|
||||
<button class="btn ghost" onclick={() => app.closeSettings()}>
|
||||
Done <span class="kbd">Esc</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
{#if !rider || !limits}
|
||||
<p class="waiting">Reading your setup…</p>
|
||||
{:else}
|
||||
<section class="card">
|
||||
<h2>You</h2>
|
||||
<div class="grid">
|
||||
{@render field(
|
||||
'Weight',
|
||||
toMass(rider.rider_kg, units),
|
||||
0.5,
|
||||
massUnit(units),
|
||||
'Sets speed, ETA and calories',
|
||||
(v) => commitRider({ rider_kg: fromMass(v, units) }),
|
||||
)}
|
||||
{@render field(
|
||||
'Bike',
|
||||
toMass(rider.bike_kg, units),
|
||||
0.1,
|
||||
massUnit(units),
|
||||
`${toMass(totalMass, units).toFixed(1)} ${massUnit(units)} on the road`,
|
||||
(v) => commitRider({ bike_kg: fromMass(v, units) }),
|
||||
)}
|
||||
{@render field(
|
||||
'FTP',
|
||||
prefs.ftpW,
|
||||
5,
|
||||
'W',
|
||||
// Zero is a real answer, not an empty box: it is how a rider says
|
||||
// "I do not know mine", and the screen then shows plain watts
|
||||
// rather than a zone measured against a guess.
|
||||
prefs.ftpW ? 'Colours power by zone' : '0 — no power zones',
|
||||
(v) => commitPrefs({ ftpW: Math.round(v) }),
|
||||
)}
|
||||
{@render field(
|
||||
'Max heart rate',
|
||||
prefs.maxHrBpm,
|
||||
1,
|
||||
'bpm',
|
||||
prefs.maxHrBpm ? 'Colours heart rate by zone' : '0 — no heart-rate zones',
|
||||
(v) => commitPrefs({ maxHrBpm: Math.round(v) }),
|
||||
)}
|
||||
|
||||
<div class="field">
|
||||
<span class="name">Units</span>
|
||||
<div class="segmented">
|
||||
{#each [['metric', 'km · kg'], ['imperial', 'mi · lb']] as [value, label]}
|
||||
<button
|
||||
class:on={units === value}
|
||||
onclick={() => commitPrefs({ units: value as Units })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="hint">Display only — rides record in SI</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Everything below has a defensible default and changes the ride subtly
|
||||
rather than obviously. Folded, so the three settings that matter are not
|
||||
buried among nine that do not.
|
||||
-->
|
||||
<details class="card">
|
||||
<summary><h2>Bike and physics</h2></summary>
|
||||
<div class="grid">
|
||||
{@render field('CdA', rider.cda, 0.005, 'm²', 'Road position ≈ 0.32', (v) =>
|
||||
commitRider({ cda: v }),
|
||||
)}
|
||||
{@render field('Rolling resistance', rider.crr, 0.0005, 'Crr', 'Road tyre ≈ 0.004', (v) =>
|
||||
commitRider({ crr: v }),
|
||||
)}
|
||||
{@render field(
|
||||
'Drivetrain',
|
||||
rider.drivetrain_efficiency * 100,
|
||||
0.5,
|
||||
'%',
|
||||
'Clean chain ≈ 97%',
|
||||
(v) => commitRider({ drivetrain_efficiency: v / 100 }),
|
||||
)}
|
||||
{@render field('Air density', rider.air_density, 0.005, 'kg/m³', 'Sea level 1.225', (v) =>
|
||||
commitRider({ air_density: v }),
|
||||
)}
|
||||
{@render field(
|
||||
'Wheel',
|
||||
rider.wheel_circumference_m * 1000,
|
||||
5,
|
||||
'mm',
|
||||
'700×25 ≈ 2100 mm',
|
||||
(v) => commitRider({ wheel_circumference_m: v / 1000 }),
|
||||
)}
|
||||
{@render field(
|
||||
'Crank',
|
||||
rider.crank_length_m * 1000,
|
||||
2.5,
|
||||
'mm',
|
||||
'Road 170–175 mm',
|
||||
(v) => commitRider({ crank_length_m: v / 1000 }),
|
||||
)}
|
||||
{@render field(
|
||||
'Real gear',
|
||||
rider.physical_development_m,
|
||||
0.1,
|
||||
'm/rev',
|
||||
'Through the Zwift Cog — 34×14 ≈ 5.1',
|
||||
(v) => commitRider({ physical_development_m: v }),
|
||||
)}
|
||||
{@render field(
|
||||
'Descent floor',
|
||||
rider.descent_load_floor_pct,
|
||||
0.5,
|
||||
'%',
|
||||
'Keeps load under the pedals downhill',
|
||||
(v) => commitRider({ descent_load_floor_pct: v }),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- SAF-3. These bound everything sent to the trainer, whatever asked
|
||||
for it, so they are shown last and phrased as limits, not targets. -->
|
||||
<details class="card">
|
||||
<summary><h2>Safety limits</h2></summary>
|
||||
<div class="grid">
|
||||
{@render field('Gradient floor', limits.min_gradient_pct, 0.5, '%', null, (v) =>
|
||||
commitLimits({ min_gradient_pct: v }),
|
||||
)}
|
||||
{@render field('Gradient ceiling', limits.max_gradient_pct, 0.5, '%', null, (v) =>
|
||||
commitLimits({ max_gradient_pct: v }),
|
||||
)}
|
||||
{@render field('Resistance floor', limits.min_resistance, 1, 'L', null, (v) =>
|
||||
commitLimits({ min_resistance: Math.round(v) }),
|
||||
)}
|
||||
{@render field('Resistance ceiling', limits.max_resistance, 1, 'L', null, (v) =>
|
||||
commitLimits({ max_resistance: Math.round(v) }),
|
||||
)}
|
||||
{@render field('Power floor', limits.min_power_w, 5, 'W', null, (v) =>
|
||||
commitLimits({ min_power_w: Math.round(v) }),
|
||||
)}
|
||||
{@render field('Power ceiling', limits.max_power_w, 5, 'W', 'Clamped at transmission', (v) =>
|
||||
commitLimits({ max_power_w: Math.round(v) }),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
header .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--edge) 3rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 54rem;
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-min);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* `display: flex` on a summary drops the browser's own marker, and a
|
||||
disclosure with nothing to disclose-looking about it does not read as one. */
|
||||
summary::before {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-right: 0.5rem;
|
||||
border-left: 5px solid currentColor;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
opacity: 0.6;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
details[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
summary h2 {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 0.7rem 1rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.input:focus-within {
|
||||
border-color: color-mix(in srgb, var(--route) 45%, transparent);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* The spinners are 12 px of target on a screen where nothing else is under
|
||||
48, and they steal the width the number needs. */
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unit {
|
||||
flex: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
flex: 1 1 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
.segmented button.on {
|
||||
background: var(--route);
|
||||
color: #04121a;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,11 @@
|
||||
* urgent. It says where the file already is, whatever the rider does next.
|
||||
*/
|
||||
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';
|
||||
|
||||
const s = $derived(app.summary);
|
||||
const units = $derived(app.units);
|
||||
|
||||
/** Time not spent riding. Only worth showing when it is not zero. */
|
||||
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="Distance"
|
||||
value={km(s.distanceM, 2)}
|
||||
unit="km"
|
||||
value={dist(s.distanceM, units, 2)}
|
||||
unit={distUnit(units)}
|
||||
size="big"
|
||||
colour="var(--route)"
|
||||
/>
|
||||
<Readout
|
||||
label="Climbing"
|
||||
value={num(s.ascentM, 0)}
|
||||
unit="m"
|
||||
value={elev(s.ascentM, units)}
|
||||
unit={elevUnit(units)}
|
||||
size="big"
|
||||
colour="var(--climb)"
|
||||
/>
|
||||
|
||||
@@ -10,13 +10,15 @@ import type {
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
Preferences,
|
||||
RideFrame,
|
||||
RideState,
|
||||
RideSummary,
|
||||
SampleProfile,
|
||||
Units,
|
||||
} from './types';
|
||||
|
||||
export type Screen = 'connect' | 'ride' | 'summary';
|
||||
export type Screen = 'connect' | 'ride' | 'summary' | 'settings';
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
@@ -40,6 +42,14 @@ class AppStore {
|
||||
controller = $state<ControllerStatus | null>(null);
|
||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||
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. */
|
||||
readonly power = new History(2);
|
||||
@@ -53,6 +63,20 @@ class AppStore {
|
||||
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.
|
||||
*
|
||||
@@ -83,7 +107,7 @@ class AppStore {
|
||||
* parallel set that can drift.
|
||||
*/
|
||||
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.deviceList(),
|
||||
api.sampleProfiles(),
|
||||
@@ -93,12 +117,14 @@ class AppStore {
|
||||
// *change*, so a webview reload with both pods already connected would
|
||||
// otherwise show two empty slots.
|
||||
api.controllerStatus(),
|
||||
api.preferences(),
|
||||
]);
|
||||
this.ride = ride;
|
||||
this.devices = devices;
|
||||
this.samples = samples;
|
||||
this.summary = summary;
|
||||
this.controller = controller;
|
||||
this.prefs = prefs;
|
||||
// A ride already in progress (a reload, or an autostart) belongs on screen
|
||||
// immediately — nobody wants to click past a device list mid-effort.
|
||||
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. */
|
||||
async newRide(): Promise<void> {
|
||||
await this.run(() => api.reset());
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
Preferences,
|
||||
ProfileView,
|
||||
Recovered,
|
||||
RideFrame,
|
||||
@@ -139,6 +140,8 @@ export const api = {
|
||||
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
||||
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
||||
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
||||
preferences: () => call<Preferences>('preferences'),
|
||||
setPreferences: (prefs: Preferences) => call<Preferences>('set_preferences', { prefs }),
|
||||
|
||||
// profiles
|
||||
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dist, elev, hrZone, percentOf, powerZone, speed, toMass, fromMass } from './format';
|
||||
|
||||
/**
|
||||
* The display layer is where a rider's preference is applied and nowhere else,
|
||||
* so these tests exist to pin two properties: the conversions are exact, and a
|
||||
* zone is never invented from a reference the rider has not given.
|
||||
*/
|
||||
describe('units', () => {
|
||||
it('leaves metric alone and converts imperial exactly', () => {
|
||||
expect(dist(42195, 'metric', 2)).toBe('42.20');
|
||||
expect(dist(1609.344, 'imperial', 3)).toBe('1.000');
|
||||
expect(elev(1000, 'metric')).toBe('1000');
|
||||
expect(elev(304.8, 'imperial')).toBe('1000');
|
||||
expect(speed(32.18688, 'imperial')).toBe('20.0');
|
||||
});
|
||||
|
||||
it('round-trips a mass through the form and back', () => {
|
||||
const kg = 72.5;
|
||||
expect(fromMass(toMass(kg, 'imperial'), 'imperial')).toBeCloseTo(kg, 6);
|
||||
expect(toMass(kg, 'metric')).toBe(kg);
|
||||
});
|
||||
|
||||
it('says nothing rather than NaN when there is no value', () => {
|
||||
expect(dist(null, 'metric')).toBe('—');
|
||||
expect(elev(undefined, 'imperial')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effort zones', () => {
|
||||
it('places power on the Coggan boundaries', () => {
|
||||
const ftp = 250;
|
||||
expect(powerZone(100, ftp)?.short).toBe('Z1'); // 40%
|
||||
expect(powerZone(175, ftp)?.short).toBe('Z2'); // 70%
|
||||
expect(powerZone(220, ftp)?.short).toBe('Z3'); // 88%
|
||||
expect(powerZone(250, ftp)?.short).toBe('Z4'); // 100%
|
||||
expect(powerZone(290, ftp)?.short).toBe('Z5'); // 116%
|
||||
expect(powerZone(350, ftp)?.short).toBe('Z6'); // 140%
|
||||
expect(powerZone(500, ftp)?.short).toBe('Z7'); // 200%
|
||||
});
|
||||
|
||||
it('puts a boundary value in the lower zone', () => {
|
||||
// 55% of FTP is the top of zone 1, not the bottom of zone 2.
|
||||
expect(powerZone(137.5, 250)?.short).toBe('Z1');
|
||||
});
|
||||
|
||||
/** The whole contract of `ftpW: 0` — no reference, no colour. */
|
||||
it('refuses to invent a zone without a reference', () => {
|
||||
expect(powerZone(300, 0)).toBeNull();
|
||||
expect(hrZone(150, 0)).toBeNull();
|
||||
expect(percentOf(300, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not colour a rider who is not pedalling', () => {
|
||||
expect(powerZone(0, 250)).toBeNull();
|
||||
expect(powerZone(null, 250)).toBeNull();
|
||||
});
|
||||
|
||||
it('places heart rate as a fraction of maximum', () => {
|
||||
expect(hrZone(100, 190)?.short).toBe('Z1'); // 53%
|
||||
expect(hrZone(125, 190)?.short).toBe('Z2'); // 66%
|
||||
expect(hrZone(150, 190)?.short).toBe('Z3'); // 79%
|
||||
expect(hrZone(170, 190)?.short).toBe('Z4'); // 89%
|
||||
expect(hrZone(185, 190)?.short).toBe('Z5'); // 97%
|
||||
});
|
||||
|
||||
it('reports the percentage the zone came from', () => {
|
||||
expect(percentOf(275, 250)).toBe('110% of FTP');
|
||||
});
|
||||
});
|
||||
+141
-7
@@ -1,4 +1,5 @@
|
||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||
import type { Units } from './types';
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||
return v.toFixed(digits);
|
||||
@@ -51,8 +47,8 @@ export function axisLabel(unit: 'seconds' | 'metres'): string {
|
||||
return unit === 'metres' ? 'distance' : 'time';
|
||||
}
|
||||
|
||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
||||
export function axisValue(unit: 'seconds' | 'metres', x: number, units: Units): string {
|
||||
return unit === 'metres' ? `${dist(x, units, 1)} ${distUnit(units)}` : clock(x);
|
||||
}
|
||||
|
||||
export function rssiBars(rssi: number): number {
|
||||
@@ -102,3 +98,141 @@ export function connectionText(
|
||||
return { label: 'Idle', tone: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Units (FR-7.4's neighbour)
|
||||
//
|
||||
// SI everywhere behind this line. The engine computes in metres and km/h, the
|
||||
// FIT file records in metres and km/h, and a rider who prefers miles changes
|
||||
// *only* what the glass says. Every function here therefore takes the unit
|
||||
// system as an argument rather than reading a module-level setting: pure
|
||||
// functions are what let the ride screen redraw the moment the preference
|
||||
// changes, and what keeps a recorded activity independent of a display choice.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KM_PER_MILE = 1.609344;
|
||||
const M_PER_FOOT = 0.3048;
|
||||
const KG_PER_LB = 0.45359237;
|
||||
|
||||
export function distUnit(units: Units): string {
|
||||
return units === 'imperial' ? 'mi' : 'km';
|
||||
}
|
||||
|
||||
export function elevUnit(units: Units): string {
|
||||
return units === 'imperial' ? 'ft' : 'm';
|
||||
}
|
||||
|
||||
export function speedUnit(units: Units): string {
|
||||
return units === 'imperial' ? 'mph' : 'km/h';
|
||||
}
|
||||
|
||||
export function massUnit(units: Units): string {
|
||||
return units === 'imperial' ? 'lb' : 'kg';
|
||||
}
|
||||
|
||||
/** Metres → the rider's long-distance unit. */
|
||||
export function dist(metres: number | null | undefined, units: Units, digits = 2): string {
|
||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||
const km = metres / 1000;
|
||||
return (units === 'imperial' ? km / KM_PER_MILE : km).toFixed(digits);
|
||||
}
|
||||
|
||||
/** Metres of climbing → metres or feet. Always whole: nobody climbs 0.4 m. */
|
||||
export function elev(metres: number | null | undefined, units: Units): string {
|
||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||
return (units === 'imperial' ? metres / M_PER_FOOT : metres).toFixed(0);
|
||||
}
|
||||
|
||||
export function speed(kph: number | null | undefined, units: Units, digits = 1): string {
|
||||
if (kph == null || !Number.isFinite(kph)) return EM_DASH;
|
||||
return (units === 'imperial' ? kph / KM_PER_MILE : kph).toFixed(digits);
|
||||
}
|
||||
|
||||
/** Kilograms → kg or lb. For the settings form, which edits SI underneath. */
|
||||
export function toMass(kg: number, units: Units): number {
|
||||
return units === 'imperial' ? kg / KG_PER_LB : kg;
|
||||
}
|
||||
|
||||
export function fromMass(value: number, units: Units): number {
|
||||
return units === 'imperial' ? value * KG_PER_LB : value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effort zones
|
||||
//
|
||||
// A watt is a fact; a zone is what it *costs you*, and it is the difference
|
||||
// between a number and a number that means something. Every professional
|
||||
// training app colours effort this way and this one did not, so the biggest
|
||||
// figure on the ride screen was the same shade of white at 90 W and at 400 W.
|
||||
//
|
||||
// Two rules hold here:
|
||||
//
|
||||
// 1. **No reference, no zone.** `ftp` or `maxHr` of zero means the rider has
|
||||
// not told us, and an invented threshold would paint every ride with a
|
||||
// confident lie. `null` is the honest answer and the callers draw the
|
||||
// plain number.
|
||||
// 2. **Colour never carries it alone.** Every zone has a short name that is
|
||||
// rendered beside the swatch, so the meaning survives a colour-blind
|
||||
// rider, a sun-washed phone and a black-and-white screenshot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Zone {
|
||||
/** One-based, as riders say them: "zone 4". */
|
||||
index: number;
|
||||
/** What it is called in a training plan. */
|
||||
name: string;
|
||||
/** Short form for a chip beside a number: "Z4". */
|
||||
short: string;
|
||||
/** A CSS custom property reference, so themes stay in the stylesheet. */
|
||||
colour: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coggan's seven power zones, as fractions of FTP. The boundaries are the
|
||||
* conventional ones — 55 / 75 / 90 / 105 / 120 / 150 % — which is what makes a
|
||||
* "zone 3 ride" here mean the same thing it means in the rider's plan.
|
||||
*/
|
||||
const POWER_ZONES: { upto: number; zone: Zone }[] = [
|
||||
{ upto: 0.55, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||
{ upto: 0.75, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||
{ upto: 0.9, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||
{ upto: 1.05, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-4)' } },
|
||||
{ upto: 1.2, zone: { index: 5, name: 'VO₂ max', short: 'Z5', colour: 'var(--zone-5)' } },
|
||||
{ upto: 1.5, zone: { index: 6, name: 'Anaerobic', short: 'Z6', colour: 'var(--zone-6)' } },
|
||||
{ upto: Infinity, zone: { index: 7, name: 'Sprint', short: 'Z7', colour: 'var(--zone-7)' } },
|
||||
];
|
||||
|
||||
/** Five heart-rate zones as fractions of maximum. */
|
||||
const HR_ZONES: { upto: number; zone: Zone }[] = [
|
||||
{ upto: 0.6, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||
{ upto: 0.7, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||
{ upto: 0.8, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||
{ upto: 0.9, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-5)' } },
|
||||
{ upto: Infinity, zone: { index: 5, name: 'Maximum', short: 'Z5', colour: 'var(--zone-6)' } },
|
||||
];
|
||||
|
||||
function zoneFor(
|
||||
table: { upto: number; zone: Zone }[],
|
||||
value: number | null | undefined,
|
||||
reference: number,
|
||||
): Zone | null {
|
||||
// Not pedalling is not zone 1. A stopped rider in "Recovery" green is the
|
||||
// screen claiming an effort that is not happening.
|
||||
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||
const ratio = value / reference;
|
||||
return table.find((row) => ratio <= row.upto)?.zone ?? null;
|
||||
}
|
||||
|
||||
export function powerZone(watts: number | null | undefined, ftp: number): Zone | null {
|
||||
return zoneFor(POWER_ZONES, watts, ftp);
|
||||
}
|
||||
|
||||
export function hrZone(bpm: number | null | undefined, maxHr: number): Zone | null {
|
||||
return zoneFor(HR_ZONES, bpm, maxHr);
|
||||
}
|
||||
|
||||
/** Percentage of the reference, for the subtitle under a zoned number. */
|
||||
export function percentOf(value: number | null | undefined, reference: number): string | null {
|
||||
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||
return `${Math.round((value / reference) * 100)}% of FTP`;
|
||||
}
|
||||
|
||||
@@ -70,10 +70,15 @@ export interface RideSnapshot {
|
||||
profile_progress: number | null;
|
||||
}
|
||||
|
||||
/** Which FTMS channel the computed load is sent on. */
|
||||
export type LoadChannel = 'Gradient' | 'Power';
|
||||
|
||||
export interface RiderConfig {
|
||||
rider_kg: number;
|
||||
bike_kg: number;
|
||||
crr: number;
|
||||
/** Fixed drivetrain and flywheel loss, watts. */
|
||||
rolling_loss_w: number;
|
||||
cda: number;
|
||||
drivetrain_efficiency: number;
|
||||
air_density: number;
|
||||
@@ -83,6 +88,33 @@ export interface RiderConfig {
|
||||
descent_load_floor_pct: number;
|
||||
/** Development of the real gear through the Zwift Cog, m per crank rev. */
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user