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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -255,6 +261,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -307,6 +314,8 @@ dependencies = [
|
|||||||
"bikecontrol-core",
|
"bikecontrol-core",
|
||||||
"btleplug",
|
"btleplug",
|
||||||
"futures",
|
"futures",
|
||||||
|
"p256",
|
||||||
|
"rand_core",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -640,6 +649,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -723,6 +738,18 @@ version = "0.8.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -892,6 +919,16 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -961,6 +998,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1102,6 +1140,25 @@ version = "1.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
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]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.11"
|
version = "3.0.11"
|
||||||
@@ -1211,6 +1268,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1504,6 +1571,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1636,6 +1704,17 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "gtk"
|
name = "gtk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -1730,6 +1809,24 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2705,6 +2802,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2915,6 +3022,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -3007,6 +3123,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3252,6 +3377,19 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@@ -3624,6 +3762,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -5526,6 +5670,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|||||||
+422
-5
@@ -1,9 +1,18 @@
|
|||||||
# BikeControl — Requirements Specification
|
# BikeControl — Requirements Specification
|
||||||
|
|
||||||
**Status:** Draft v0.4
|
**Status:** Draft v0.5
|
||||||
**Date:** 2026-08-05
|
**Date:** 2026-08-21
|
||||||
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
|
||||||
|
|
||||||
|
> **v0.5 changes — the ride leaves the app.** §5.10 adds two ways for a finished activity
|
||||||
|
> to reach somewhere else: an Android **share sheet**, which needs no credential and is the
|
||||||
|
> only route into Garmin Connect; and an opt-in **upload to Runalyze**, chosen because its
|
||||||
|
> auth is a token the rider pastes in rather than an OAuth flow with a client secret to hide.
|
||||||
|
> Strava is deliberately not in scope (RISK-11). This is the first network traffic the app
|
||||||
|
> has ever originated, so §1.2's non-goal and NFR-6 are both amended to say precisely what is
|
||||||
|
> and is not permitted — the short version being that nothing on the network is ever between
|
||||||
|
> a rider and a ride.
|
||||||
|
|
||||||
> **v0.4 changes — the input decision is reversed.** Further research found that the Click
|
> **v0.4 changes — the input decision is reversed.** Further research found that the Click
|
||||||
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
|
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
|
||||||
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
|
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
|
||||||
@@ -41,7 +50,10 @@ D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and pro
|
|||||||
|
|
||||||
### 1.2 Non-goals
|
### 1.2 Non-goals
|
||||||
|
|
||||||
- Multiplayer, social features, or any network service.
|
- Multiplayer, social features, or acting as a network *service*. The app never listens on
|
||||||
|
a socket and never holds an account of its own. One-way export of a finished ride to a
|
||||||
|
destination the rider configured (§5.10) is not that, and is the only network traffic
|
||||||
|
the app ever originates.
|
||||||
- 3D world rendering or avatars.
|
- 3D world rendering or avatars.
|
||||||
- ANT+ support (§10.1).
|
- ANT+ support (§10.1).
|
||||||
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
|
||||||
@@ -188,6 +200,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 —
|
> so it is evidence of a wrong-way-round pair only while that pod has never sent its own —
|
||||||
> otherwise the connection screen asks the rider to swap a pair that is filed correctly.
|
> otherwise the connection screen asks the rider to swap a pair that is filed correctly.
|
||||||
|
|
||||||
|
> **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
|
### 2.3.2 The D100's own Zwift service — telemetry, not shifting
|
||||||
|
|
||||||
The trainer answers the same handshake (`RideOn 00 09` → `RideOn 02 00`) on the original
|
The trainer answers the same handshake (`RideOn 00 09` → `RideOn 02 00`) on the original
|
||||||
@@ -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
|
> **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.
|
> 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
|
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
|
ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no
|
||||||
crypto crate has been added.
|
crypto crate has been added.
|
||||||
@@ -359,6 +491,7 @@ is a deliberate trade of resilience for simplicity.
|
|||||||
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
||||||
| Charts | `uPlot` | Canvas, built for streaming series |
|
| Charts | `uPlot` | Canvas, built for streaming series |
|
||||||
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
|
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
|
||||||
|
| HTTP | `reqwest` + `rustls-tls` | Activity upload only (FR-10), and nothing else in the app opens a socket. **Not** `native-tls`: OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is already the choice for the Click session |
|
||||||
|
|
||||||
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
|
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
|
||||||
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
|
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
|
||||||
@@ -372,6 +505,7 @@ bikecontrol/
|
|||||||
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
|
||||||
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
|
||||||
│ ├── fit/ # FIT encoder (ported from §3.2)
|
│ ├── fit/ # FIT encoder (ported from §3.2)
|
||||||
|
│ ├── sync/ # activity upload — one trait, one impl per service (§5.10)
|
||||||
│ └── probe/ # CLI for protocol discovery (Phase 0)
|
│ └── probe/ # CLI for protocol discovery (Phase 0)
|
||||||
├── src-tauri/ # Tauri shell, commands, event bridge
|
├── src-tauri/ # Tauri shell, commands, event bridge
|
||||||
└── ui/ # web frontend
|
└── ui/ # web frontend
|
||||||
@@ -390,6 +524,8 @@ bikecontrol/
|
|||||||
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
|
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
|
||||||
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
|
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
|
||||||
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
|
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
|
||||||
|
| FR-1.5a | Remembering survives the process: trainer, `−` pod and heart rate monitor are written to `devices.json` in the app data directory when a link actually comes up, and a device the rider forgets is recorded as refused rather than merely dropped | Should |
|
||||||
|
| FR-1.5b | Auto-connect is driven by the scan, not by startup — the hardware is asleep at launch (A-4), so a remembered device is reconnected the moment it advertises. Bounded by `AUTO_ATTEMPTS`, so "gave up" (FR-1.11) is not contradicted two ticks later, and suspended for any device the rider disconnected by hand this session | Should |
|
||||||
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
|
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
|
||||||
| FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must |
|
| FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must |
|
||||||
| FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
|
| FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
|
||||||
@@ -570,6 +706,8 @@ blocks:
|
|||||||
| FR-7.2 | Integrate speed into distance, which drives route position | Must |
|
| FR-7.2 | Integrate speed into distance, which drives route position | Must |
|
||||||
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
|
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
|
||||||
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
|
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
|
||||||
|
| FR-7.4a | **Configurable means reachable and durable.** A setup screen in the app edits them, Rust validates and refuses values that make the engine produce nonsense, and the result is written to `settings.json` in the app data directory so it survives the process. A command the UI never calls does not satisfy FR-7.4 — it left every rider on the 105 kg default | Must |
|
||||||
|
| FR-7.5a | Units are a **display preference** (metric or imperial), applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to | Should |
|
||||||
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
|
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
|
||||||
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
|
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
|
||||||
|
|
||||||
@@ -610,6 +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.2 | Per-device connect / disconnect / forget, with clear state and error text | Must |
|
||||||
| FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
|
| FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
|
||||||
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
|
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
|
||||||
|
| FR-9.20 | **The setup is presented as roles, not radios.** Trainer, shifter and heart rate each get a slot that says what is filling it, what is missing and the one action that would fill it; the device list of FR-9.1 sits below them. A peripheral of no known role is listed but folded away — unless no trainer has been identified at all, since a trainer that does not advertise FTMS until it is connected classifies as unknown | Should |
|
||||||
|
| FR-9.21 | **No MAC addresses on screen.** An address is not something a rider reads, types or acts on, and on Android it is randomised. The device kind is a glyph and the identity is the name; where two rows would otherwise be indistinguishable, four characters of the address disambiguate them | Should |
|
||||||
|
|
||||||
**Ride screen**
|
**Ride screen**
|
||||||
|
|
||||||
@@ -627,6 +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.16 | Estimated energy expenditure in kcal, from measured work and rider mass. One model shared by the live readout and the exported FIT, so the two agree; the trainer's own energy field is not used, because FTMS does not define what it means | Should |
|
||||||
| FR-9.17 | **Screen size is a measured input to the layout, not an assumption.** Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own | Must |
|
| FR-9.17 | **Screen size is a measured input to the layout, not an assumption.** Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own | Must |
|
||||||
| FR-9.18 | **Legibility floors are absolute.** No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go | Must |
|
| FR-9.18 | **Legibility floors are absolute.** No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go | Must |
|
||||||
|
| FR-9.22 | **Effort is shown as a zone where the rider has given a reference.** Power against FTP (Coggan's seven) and heart rate against maximum (five), applied to the rolling average rather than the instantaneous figure so the colour does not strobe at 4 Hz. **No reference, no zone**: an unset FTP draws the plain number rather than a zone measured against a guess. Colour never carries the meaning alone — the zone's short name is rendered beside it | Should |
|
||||||
|
| FR-9.23 | **Gradient is a shape as well as a number**, and the profile's next block is announced with the distance or time to it — what is coming decides whether to shift now | Should |
|
||||||
| FR-9.19 | **Touch is a first-class input on Android.** Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap | Must |
|
| FR-9.19 | **Touch is a first-class input on Android.** Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap | Must |
|
||||||
|
|
||||||
**Post-ride**
|
**Post-ride**
|
||||||
@@ -638,6 +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
|
## 6. Non-Functional Requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| 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-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth |
|
||||||
| NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
|
| NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
|
||||||
| NFR-5 | **Portability** — `core` and `fit` compile for Android with no platform-specific code |
|
| NFR-5 | **Portability** — `core` and `fit` compile for Android with no platform-specific code |
|
||||||
| NFR-6 | **Offline** — full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet |
|
| NFR-6 | **Offline** — every function that is part of *riding* works with no internet connection: discovery, connection, control, profiles, physics, recording and the FIT file. The app contacts no service unless the rider has configured one (FR-10.11), and it never contacts Zwift — only the rider's separate daily unlock needs the internet. An installation with no upload token configured originates no network traffic at all |
|
||||||
| NFR-7 | **Startup** — launch to scanning in under 3 seconds |
|
| NFR-7 | **Startup** — launch to scanning in under 3 seconds |
|
||||||
| NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis |
|
| NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis |
|
||||||
| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
|
| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
|
||||||
@@ -656,6 +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-12 | **Reproducible builds** — every artifact (Linux `.deb`/AppImage, Arch `.pkg.tar.zst`, Android APK) is built in CI from a pinned container image, not from a developer's machine. The images are built from `Dockerfile.builder` and `Dockerfile.arch` in this repo |
|
||||||
| NFR-13 | **No untracked source** — `src-tauri/gen/` is generated and untracked, so every hand-written Android source lives in `src-tauri/android/` and is copied in after each `tauri android init`. CI fails if a source exists only under `gen/`, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written |
|
| NFR-13 | **No untracked source** — `src-tauri/gen/` is generated and untracked, so every hand-written Android source lives in `src-tauri/android/` and is copied in after each `tauri android init`. CI fails if a source exists only under `gen/`, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written |
|
||||||
| NFR-14 | **Version-locked BLE Java** — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a `NoSuchMethodError` at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in `Cargo.lock`, never from a separately published artifact |
|
| NFR-14 | **Version-locked BLE Java** — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a `NoSuchMethodError` at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in `Cargo.lock`, never from a separately published artifact |
|
||||||
|
| NFR-15 | **Nothing on the network is on the ride path** — no ride may be blocked, delayed or degraded by a network operation. Sharing and uploading run *behind* the ride, never in front of it; the ride loop awaits neither, and a service that is down, slow or wrong costs a rider an upload and never a session |
|
||||||
|
| NFR-16 | **Credentials are not settings** — an API token is never stored in `settings.json`, never logged at any level, and never redisplayed by the UI once stored (FR-10.22, FR-10.25–10.28). NFR-8's "log everything" stops at the credential |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -735,6 +1115,20 @@ Three rules fall out of it:
|
|||||||
by the rider in Zwift
|
by the rider in Zwift
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Everything above is the ride. §5.10 hangs off the *right* of it and touches nothing in it:
|
||||||
|
|
||||||
|
```
|
||||||
|
Recorder ──► rides/<stamp>.fit ──┬──► the FIT is the ride artifact; nothing below alters it
|
||||||
|
│
|
||||||
|
├──► FR-9.14 save dialog (desktop + Android, today)
|
||||||
|
├──► FR-10.1 share sheet (Android — JNI → Kotlin → FileProvider)
|
||||||
|
└──► FR-10.9 upload queue ──► crates/sync ──► Runalyze
|
||||||
|
uploads.json (HTTPS, opt-in)
|
||||||
|
```
|
||||||
|
|
||||||
|
The arrows only ever point away from the file. Nothing on the right re-enters the ride loop,
|
||||||
|
and the queue is the only thing there that owns state of its own.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Delivery Plan
|
## 9. Delivery Plan
|
||||||
@@ -793,6 +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-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding),
|
||||||
FR-4.6 (ERG), FR-9.11/9.12, Android.
|
FR-4.6 (ERG), FR-9.11/9.12, Android.
|
||||||
|
|
||||||
|
### Phase 5 — Sharing and upload
|
||||||
|
FR-10, plus the NFR-6 / NFR-15 / NFR-16 amendments. Two independent pieces: the share ships
|
||||||
|
first and is blocked on nothing in the upload.
|
||||||
|
|
||||||
|
| Step | Work |
|
||||||
|
|------|------|
|
||||||
|
| **1** | **Share (FR-10.1–10.8).** `file_paths.xml` becomes a tracked source under `src-tauri/android/` with a `<files-path>` for `rides/`, copied by `sync-android-sources.sh` and checked in CI like the manifest (NFR-13). One Kotlin method on `MainActivity`, one JNI entry in `android.rs`, one command, one button on the summary. **Settles OQ-11 on a real phone against Garmin Connect and Strava** — which is the reason it goes first |
|
||||||
|
| **2** | **Token storage (FR-10.25–10.28)**, before any code exists that has a token to lose |
|
||||||
|
| **3** | **`crates/sync` and the Runalyze impl**, against a local mock HTTP server. Then one real upload and one deliberate re-upload by hand, both bodies pinned as fixtures — **OQ-12**, without which FR-10.19 is a guess |
|
||||||
|
| **4** | **The queue (FR-10.12–10.21)**: `uploads.json`, the drain task, the prune predicate, the UI |
|
||||||
|
|
||||||
|
*Milestone: a ride reaches Garmin Connect from the phone with no cable, and reaches Runalyze
|
||||||
|
without the rider touching anything.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Risks
|
## 10. Risks
|
||||||
@@ -808,6 +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-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) |
|
||||||
| **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
|
| **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
|
||||||
| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `−` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod |
|
| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `−` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod |
|
||||||
|
| **RISK-10** | The FIT share MIME type is not what the receiving apps filter on | **The chooser comes up empty, with no error anywhere.** A feature that looks broken and logs nothing | OQ-11: settle it on a real phone against Garmin Connect, Strava and a file manager *before* the UI is written. `application/vnd.ant.fit` is the registered type; `application/octet-stream` is what many Android apps actually match, and is the fallback |
|
||||||
|
| **RISK-11** | Strava is the service riders will ask for next, and its token exchange needs a `client_secret` inside an open-source native binary — it has no PKCE — against a rate limit shared by every install rather than held per rider | An obvious feature request carries policy risk and a ceiling that arrives without warning | Deferred deliberately, not forgotten. Runalyze first proves the trait, the queue and the error classification; when Strava is done, the likely shape is the rider registering their *own* API application, which moves both the secret and the rate limit onto them. Until then the share sheet reaches Strava's own app and needs none of it |
|
||||||
|
| **RISK-12** | Runalyze's success and duplicate response bodies are undocumented — the reference client discards the body apart from one substring check, and the published docs are behind a login | FR-10.18/10.19's idempotency would rest on a guess, and a retried upload could silently double a ride | Capture both by hand and pin them as fixtures before implementing (OQ-12). Until then, treat a 2xx not containing `No valid token` as accepted and do not claim duplicate detection at all |
|
||||||
|
| **RISK-13** | `file_paths.xml` is generated by the dialog plugin and regenerated by `tauri android init`, and its roots do not cover app-private storage | Share throws `Failed to find configured root` in a build that is otherwise perfectly healthy | Exactly the failure NFR-13 exists for. The file moves under `src-tauri/android/`, the sync script copies it, and CI checks it as it already checks the manifest |
|
||||||
|
|
||||||
### 10.1 On ANT+
|
### 10.1 On ANT+
|
||||||
|
|
||||||
@@ -829,6 +1241,9 @@ intolerable.
|
|||||||
| **OQ-6** | Which frontend framework? Svelte recommended. |
|
| **OQ-6** | Which frontend framework? Svelte recommended. |
|
||||||
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
|
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
|
||||||
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
|
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
|
||||||
|
| **OQ-11** | Which MIME type do Garmin Connect's and Strava's Android apps actually accept a `.fit` on — `application/vnd.ant.fit`, `application/octet-stream`, or both? Answered by trying it on a phone, not by reading (RISK-10). |
|
||||||
|
| **OQ-12** | What do Runalyze's upload responses look like on success, and on a re-upload of a file already accepted? Both need capturing before FR-10.19 can be written (RISK-12). |
|
||||||
|
| **OQ-13** | Is auto-upload a standing preference or a per-ride choice — and if standing, does a ride abandoned after ninety seconds get uploaded too? (Suggest: standing, with a minimum-duration floor, so a two-minute test of the trainer does not land in the training log.) |
|
||||||
|
|
||||||
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
|
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
|
||||||
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
||||||
@@ -849,3 +1264,5 @@ MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
|
|||||||
| **Crr** | Coefficient of rolling resistance |
|
| **Crr** | Coefficient of rolling resistance |
|
||||||
| **CdA** | Drag coefficient × frontal area |
|
| **CdA** | Drag coefficient × frontal area |
|
||||||
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
|
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
|
||||||
|
| **FileProvider** | Android component that hands another app a `content://` URI onto a file in this app's private storage, without making the file world-readable (FR-10.2) |
|
||||||
|
| **Personal API token** | Runalyze's credential: a string the rider generates in their own account settings and pastes in. Not OAuth — no authorisation flow, no redirect, no client secret (FR-10.10) |
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ async fn open_session(
|
|||||||
in_flight: &InFlight,
|
in_flight: &InFlight,
|
||||||
) -> Result<(Session, Notifications), FtmsError> {
|
) -> Result<(Session, Notifications), FtmsError> {
|
||||||
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
|
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).
|
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||||
in_flight.hold(peripheral.clone());
|
in_flight.hold(peripheral.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -1321,6 +1321,10 @@ async fn connect_session(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
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
|
// 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
|
// thing that can leave a link open with nobody to close it. Hand the
|
||||||
// peripheral over now, before `connect()` — a cancellation lands wherever it
|
// peripheral over now, before `connect()` — a cancellation lands wherever it
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ async fn open_session(
|
|||||||
|d| selector.matches(d),
|
|d| selector.matches(d),
|
||||||
)
|
)
|
||||||
.await?;
|
.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).
|
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||||
in_flight.hold(peripheral.clone());
|
in_flight.hold(peripheral.clone());
|
||||||
|
|
||||||
|
|||||||
+153
-8
@@ -1,7 +1,9 @@
|
|||||||
//! BLE discovery (FR-1.1, FR-1.2).
|
//! BLE discovery (FR-1.1, FR-1.2).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::time::Duration;
|
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::api::{Central, Manager as _, Peripheral as _, ScanFilter};
|
||||||
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
|
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.
|
/// Scan for `duration` and return everything seen.
|
||||||
///
|
///
|
||||||
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is
|
/// 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
|
/// pedalled. An empty result means "nothing was advertising", not "no such
|
||||||
/// device exists"; FR-1.8 requires the UI to say so.
|
/// 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(
|
pub async fn scan(
|
||||||
adapter: &Adapter,
|
adapter: &Adapter,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
kind: ScanKind,
|
kind: ScanKind,
|
||||||
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
|
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
|
||||||
adapter.start_scan(kind.filter()).await?;
|
begin(adapter, kind, "device list").await?;
|
||||||
tokio::time::sleep(duration).await;
|
tokio::time::sleep(duration).await;
|
||||||
let peripherals = adapter.peripherals().await?;
|
let peripherals = adapter.peripherals().await?;
|
||||||
// Stopping the scan is best effort; a failure here must not lose results.
|
end(adapter, "device list", "listed").await;
|
||||||
if let Err(e) = adapter.stop_scan().await {
|
|
||||||
tracing::debug!(error = %e, "stop_scan failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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());
|
let mut out = Vec::with_capacity(peripherals.len());
|
||||||
for p in peripherals {
|
for p in peripherals {
|
||||||
if let Some(d) = describe(&p).await {
|
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)));
|
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.
|
/// Convenience wrapper: scan the default adapter for trainers.
|
||||||
@@ -261,6 +404,7 @@ pub async fn find_matching(
|
|||||||
matches: impl Fn(&DiscoveredDevice) -> bool,
|
matches: impl Fn(&DiscoveredDevice) -> bool,
|
||||||
) -> Result<Peripheral, FtmsError> {
|
) -> Result<Peripheral, FtmsError> {
|
||||||
adapter.start_scan(kind.filter()).await?;
|
adapter.start_scan(kind.filter()).await?;
|
||||||
|
discovery_opened(what);
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
let poll = Duration::from_millis(400);
|
let poll = Duration::from_millis(400);
|
||||||
@@ -291,6 +435,7 @@ pub async fn find_matching(
|
|||||||
if let Err(e) = adapter.stop_scan().await {
|
if let Err(e) = adapter.stop_scan().await {
|
||||||
tracing::debug!(error = %e, "stop_scan failed");
|
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()))
|
found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ uuid = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { 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
|
inspect <ADDR> Connect and dump every service, characteristic and capability
|
||||||
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
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
|
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
|
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||||
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
|
||||||
subscribe to everything and print each notification's
|
subscribe to everything and print each notification's
|
||||||
@@ -33,7 +41,7 @@ TARGET (for `set`):
|
|||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
|
--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
|
--all `scan`: list every peripheral, not just fitness machines
|
||||||
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
||||||
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
|
--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.
|
/// until it is greeted, so listening alone hears silence from one.
|
||||||
handshake: bool,
|
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
|
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||||
/// it — a Click, or the trainer itself.
|
/// it — a Click, or the trainer itself.
|
||||||
Zwift {
|
Zwift {
|
||||||
@@ -124,6 +152,10 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
|||||||
let mut no_handshake = false;
|
let mut no_handshake = false;
|
||||||
let mut buttons_only = false;
|
let mut buttons_only = false;
|
||||||
let mut name: Option<String> = None;
|
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 help = false;
|
||||||
let mut positional: Vec<String> = Vec::new();
|
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:?}"))?,
|
.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" => {
|
"--name" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
name = Some(
|
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)),
|
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||||
handshake: !no_handshake,
|
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 {
|
"zwift" => Command::Zwift {
|
||||||
device: device(&positional, 1)?,
|
device: device(&positional, 1)?,
|
||||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
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::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
||||||
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
||||||
use bikecontrol_core::types::ControlTarget;
|
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 btleplug::platform::Peripheral;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use uuid::Uuid;
|
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
|
// 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.
|
// the ~10 Hz repeat while a button is held collapses to one line.
|
||||||
let mut last_mask: Option<u32> = None;
|
let mut last_mask: Option<u32> = None;
|
||||||
|
|
||||||
let mut presses: u64 = 0;
|
let mut presses: u64 = 0;
|
||||||
|
|
||||||
loop {
|
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.
|
/// 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 {
|
fn zwift_named(uuid: Uuid) -> String {
|
||||||
zwift::well_known_name(uuid)
|
zwift::well_known_name(uuid)
|
||||||
.map(|n| format!(" ({n})"))
|
.map(|n| format!(" ({n})"))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod unlock;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -54,6 +55,25 @@ async fn main() -> Result<()> {
|
|||||||
duration,
|
duration,
|
||||||
handshake,
|
handshake,
|
||||||
} => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await,
|
} => 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 {
|
cli::Command::Zwift {
|
||||||
device,
|
device,
|
||||||
duration,
|
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 = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
# Not for the frontend — nothing in `ui/` calls the fs commands. This is here
|
||||||
|
# for `FsExt::read_to_string`, the one API that can read what the *picker*
|
||||||
|
# returns on Android: a `content://` URI rather than a path. See
|
||||||
|
# `read_picked_file` in commands.rs.
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
+176
-27
@@ -4,12 +4,13 @@
|
|||||||
//! they mutate Rust-side state and the resulting truth comes back on the event
|
//! they mutate Rust-side state and the resulting truth comes back on the event
|
||||||
//! channel. The UI never assumes a command took effect (§4.3).
|
//! channel. The UI never assumes a command took effect (§4.3).
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||||
use bikecontrol_core::profile::Profile;
|
use bikecontrol_core::profile::Profile;
|
||||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||||||
|
|
||||||
use bikecontrol_ble::PodId;
|
use bikecontrol_ble::PodId;
|
||||||
|
|
||||||
@@ -188,8 +189,7 @@ pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
|
|||||||
/// Returns the path actually written, so the UI can confirm it rather than
|
/// Returns the path actually written, so the UI can confirm it rather than
|
||||||
/// claiming success against a path it merely proposed.
|
/// claiming success against a path it merely proposed.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||||||
let dest = PathBuf::from(&path);
|
|
||||||
let source = {
|
let source = {
|
||||||
let inner = state.lock();
|
let inner = state.lock();
|
||||||
let summary = inner
|
let summary = inner
|
||||||
@@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
PathBuf::from(&summary.fit_path)
|
PathBuf::from(&summary.fit_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
recording::save_copy(&source, &dest)?;
|
match &path {
|
||||||
let written = dest.display().to_string();
|
FilePath::Path(dest) => recording::save_copy(&source, dest)?,
|
||||||
|
// Android: the save dialog returns a `content://` URI for a document
|
||||||
|
// the provider has already created. There is no directory to make and
|
||||||
|
// no path to copy to — the bytes go down a descriptor the resolver
|
||||||
|
// opens, which is the same reason `read_picked_file` exists.
|
||||||
|
FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
|
||||||
|
}
|
||||||
|
let written = path.to_string();
|
||||||
if let Some(summary) = state.lock().last_summary.as_mut() {
|
if let Some(summary) = state.lock().last_summary.as_mut() {
|
||||||
summary.saved_path = Some(written.clone());
|
summary.saved_path = Some(written.clone());
|
||||||
}
|
}
|
||||||
@@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy the activity into a document the rider chose from an Android picker.
|
||||||
|
///
|
||||||
|
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
|
||||||
|
/// filesystem path on the other end, only a URI the content resolver can turn
|
||||||
|
/// into a writable descriptor. Still a copy, never a move, for the reason
|
||||||
|
/// `save_copy` documents — the automatic file in the rides directory has to
|
||||||
|
/// survive a failed export.
|
||||||
|
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
|
||||||
|
let mut from = std::fs::File::open(source)
|
||||||
|
.map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
|
||||||
|
let mut to = app
|
||||||
|
.fs()
|
||||||
|
.open(
|
||||||
|
dest.clone(),
|
||||||
|
OpenOptions::new().write(true).truncate(true).clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))?;
|
||||||
|
std::io::copy(&mut from, &mut to)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||||
state.lock().reset_ride();
|
state.lock().reset_ride();
|
||||||
@@ -412,10 +441,19 @@ pub fn set_rider_config(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
config: RiderConfig,
|
config: RiderConfig,
|
||||||
) -> Cmd<RiderConfig> {
|
) -> Cmd<RiderConfig> {
|
||||||
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
|
// Every bound refused here is one that makes the engine produce nonsense
|
||||||
return Err("Rider and bike mass must be positive and realistic".into());
|
// rather than something merely unusual, and each refusal says what a
|
||||||
|
// workable value looks like — this is now a form a rider fills in, not a
|
||||||
|
// struct only the developer ever touched.
|
||||||
|
crate::settings::validate_rider(&config)?;
|
||||||
|
{
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.inputs.rider = config;
|
||||||
|
// Written down immediately. A setup that lasts only as long as the
|
||||||
|
// process is what left every rider on the 105 kg default (FR-7.4).
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
}
|
}
|
||||||
state.lock().inputs.rider = config;
|
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -431,14 +469,40 @@ pub fn set_safety_limits(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
limits: SafetyLimits,
|
limits: SafetyLimits,
|
||||||
) -> Cmd<SafetyLimits> {
|
) -> Cmd<SafetyLimits> {
|
||||||
if limits.min_gradient_pct >= limits.max_gradient_pct {
|
crate::settings::validate_limits(&limits)?;
|
||||||
return Err("Gradient limits are inverted".into());
|
{
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.inputs.limits = limits;
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
}
|
}
|
||||||
state.lock().inputs.limits = limits;
|
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
Ok(limits)
|
Ok(limits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display preferences: FTP, maximum heart rate, units (§4.3).
|
||||||
|
///
|
||||||
|
/// Separate from [`rider_config`] because nothing here reaches the physics —
|
||||||
|
/// these decide how a number is drawn, not what it is. They are stored in the
|
||||||
|
/// same file because that is where the rider expects to find them.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn preferences(state: State<'_, AppState>) -> crate::settings::Preferences {
|
||||||
|
state.lock().settings.prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_preferences(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
prefs: crate::settings::Preferences,
|
||||||
|
) -> Cmd<crate::settings::Preferences> {
|
||||||
|
crate::settings::validate_prefs(&prefs)?;
|
||||||
|
let mut inner = state.lock();
|
||||||
|
inner.settings.prefs = prefs;
|
||||||
|
let inner = &*inner;
|
||||||
|
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
|
||||||
|
Ok(prefs)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Profiles (§5.5, §5.6)
|
// Profiles (§5.5, §5.6)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -453,22 +517,101 @@ fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a profile from a path on disk. GPX is detected by extension, everything
|
/// Read a file the rider picked, wherever it actually lives.
|
||||||
/// else is treated as the YAML profile format.
|
///
|
||||||
|
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
|
||||||
|
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
|
||||||
|
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
|
||||||
|
/// or directory" — an error the rider gets for a file they are looking at in
|
||||||
|
/// the picker. And for a provider backed by a server rather than storage
|
||||||
|
/// (Nextcloud, Drive) there may be no local file at all until the resolver
|
||||||
|
/// opens the stream, so no amount of path-guessing could have found one.
|
||||||
|
///
|
||||||
|
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
|
||||||
|
/// opened directly, a URI goes through the Android content resolver for a file
|
||||||
|
/// descriptor. On desktop it is `std::fs` with extra steps.
|
||||||
|
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
|
||||||
|
app.fs()
|
||||||
|
.read_to_string(path.clone())
|
||||||
|
.map_err(|e| format!("{path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picked file is GPX, decided by content rather than by name.
|
||||||
|
///
|
||||||
|
/// The extension is not always there to read: a `content://` URI carries a
|
||||||
|
/// document id, which for some providers contains no filename at all. XML is
|
||||||
|
/// unmistakable next to the YAML profile format — no YAML document begins with
|
||||||
|
/// `<` — so the first non-space character is the reliable test and the
|
||||||
|
/// extension is only a fast path.
|
||||||
|
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
|
||||||
|
path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filename behind a picked file, if there is one to be had.
|
||||||
|
///
|
||||||
|
/// A `FilePath::Path` always has a stem. A URI might: providers over real
|
||||||
|
/// storage encode the path in the last segment, percent-escaped but with the
|
||||||
|
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
|
||||||
|
/// ids, which would make a terrible route name — so "does it end in an
|
||||||
|
/// extension we know" is the test, and anything else gets `None` and falls back
|
||||||
|
/// to the name inside the GPX.
|
||||||
|
fn picked_file_stem(path: &FilePath) -> Option<String> {
|
||||||
|
match path {
|
||||||
|
FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
|
||||||
|
FilePath::Url(url) => {
|
||||||
|
let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
|
||||||
|
if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The escaped separators are all that stands between the document
|
||||||
|
// id and the name inside it.
|
||||||
|
let decoded = stem
|
||||||
|
.replace("%2F", "/")
|
||||||
|
.replace("%2f", "/")
|
||||||
|
.replace("%3A", ":")
|
||||||
|
.replace("%3a", ":");
|
||||||
|
let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
|
||||||
|
(!name.is_empty()).then(|| name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
|
||||||
|
///
|
||||||
|
/// The fallback when the picker gave us no filename to use. Matches on local
|
||||||
|
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
|
||||||
|
/// same reason `core::gpx` does.
|
||||||
|
fn gpx_name(xml: &str) -> Option<String> {
|
||||||
|
let doc = roxmltree::Document::parse(xml).ok()?;
|
||||||
|
let named = |parent: &str| {
|
||||||
|
doc.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == parent)?
|
||||||
|
.children()
|
||||||
|
.find(|c| c.is_element() && c.tag_name().name() == "name")?
|
||||||
|
.text()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
|
named("metadata").or_else(|| named("trk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a profile the rider picked: a path on desktop, a `content://` URI on
|
||||||
|
/// Android. GPX is detected by content, everything else is treated as the YAML
|
||||||
|
/// profile format.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_profile_from_path(
|
pub fn load_profile_from_path(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
path: String,
|
path: FilePath,
|
||||||
) -> Cmd<ProfileView> {
|
) -> Cmd<ProfileView> {
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
let text = read_picked_file(&app, &path)?;
|
||||||
let stem = std::path::Path::new(&path)
|
let is_gpx = looks_like_gpx(&path, &text);
|
||||||
.file_stem()
|
let name = picked_file_stem(&path)
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.or_else(|| gpx_name(&text))
|
||||||
.unwrap_or_else(|| "Profile".into());
|
.unwrap_or_else(|| "Profile".into());
|
||||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||||||
let (view, geom) = profile_view::build(&profile, path);
|
|
||||||
state.lock().set_profile(profile, view.clone(), geom);
|
state.lock().set_profile(profile, view.clone(), geom);
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
notify(
|
notify(
|
||||||
@@ -620,7 +763,14 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
|
|||||||
state.controller().status()
|
state.controller().status()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
|
/// Connect a Click pod, or the one that matters when `pod` is omitted (FR-1.4).
|
||||||
|
///
|
||||||
|
/// **Omitting `pod` means the `−` pod, not both.** One link is the whole
|
||||||
|
/// controller: the `−` pod relays its twin's paddle and face buttons, so all
|
||||||
|
/// ten buttons arrive over it alone (§2.3.1, confirmed in the field
|
||||||
|
/// 2026-08-21). Connecting the pair adds nothing and breaks one thing — the
|
||||||
|
/// `−` pod stops reporting its own paddle. The `+` pod keeps its own button on
|
||||||
|
/// the connection screen for the case where the `−` pod is flat or absent.
|
||||||
///
|
///
|
||||||
/// `device_id` is an address, for a specific pod the scanner has already
|
/// `device_id` is an address, for a specific pod the scanner has already
|
||||||
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
||||||
@@ -645,12 +795,11 @@ pub fn connect_controller(
|
|||||||
if address.is_some() {
|
if address.is_some() {
|
||||||
return Err("An address names one pod, so say which pod it is".into());
|
return Err("An address names one pod, so say which pod it is".into());
|
||||||
}
|
}
|
||||||
// Both, each on its own schedule: a pod that is awake connects now
|
// The `+` pod, since 2026-08-27: it is the one that keeps
|
||||||
// rather than queueing behind its sleeping twin.
|
// 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();
|
let known = state.lock().devices.click_pod_addresses();
|
||||||
for id in PodId::BOTH {
|
controller.connect(PodId::Plus, known.get(&PodId::Plus).cloned());
|
||||||
controller.connect(id, known.get(&id).cloned());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// How long a *live* link may go without ever carrying a button before we stop
|
||||||
/// believing in it.
|
/// believing in it.
|
||||||
///
|
///
|
||||||
/// This is not "the rider has not shifted lately" — that is normal, and tearing
|
/// The §7.1 case: frames arriving steadily, battery every five seconds, and not
|
||||||
/// down a working link over it would strand a pod that only advertises while
|
/// one press in all that time. Recycling costs a few seconds against a pod that
|
||||||
/// awake. It is the narrower and much stranger case from §7.1: frames arriving
|
/// is otherwise useless for the rest of the ride.
|
||||||
/// 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
|
/// It is measured from the last press rather than from the connect, because a
|
||||||
/// touched by this; a wedged one never does, and recycling costs a few seconds
|
/// link can wedge *after* working — sixty presses and then nothing, tablet,
|
||||||
/// against a pod that is otherwise useless for the whole ride.
|
/// 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);
|
const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
|
||||||
/// Upper bound on closing the controller links at exit. Shorter than the
|
/// 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
|
/// trainer's: there is no reset sequence here, only an unsubscribe and a
|
||||||
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
||||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
/// How long a known `−` pod is waited for before a lone `+` pod will do.
|
||||||
|
///
|
||||||
|
/// One link is the whole controller: the `−` pod relays its twin's paddle and
|
||||||
|
/// face buttons (§2.3.1), so with a `−` pod in the house there is nothing for a
|
||||||
|
/// second link to add and one specific thing for it to break — connected as a
|
||||||
|
/// pair, the `−` pod stops reporting its *own* paddle. Confirmed in the field
|
||||||
|
/// 2026-08-21: pairing the `−` pod alone gives all ten buttons.
|
||||||
|
///
|
||||||
|
/// But holding out forever would mean a flat or lost `−` pod costs the rider
|
||||||
|
/// their `+` paddle as well, which is a worse trade than a redundant link. So
|
||||||
|
/// the wait is bounded: long enough for a rider to wake both pods in whatever
|
||||||
|
/// order they like, short enough that half a controller beats none.
|
||||||
|
const PLUS_GRACE: Duration = Duration::from_secs(45);
|
||||||
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
|
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
|
||||||
///
|
///
|
||||||
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
|
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
|
||||||
@@ -379,6 +397,14 @@ enum Cmd {
|
|||||||
/// Connect one pod. `address` is used when the scanner has already seen it,
|
/// Connect one pod. `address` is used when the scanner has already seen it,
|
||||||
/// which is both faster and unambiguous.
|
/// which is both faster and unambiguous.
|
||||||
Connect { pod: PodId, address: Option<String> },
|
Connect { pod: PodId, address: Option<String> },
|
||||||
|
/// This is the pod we paired with last time (FR-1.5).
|
||||||
|
///
|
||||||
|
/// Sets the slot's address without touching the radio, so a pod that is
|
||||||
|
/// still asleep is nonetheless *known* — which is what lets the `+` pod
|
||||||
|
/// hold back for a `−` pod that has not woken up yet, and what gives a
|
||||||
|
/// manual connect an address to go straight to instead of a type byte to
|
||||||
|
/// go hunting with.
|
||||||
|
Remember { pod: PodId, address: String },
|
||||||
/// The scanner has this pod in view *right now* (FR-1.5).
|
/// The scanner has this pod in view *right now* (FR-1.5).
|
||||||
///
|
///
|
||||||
/// The whole difficulty with a Click is that it advertises for only a few
|
/// The whole difficulty with a Click is that it advertises for only a few
|
||||||
@@ -456,6 +482,14 @@ impl ControllerHandle {
|
|||||||
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
|
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed the pod we paired with last time, from the remembered-device store.
|
||||||
|
pub fn remember(&self, pod: PodId, address: &str) {
|
||||||
|
let _ = self.cmd_tx.try_send(Cmd::Remember {
|
||||||
|
pod,
|
||||||
|
address: address.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
||||||
///
|
///
|
||||||
/// Called from the device scan on every pass. Cheap and idempotent: the
|
/// Called from the device scan on every pass. Cheap and idempotent: the
|
||||||
@@ -530,6 +564,9 @@ struct Slot {
|
|||||||
/// frames arriving, and not one press among them.
|
/// frames arriving, and not one press among them.
|
||||||
connected_at: Option<tokio::time::Instant>,
|
connected_at: Option<tokio::time::Instant>,
|
||||||
buttons_this_link: u32,
|
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?
|
/// May this pod be connected the moment the scan sees it?
|
||||||
///
|
///
|
||||||
/// True until the rider disconnects it by hand, because a pod that
|
/// True until the rider disconnects it by hand, because a pod that
|
||||||
@@ -546,6 +583,7 @@ impl Default for Slot {
|
|||||||
events: None,
|
events: None,
|
||||||
connected_at: None,
|
connected_at: None,
|
||||||
buttons_this_link: 0,
|
buttons_this_link: 0,
|
||||||
|
last_button: None,
|
||||||
cancel: None,
|
cancel: None,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
@@ -587,6 +625,13 @@ async fn run(
|
|||||||
let mut swapped = false;
|
let mut swapped = false;
|
||||||
// One press is one press, whichever pods report it (see `Buttons`).
|
// One press is one press, whichever pods report it (see `Buttons`).
|
||||||
let mut buttons = Buttons::default();
|
let mut buttons = Buttons::default();
|
||||||
|
// When a lone `+` pod stops being worse than no controller at all. Pushed
|
||||||
|
// back whenever the `−` pod is reachable, so it only ever expires against a
|
||||||
|
// `−` pod that is genuinely not coming (see `PLUS_GRACE`).
|
||||||
|
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
// 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 (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -601,10 +646,36 @@ async fn run(
|
|||||||
};
|
};
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Connect { pod, address } => {
|
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);
|
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||||||
// Asking for it by hand re-arms auto-connect, whatever
|
// Asking for it by hand re-arms auto-connect, whatever
|
||||||
// came before.
|
// came before.
|
||||||
slot.auto = true;
|
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
|
// A second click while a search is running means
|
||||||
// "connect", which is what is already happening.
|
// "connect", which is what is already happening.
|
||||||
if slot.connecting() {
|
if slot.connecting() {
|
||||||
@@ -618,30 +689,55 @@ async fn run(
|
|||||||
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
||||||
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
||||||
start_attempt(slot, pod, selector, &attempt_tx);
|
start_attempt(slot, pod, selector, &attempt_tx);
|
||||||
|
if pod == PodId::Plus {
|
||||||
|
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
}
|
||||||
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
|
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
|
||||||
}
|
}
|
||||||
|
Cmd::Remember { pod, address } => {
|
||||||
|
// Only fills a gap. A pod we have actually talked to
|
||||||
|
// this session knows its own address better than a file
|
||||||
|
// written last week does.
|
||||||
|
status_tx.send_modify(|s| {
|
||||||
|
let p = s.get_mut(pod);
|
||||||
|
if p.address.is_none() && !address.trim().is_empty() {
|
||||||
|
tracing::info!(pod = pod.as_str(), %address, "controller: remembered pod");
|
||||||
|
p.address = Some(address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Cmd::Seen { pod, address } => {
|
Cmd::Seen { pod, address } => {
|
||||||
// One link is the whole controller.
|
// One link is the whole controller — see
|
||||||
//
|
// `take_plus_pod`, which owns this rule. The short of
|
||||||
// The `−` pod relays its twin: connected on its own it
|
// it: the `−` pod relays its twin, so all ten buttons
|
||||||
// delivers its own paddle and D-pad *and* the `+`
|
// arrive over it alone (§2.3.1, confirmed in the field
|
||||||
// paddle and face buttons — all ten buttons, measured
|
// 2026-08-21), and a `+` link is a substitute for a `−`
|
||||||
// over 445 frames on one characteristic (§2.3.1). So
|
// pod that is missing rather than the other half of a
|
||||||
// the `+` pod is not connected while the `−` pod is
|
// pair. Connecting both is what the merge in `Buttons`
|
||||||
// there to speak for it. It stays a fallback for the
|
|
||||||
// case where the `−` pod is absent or the rider only
|
|
||||||
// owns that half.
|
|
||||||
//
|
|
||||||
// Connecting both is what the pair-merge in `Buttons`
|
|
||||||
// exists to paper over, and it is also the
|
// exists to paper over, and it is also the
|
||||||
// configuration in which the `−` pod stops reporting
|
// configuration in which the `−` pod stops reporting
|
||||||
// its own paddle — the failure that cost an evening.
|
// its own paddle — the failure that cost an evening.
|
||||||
// Not opening the second link removes both.
|
if pod == PodId::Minus
|
||||||
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
|
&& !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!(
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
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.
|
// already found are the seconds it goes back to sleep in.
|
||||||
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
||||||
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
||||||
|
if pod == PodId::Minus {
|
||||||
|
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||||
|
}
|
||||||
status_tx.send_modify(|s| {
|
status_tx.send_modify(|s| {
|
||||||
let p = s.get_mut(pod);
|
let p = s.get_mut(pod);
|
||||||
p.address = Some(address);
|
p.address = Some(address);
|
||||||
@@ -738,25 +837,37 @@ async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = housekeeping.tick() => {
|
_ = housekeeping.tick() => {
|
||||||
|
// A `−` pod that is up, or on its way up, is a `−` pod worth
|
||||||
|
// waiting for. Only a slot that has been idle for the whole
|
||||||
|
// grace period lets the `+` pod in.
|
||||||
|
if !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 —
|
// Converge on one link. The `+` pod may have connected first —
|
||||||
// it is the one the rider happened to wake — and once the `−`
|
// it is the one the rider happened to wake — and once the `−`
|
||||||
// pod is up it speaks for both, so the second link is redundant
|
// pod is up it speaks for both, so the second link is redundant
|
||||||
// and is exactly the configuration that breaks the `−` paddle.
|
// and is exactly the configuration that breaks the `−` paddle.
|
||||||
// Dropping it leaves `auto` alone, so if the `−` pod later goes
|
// Dropping it leaves `auto` alone, so if the `−` pod later goes
|
||||||
// away the `+` pod is picked up again on its next advertisement.
|
// 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!(
|
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);
|
forget(PodId::Minus, &mut buttons, &input_tx);
|
||||||
plus.generation += 1;
|
minus.generation += 1;
|
||||||
plus.events = None;
|
minus.events = None;
|
||||||
plus.last_seen = None;
|
minus.last_seen = None;
|
||||||
plus.connected_at = None;
|
minus.connected_at = None;
|
||||||
if let Some(client) = plus.client.take() {
|
if let Some(client) = minus.client.take() {
|
||||||
tokio::spawn(async move { client.shutdown().await });
|
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
|
// 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 slot = slot_mut(&mut minus, &mut plus, id);
|
||||||
let alive = slot.client.is_some()
|
let alive = slot.client.is_some()
|
||||||
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
|
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
|
||||||
let mute = slot.buttons_this_link == 0
|
// Silence *since the last press*, not "never pressed".
|
||||||
&& slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
|
//
|
||||||
|
// 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 {
|
if alive && mute && slot.auto {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
pod = id.as_str(),
|
pod = id.as_str(),
|
||||||
"controller: link is alive but has never carried a button; \
|
presses = slot.buttons_this_link,
|
||||||
recycling it (see REQUIREMENTS §7.1)"
|
"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.generation += 1;
|
||||||
slot.connected_at = None;
|
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 {
|
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
||||||
match pod {
|
match pod {
|
||||||
PodId::Minus => minus,
|
PodId::Minus => minus,
|
||||||
@@ -895,6 +1049,7 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<Co
|
|||||||
// nothing about this one.
|
// nothing about this one.
|
||||||
slot.connected_at = Some(tokio::time::Instant::now());
|
slot.connected_at = Some(tokio::time::Instant::now());
|
||||||
slot.buttons_this_link = 0;
|
slot.buttons_this_link = 0;
|
||||||
|
slot.last_button = None;
|
||||||
status_tx.send_modify(|s| {
|
status_tx.send_modify(|s| {
|
||||||
let p = s.get_mut(pod);
|
let p = s.get_mut(pod);
|
||||||
p.state = PodState::Connected;
|
p.state = PodState::Connected;
|
||||||
@@ -983,6 +1138,7 @@ fn handle_event(
|
|||||||
// This link has now proven it carries input, which puts it
|
// This link has now proven it carries input, which puts it
|
||||||
// beyond the no-input watchdog for as long as it lasts.
|
// beyond the no-input watchdog for as long as it lasts.
|
||||||
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
|
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
|
// 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
|
// ever arrives — the pod does not announce that it has started
|
||||||
@@ -1574,6 +1730,47 @@ mod tests {
|
|||||||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
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]
|
#[test]
|
||||||
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
||||||
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
||||||
|
|||||||
+414
-46
@@ -19,7 +19,8 @@
|
|||||||
//! point is refused.
|
//! point is refused.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::Duration;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
||||||
use bikecontrol_ble::uuids;
|
use bikecontrol_ble::uuids;
|
||||||
@@ -31,16 +32,55 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::controller::{ControllerHandle, PodState};
|
use crate::controller::{ControllerHandle, PodState};
|
||||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||||
|
use crate::known::KnownDevices;
|
||||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||||
|
|
||||||
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
/// How long one discovery session is held before it is recycled.
|
||||||
/// enough that the list feels live.
|
///
|
||||||
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
|
/// **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.
|
/// Poll interval while scanning is switched off.
|
||||||
const IDLE_POLL: Duration = Duration::from_millis(400);
|
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
|
/// How long to wait before looking for the adapter again. Longer than the scan
|
||||||
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
||||||
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
/// How long before auto-connect tries a remembered device again (FR-1.5).
|
||||||
|
///
|
||||||
|
/// An attempt only starts against a peripheral the scan can *see*, so the
|
||||||
|
/// common case needs no backing off at all. This is for the awkward one: a
|
||||||
|
/// trainer that advertises happily and then refuses the connect, which without
|
||||||
|
/// a cooldown would be retried every device tick — four failed connects a
|
||||||
|
/// second, all of them fighting the scan for the one adapter.
|
||||||
|
const AUTO_RETRY: Duration = Duration::from_secs(15);
|
||||||
|
/// How many times auto-connect will chase one device before leaving it alone.
|
||||||
|
///
|
||||||
|
/// FR-1.11 in the small: giving up has to be terminal until the rider acts, and
|
||||||
|
/// an unbounded retry would quietly undo it — the trainer supervisor announces
|
||||||
|
/// that it has stopped trying, and two ticks later the device list starts the
|
||||||
|
/// whole thing again. Cleared the moment the link does come up, and by any
|
||||||
|
/// Connect the rider presses themselves, so the budget is per problem rather
|
||||||
|
/// than per lifetime.
|
||||||
|
const AUTO_ATTEMPTS: u32 = 5;
|
||||||
|
|
||||||
/// What to suggest when there is no adapter. The remedy is platform-specific
|
/// What to suggest when there is no adapter. The remedy is platform-specific
|
||||||
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
||||||
@@ -126,6 +166,14 @@ pub struct PollResult {
|
|||||||
pub hr_changed: Option<HeartRateStatus>,
|
pub hr_changed: Option<HeartRateStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-connect's memory of one address: when it last tried, and how many
|
||||||
|
/// times it has tried since the last time the link actually came up.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct AutoAttempts {
|
||||||
|
last: Instant,
|
||||||
|
tries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// What the scan task publishes.
|
/// What the scan task publishes.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ScanSnapshot {
|
pub struct ScanSnapshot {
|
||||||
@@ -146,8 +194,23 @@ pub struct DeviceRegistry {
|
|||||||
hr: HeartRateHandle,
|
hr: HeartRateHandle,
|
||||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||||
scan_on: watch::Sender<bool>,
|
scan_on: watch::Sender<bool>,
|
||||||
forgotten: HashSet<String>,
|
/// Everything the rider has paired with, and everything they have refused.
|
||||||
remembered: HashSet<String>,
|
/// Backed by a file, so it outlives the process (FR-1.5).
|
||||||
|
known: KnownDevices,
|
||||||
|
/// Links the rider closed by hand *this session*. Auto-connect must not
|
||||||
|
/// undo a deliberate disconnect two ticks later — that is not a disconnect,
|
||||||
|
/// it is a flicker. Deliberately not persisted: a fresh launch is a fresh
|
||||||
|
/// intention, and next time they start the app they do want their trainer.
|
||||||
|
auto_off: HashSet<String>,
|
||||||
|
/// What auto-connect has already tried, per address, so a device that
|
||||||
|
/// advertises but will not connect is retried on a schedule and then let
|
||||||
|
/// be (see [`AUTO_RETRY`], [`AUTO_ATTEMPTS`]).
|
||||||
|
auto: HashMap<String, AutoAttempts>,
|
||||||
|
/// Addresses the most recent scan pass actually saw. A remembered device is
|
||||||
|
/// only chased while it is advertising: a connect against a sleeping
|
||||||
|
/// peripheral burns the whole scan timeout for nothing, and the scan is
|
||||||
|
/// already telling us the moment it wakes (A-4).
|
||||||
|
seen_now: HashSet<String>,
|
||||||
/// The list published last tick, for change detection.
|
/// The list published last tick, for change detection.
|
||||||
published: Vec<DeviceInfo>,
|
published: Vec<DeviceInfo>,
|
||||||
last_trainer: TrainerStatus,
|
last_trainer: TrainerStatus,
|
||||||
@@ -173,8 +236,10 @@ impl DeviceRegistry {
|
|||||||
hr,
|
hr,
|
||||||
scan_rx,
|
scan_rx,
|
||||||
scan_on,
|
scan_on,
|
||||||
forgotten: HashSet::new(),
|
known: KnownDevices::default(),
|
||||||
remembered: HashSet::new(),
|
auto_off: HashSet::new(),
|
||||||
|
auto: HashMap::new(),
|
||||||
|
seen_now: HashSet::new(),
|
||||||
published: Vec::new(),
|
published: Vec::new(),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
scan_suspended: false,
|
scan_suspended: false,
|
||||||
@@ -182,6 +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
|
/// Rider-initiated. Cancels any suspension: an explicit request outranks
|
||||||
/// our own bookkeeping in both directions.
|
/// our own bookkeeping in both directions.
|
||||||
pub fn start_scan(&mut self) {
|
pub fn start_scan(&mut self) {
|
||||||
@@ -224,11 +312,16 @@ impl DeviceRegistry {
|
|||||||
self.set_scanning(true);
|
self.set_scanning(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let next = self.build(&trainer);
|
let next = self.build(&trainer);
|
||||||
let transitions = state_transitions(&self.published, &next);
|
let transitions = state_transitions(&self.published, &next);
|
||||||
let changed = next != self.published;
|
let changed = next != self.published;
|
||||||
self.published = next;
|
self.published = next;
|
||||||
|
|
||||||
|
// After publishing, never before: `connect` reads the published list,
|
||||||
|
// and a device discovered this tick has to be in it to be connectable.
|
||||||
|
self.auto_connect();
|
||||||
|
|
||||||
PollResult {
|
PollResult {
|
||||||
changed,
|
changed,
|
||||||
transitions,
|
transitions,
|
||||||
@@ -244,10 +337,12 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let controller = self.controller.status();
|
let controller = self.controller.status();
|
||||||
|
|
||||||
|
self.seen_now.clear();
|
||||||
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
||||||
for d in &snapshot.devices {
|
for d in &snapshot.devices {
|
||||||
let id = d.address.clone();
|
let id = d.address.clone();
|
||||||
if self.forgotten.contains(&id) {
|
self.seen_now.insert(id.clone());
|
||||||
|
if self.known.is_forgotten(&id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = classify(d);
|
let kind = classify(d);
|
||||||
@@ -257,9 +352,16 @@ impl DeviceRegistry {
|
|||||||
// is the pod the rider already told us about by pressing a button
|
// is the pod the rider already told us about by pressing a button
|
||||||
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
||||||
// it on purpose, in which case the supervisor ignores this.
|
// it on purpose, in which case the supervisor ignores this.
|
||||||
|
//
|
||||||
|
// Once we have paired with a pod, it is that pod we chase. Every
|
||||||
|
// Click advertises the same name and the same type byte, so without
|
||||||
|
// this a rider whose partner is warming up in the same room gets
|
||||||
|
// whichever pod woke first.
|
||||||
if let Some(pod) = kind.pod_id() {
|
if let Some(pod) = kind.pod_id() {
|
||||||
|
if is_ours(&self.known, kind, &id) {
|
||||||
self.controller.pod_seen(pod, &id);
|
self.controller.pod_seen(pod, &id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
out.push(DeviceInfo {
|
out.push(DeviceInfo {
|
||||||
kind,
|
kind,
|
||||||
name: d.label(),
|
name: d.label(),
|
||||||
@@ -272,7 +374,9 @@ impl DeviceRegistry {
|
|||||||
},
|
},
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
||||||
remembered: self.remembered.contains(&id),
|
// Filled in below, once every row exists: the pass that records
|
||||||
|
// a live link is the same pass that reads the store back.
|
||||||
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -296,7 +400,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -308,7 +412,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = DeviceKind::Trainer;
|
device.kind = DeviceKind::Trainer;
|
||||||
device.state = trainer.state.clone();
|
device.state = trainer.state.clone();
|
||||||
device.control_acquired = trainer.control_acquired;
|
device.control_acquired = trainer.control_acquired;
|
||||||
device.remembered = true;
|
|
||||||
device.error = trainer.error.clone().or_else(|| {
|
device.error = trainer.error.clone().or_else(|| {
|
||||||
trainer
|
trainer
|
||||||
.stale
|
.stale
|
||||||
@@ -329,7 +432,7 @@ impl DeviceRegistry {
|
|||||||
let Some(address) = pod.address.clone() else {
|
let Some(address) = pod.address.clone() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self.forgotten.contains(&address) {
|
if self.known.is_forgotten(&address) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = match id {
|
let kind = match id {
|
||||||
@@ -348,7 +451,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -360,7 +463,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = kind;
|
device.kind = kind;
|
||||||
device.battery_pct = pod.battery_percent;
|
device.battery_pct = pod.battery_percent;
|
||||||
device.error = pod.error.clone();
|
device.error = pod.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
device.state = match pod.state {
|
device.state = match pod.state {
|
||||||
PodState::Connected => ConnectionState::Connected,
|
PodState::Connected => ConnectionState::Connected,
|
||||||
PodState::Searching => ConnectionState::Connecting,
|
PodState::Searching => ConnectionState::Connecting,
|
||||||
@@ -380,7 +482,7 @@ impl DeviceRegistry {
|
|||||||
// link.
|
// link.
|
||||||
let hr = self.hr.status();
|
let hr = self.hr.status();
|
||||||
if let Some(address) = hr.address.clone() {
|
if let Some(address) = hr.address.clone() {
|
||||||
if !self.forgotten.contains(&address) {
|
if !self.known.is_forgotten(&address) {
|
||||||
let idx = match out.iter().position(|d| d.id == address) {
|
let idx = match out.iter().position(|d| d.id == address) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
@@ -396,7 +498,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(HEART_RATE_SERVICE)],
|
services: vec![describe_service(HEART_RATE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -409,7 +511,6 @@ impl DeviceRegistry {
|
|||||||
device.battery_pct = hr.battery_percent;
|
device.battery_pct = hr.battery_percent;
|
||||||
device.heart_rate_bpm = hr.bpm;
|
device.heart_rate_bpm = hr.bpm;
|
||||||
device.error = hr.error.clone();
|
device.error = hr.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
if let Some(name) = &hr.name {
|
if let Some(name) = &hr.name {
|
||||||
device.name = name.clone();
|
device.name = name.clone();
|
||||||
}
|
}
|
||||||
@@ -429,9 +530,95 @@ impl DeviceRegistry {
|
|||||||
.then(b.rssi.cmp(&a.rssi))
|
.then(b.rssi.cmp(&a.rssi))
|
||||||
.then(a.id.cmp(&b.id))
|
.then(a.id.cmp(&b.id))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A link that actually came up is what "paired" means (FR-1.5) — not a
|
||||||
|
// Connect the rider pressed against a trainer that then refused them.
|
||||||
|
// So the store is written from the finished list rather than from the
|
||||||
|
// request, and the row's `remembered` flag is read straight back out of
|
||||||
|
// it, which keeps the screen and the file incapable of disagreeing.
|
||||||
|
for device in &out {
|
||||||
|
if matches!(
|
||||||
|
device.state,
|
||||||
|
ConnectionState::Connected | ConnectionState::Controlling
|
||||||
|
) {
|
||||||
|
self.known
|
||||||
|
.remember(&device.address, Some(&device.name), device.kind);
|
||||||
|
// And a link that came up settles whatever auto-connect was
|
||||||
|
// struggling with, so the next problem starts from a full
|
||||||
|
// budget rather than inheriting the last one's.
|
||||||
|
self.auto.remove(&device.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for device in &mut out {
|
||||||
|
device.remembered = self.known.contains(&device.address);
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconnect to remembered hardware as it turns up (FR-1.5).
|
||||||
|
///
|
||||||
|
/// This is the whole feature from the rider's side: launch the app, get on
|
||||||
|
/// the bike, pedal, and the trainer and strap are simply there — no trip to
|
||||||
|
/// the device screen, no Connect button, no repairing what was paired last
|
||||||
|
/// week. Deliberately driven by the scan rather than fired once at startup,
|
||||||
|
/// because the hardware is asleep at startup: a trainer wakes when the
|
||||||
|
/// cranks turn and a strap when it is put on, and *that* is the moment
|
||||||
|
/// worth acting on (A-4, FR-1.8).
|
||||||
|
///
|
||||||
|
/// Click pods are absent here on purpose. They already have this, in the
|
||||||
|
/// shape of `pod_seen` above — a pod's advertising window is too short for
|
||||||
|
/// anything that waits for the list to be published.
|
||||||
|
fn auto_connect(&mut self) {
|
||||||
|
if !self.scanning {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let candidates: Vec<DeviceInfo> = self
|
||||||
|
.published
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.kind, DeviceKind::Trainer | DeviceKind::HeartRate))
|
||||||
|
// Advertising right now, so the connect has something to reach.
|
||||||
|
.filter(|d| self.seen_now.contains(&d.address))
|
||||||
|
.filter(|d| d.remembered && !self.auto_off.contains(&d.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for device in candidates {
|
||||||
|
// Whatever is holding the supervisor — a live link, a connect in
|
||||||
|
// flight, a reconnect the BLE layer is running — outranks this.
|
||||||
|
let busy = match device.kind {
|
||||||
|
DeviceKind::Trainer => self.trainer.status().is_attached(),
|
||||||
|
_ => self.hr.status().is_attached(),
|
||||||
|
};
|
||||||
|
if busy {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let previous = self.auto.get(&device.id).copied();
|
||||||
|
if !may_auto_connect(previous.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tries = previous.map_or(0, |a| a.tries);
|
||||||
|
self.auto.insert(
|
||||||
|
device.id.clone(),
|
||||||
|
AutoAttempts {
|
||||||
|
last: Instant::now(),
|
||||||
|
tries: tries + 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
name = %device.name,
|
||||||
|
address = %device.address,
|
||||||
|
attempt = tries + 1,
|
||||||
|
"reconnecting to a remembered device"
|
||||||
|
);
|
||||||
|
// Through the same path the rider's own click takes, so a connect
|
||||||
|
// started by the scan and one started by a finger cannot diverge —
|
||||||
|
// including suspending the scan for a trainer.
|
||||||
|
if let Err(e) = self.connect(&device.id) {
|
||||||
|
tracing::debug!(address = %device.address, reason = %e, "auto-connect declined");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<DeviceInfo> {
|
pub fn list(&self) -> Vec<DeviceInfo> {
|
||||||
self.published.clone()
|
self.published.clone()
|
||||||
}
|
}
|
||||||
@@ -449,13 +636,12 @@ impl DeviceRegistry {
|
|||||||
// controller supervisor rather than handled here, so the device list
|
// controller supervisor rather than handled here, so the device list
|
||||||
// and the connection screen drive the same one link per pod (FR-1.4).
|
// and the connection screen drive the same one link per pod (FR-1.4).
|
||||||
if let Some(pod) = device.kind.pod_id() {
|
if let Some(pod) = device.kind.pod_id() {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.controller.connect(pod, Some(device.address.clone()));
|
self.controller.connect(pod, Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,13 +649,12 @@ impl DeviceRegistry {
|
|||||||
// the heart rate supervisor so this list and whatever else drives the
|
// the heart rate supervisor so this list and whatever else drives the
|
||||||
// link stay one path.
|
// link stay one path.
|
||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.hr.connect(Some(device.address.clone()));
|
self.hr.connect(Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,8 +693,7 @@ impl DeviceRegistry {
|
|||||||
// soon as the trainer is no longer attached (FR-1.12).
|
// soon as the trainer is no longer attached (FR-1.12).
|
||||||
self.set_scanning(false);
|
self.set_scanning(false);
|
||||||
self.scan_suspended = true;
|
self.scan_suspended = true;
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.trainer
|
self.trainer
|
||||||
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
||||||
|
|
||||||
@@ -517,7 +701,7 @@ impl DeviceRegistry {
|
|||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.control_acquired = false;
|
info.control_acquired = false;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +709,11 @@ impl DeviceRegistry {
|
|||||||
let mut device = self
|
let mut device = self
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||||
|
// The device stays remembered — the rider closed this link, not the
|
||||||
|
// pairing — but auto-connect stops chasing it for the rest of the
|
||||||
|
// session. A "disconnect" that reconnects itself half a second later is
|
||||||
|
// not a disconnect (FR-1.5).
|
||||||
|
self.auto_off.insert(id.to_string());
|
||||||
if device.kind == DeviceKind::Trainer {
|
if device.kind == DeviceKind::Trainer {
|
||||||
// SAF-2 runs inside the supervisor before the link drops.
|
// SAF-2 runs inside the supervisor before the link drops.
|
||||||
self.trainer.disconnect();
|
self.trainer.disconnect();
|
||||||
@@ -550,12 +739,29 @@ impl DeviceRegistry {
|
|||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.hr.disconnect();
|
self.hr.disconnect();
|
||||||
}
|
}
|
||||||
self.remembered.remove(id);
|
self.known.forget(&device.address);
|
||||||
self.forgotten.insert(id.to_string());
|
self.auto_off.remove(id);
|
||||||
|
self.auto.remove(id);
|
||||||
self.published.retain(|d| d.id != id);
|
self.published.retain(|d| d.id != id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rider asked for this device: un-forget it and re-arm auto-connect.
|
||||||
|
///
|
||||||
|
/// It is deliberately *not* remembered here. A Connect that the hardware
|
||||||
|
/// then refuses is not a pairing, and writing one down would mean a trainer
|
||||||
|
/// the rider gave up on getting chased on every launch afterwards. The
|
||||||
|
/// store is written when the link actually comes up — see `build`.
|
||||||
|
///
|
||||||
|
/// A row's `id` *is* its address — see how every branch of `build`
|
||||||
|
/// constructs one — so the same string keys the store and the two
|
||||||
|
/// session-lifetime maps.
|
||||||
|
fn wanted(&mut self, address: &str) {
|
||||||
|
self.known.unforget(address);
|
||||||
|
self.auto_off.remove(address);
|
||||||
|
self.auto.remove(address);
|
||||||
|
}
|
||||||
|
|
||||||
/// The address of each Click pod the scanner has seen (FR-1.4).
|
/// The address of each Click pod the scanner has seen (FR-1.4).
|
||||||
///
|
///
|
||||||
/// Connecting by address is both faster and unambiguous: the pods share a
|
/// Connecting by address is both faster and unambiguous: the pods share a
|
||||||
@@ -616,6 +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?
|
/// May a scan we suspended for a connect be switched back on?
|
||||||
///
|
///
|
||||||
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
|
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
|
||||||
@@ -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
|
// 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
|
// 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.
|
// in the room to know the scan is working at all.
|
||||||
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
|
// Wait out any link still being built before opening a session, for the
|
||||||
generation += 1;
|
// same reason the sample loop below yields to one.
|
||||||
let snapshot = match result {
|
while scan::gatt_setup_active() {
|
||||||
Ok(devices) => ScanSnapshot {
|
tokio::time::sleep(GATT_YIELD).await;
|
||||||
devices,
|
}
|
||||||
error: None,
|
|
||||||
generation,
|
if let Err(e) = scan::begin(&adapter, ScanKind::All, "device list").await {
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
// Debug as well as Display, because the useful half of a BLE
|
// Debug as well as Display, because the useful half of a BLE
|
||||||
// failure is usually in the source chain that Display drops.
|
// failure is usually in the source chain that Display drops.
|
||||||
// "bluetooth error: JNI call failed" was the *entire* symptom of
|
// "bluetooth error: JNI call failed" was the *entire* symptom of a
|
||||||
// a detached-thread bug; the Debug form said
|
// detached-thread bug; the Debug form said
|
||||||
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have
|
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have named
|
||||||
// named it outright.
|
// it outright.
|
||||||
tracing::warn!(error = %e, cause = ?e, "scan failed");
|
tracing::warn!(error = %e, cause = ?e, "scan failed to start");
|
||||||
ScanSnapshot {
|
generation += 1;
|
||||||
|
let _ = tx.send(ScanSnapshot {
|
||||||
devices: Vec::new(),
|
devices: Vec::new(),
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
generation,
|
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;
|
tokio::time::sleep(IDLE_POLL).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,6 +1114,79 @@ mod tests {
|
|||||||
assert!(!should_resume_scan(false, &idle));
|
assert!(!should_resume_scan(false, &idle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_connect_paces_itself_and_eventually_stops() {
|
||||||
|
// Never tried: go.
|
||||||
|
assert!(may_auto_connect(None));
|
||||||
|
|
||||||
|
// Just tried. A trainer that advertises and then refuses would
|
||||||
|
// otherwise be retried on every device tick, four times a second, each
|
||||||
|
// attempt fighting the scan for the one adapter.
|
||||||
|
let just_now = AutoAttempts {
|
||||||
|
last: Instant::now(),
|
||||||
|
tries: 1,
|
||||||
|
};
|
||||||
|
assert!(!may_auto_connect(Some(&just_now)));
|
||||||
|
|
||||||
|
// Long enough ago, and budget left.
|
||||||
|
let stale = AutoAttempts {
|
||||||
|
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
|
||||||
|
tries: 1,
|
||||||
|
};
|
||||||
|
assert!(may_auto_connect(Some(&stale)));
|
||||||
|
|
||||||
|
// Out of budget. FR-1.11: the app has to be able to stop, or "gave up"
|
||||||
|
// is a message it contradicts two ticks later. The rider's own Connect
|
||||||
|
// clears this — see `wanted`.
|
||||||
|
let spent = AutoAttempts {
|
||||||
|
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
|
||||||
|
tries: AUTO_ATTEMPTS,
|
||||||
|
};
|
||||||
|
assert!(!may_auto_connect(Some(&spent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_pod_will_do_until_one_has_been_paired_with() {
|
||||||
|
// FR-1.5. Every Click advertises the same name and the same type byte,
|
||||||
|
// so "a − pod is advertising" is not the same question as "*our* − pod
|
||||||
|
// is advertising" the moment there is more than one in the room.
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
|
||||||
|
|
||||||
|
known.remember(
|
||||||
|
"AA:BB:CC:DD:EE:01",
|
||||||
|
Some("Zwift Click"),
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
);
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "aa:bb:cc:dd:ee:01"));
|
||||||
|
// The one on the next bike.
|
||||||
|
assert!(!is_ours(
|
||||||
|
&known,
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
"AA:BB:CC:DD:EE:02"
|
||||||
|
));
|
||||||
|
// A kind we have never paired with is still wide open.
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickPlus, "AA:BB:CC:DD:EE:03"));
|
||||||
|
assert!(is_ours(&known, DeviceKind::Trainer, "AA:BB:CC:DD:EE:04"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_replacement_is_adopted_once_the_rider_connects_it() {
|
||||||
|
// The pod broke and a new one arrived. Auto-connect ignores it — it is
|
||||||
|
// not the one we know — but the rider's own Connect pairs it, and from
|
||||||
|
// then on it is chased like the old one.
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.remember("AA:BB:CC:DD:EE:01", None, DeviceKind::ClickMinus);
|
||||||
|
assert!(!is_ours(
|
||||||
|
&known,
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
"AA:BB:CC:DD:EE:99"
|
||||||
|
));
|
||||||
|
known.remember("AA:BB:CC:DD:EE:99", None, DeviceKind::ClickMinus);
|
||||||
|
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:99"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn well_known_services_are_named_and_zwift_is_recognised() {
|
fn well_known_services_are_named_and_zwift_is_recognised() {
|
||||||
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! Devices the rider has paired before, remembered across launches (FR-1.5).
|
||||||
|
//!
|
||||||
|
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
|
||||||
|
//! which meant it lasted exactly as long as the process. Every launch started
|
||||||
|
//! from nothing: find the trainer, press Connect, find the strap, press
|
||||||
|
//! Connect, and only then ride. This is that set written down.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! app_data_dir()/devices.json
|
||||||
|
//! { "version": 1,
|
||||||
|
//! "devices": [ { address, name, kind }, … ],
|
||||||
|
//! "forgotten": [ address, … ] }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Two lists rather than one, because *forgotten* is not merely "absent".
|
||||||
|
//! Absent means never seen; forgotten means the rider looked at this device and
|
||||||
|
//! said no, and auto-connect has to keep honouring that on the next launch too.
|
||||||
|
//!
|
||||||
|
//! The file is small and written only when something actually changes — a pair,
|
||||||
|
//! an unpair, a name learned — so this never lands in the ride loop's path. It
|
||||||
|
//! is also *advisory*: a corrupt or unreadable file costs the rider their
|
||||||
|
//! auto-connect, never their ride, so every failure here is logged and
|
||||||
|
//! swallowed rather than propagated.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::devices::DeviceKind;
|
||||||
|
|
||||||
|
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||||
|
/// we do not know is discarded rather than guessed at.
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
const FILE: &str = "devices.json";
|
||||||
|
|
||||||
|
/// One device the rider has paired with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KnownDevice {
|
||||||
|
/// As the adapter reports it. Matching is case-insensitive — see [`key`] —
|
||||||
|
/// but what is written down is what we were told.
|
||||||
|
pub address: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub kind: DeviceKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file on disk. Kept separate from the in-memory form so the indexes below
|
||||||
|
/// are never serialised.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
struct Stored {
|
||||||
|
version: u32,
|
||||||
|
devices: Vec<KnownDevice>,
|
||||||
|
forgotten: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
|
||||||
|
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
|
||||||
|
/// but not a platform is not much of a pairing.
|
||||||
|
fn key(address: &str) -> String {
|
||||||
|
address.trim().to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the app remembers about the hardware in the room, plus where to
|
||||||
|
/// write it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct KnownDevices {
|
||||||
|
devices: BTreeMap<String, KnownDevice>,
|
||||||
|
forgotten: BTreeSet<String>,
|
||||||
|
/// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
|
||||||
|
/// is an `AppHandle` to ask for a data directory, so the registry spends
|
||||||
|
/// the first moments of the process with an in-memory-only store.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KnownDevices {
|
||||||
|
/// Read the file, or start empty if it is missing, unreadable or from a
|
||||||
|
/// version we do not understand.
|
||||||
|
pub fn load(path: PathBuf) -> Self {
|
||||||
|
let mut out = Self {
|
||||||
|
path: Some(path.clone()),
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
let text = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stored: Stored = match serde_json::from_str(&text) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if stored.version != VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
found = stored.version,
|
||||||
|
expected = VERSION,
|
||||||
|
"remembered devices are from another version; starting fresh"
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for device in stored.devices {
|
||||||
|
out.devices.insert(key(&device.address), device);
|
||||||
|
}
|
||||||
|
out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.devices.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.devices.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has this device been paired with before?
|
||||||
|
pub fn contains(&self, address: &str) -> bool {
|
||||||
|
self.devices.contains_key(&key(address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the rider say no to this device?
|
||||||
|
pub fn is_forgotten(&self, address: &str) -> bool {
|
||||||
|
self.forgotten.contains(&key(address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remembered device of this kind, if there is one. Used to prefer
|
||||||
|
/// *our* Click over the identically-named one in the next room.
|
||||||
|
pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
|
||||||
|
self.devices.values().find(|d| d.kind == kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
|
||||||
|
self.first_of(kind).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a pairing. No-op — and no write — when nothing changed, which is
|
||||||
|
/// the common case: this is called from the device poll, four times a
|
||||||
|
/// second.
|
||||||
|
pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
|
||||||
|
// Nothing useful to auto-connect to, and a list full of every anonymous
|
||||||
|
// peripheral in the building helps nobody.
|
||||||
|
if kind == DeviceKind::Unknown || address.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let id = key(address);
|
||||||
|
let entry = KnownDevice {
|
||||||
|
address: address.to_string(),
|
||||||
|
name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
let unchanged = self.devices.get(&id) == Some(&entry);
|
||||||
|
let was_forgotten = self.forgotten.remove(&id);
|
||||||
|
if unchanged && !was_forgotten {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(address, name, ?kind, "remembering device");
|
||||||
|
self.devices.insert(id, entry);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rider said no. Both halves matter: drop the pairing *and* record the
|
||||||
|
/// refusal, so the next launch does not helpfully connect it again.
|
||||||
|
pub fn forget(&mut self, address: &str) {
|
||||||
|
let id = key(address);
|
||||||
|
let removed = self.devices.remove(&id).is_some();
|
||||||
|
let added = self.forgotten.insert(id);
|
||||||
|
if removed || added {
|
||||||
|
tracing::info!(address, "forgetting device");
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit connect outranks an earlier refusal.
|
||||||
|
pub fn unforget(&mut self, address: &str) {
|
||||||
|
if self.forgotten.remove(&key(address)) {
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the file, atomically: a half-written `devices.json` would be
|
||||||
|
/// discarded whole on the next launch, and losing the pairings because the
|
||||||
|
/// power went out mid-`write` is exactly the failure this module exists to
|
||||||
|
/// prevent.
|
||||||
|
fn save(&self) {
|
||||||
|
let Some(path) = &self.path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
devices: self.devices.values().cloned().collect(),
|
||||||
|
forgotten: self.forgotten.iter().cloned().collect(),
|
||||||
|
};
|
||||||
|
if let Err(e) = write_atomic(path, &stored) {
|
||||||
|
// Advisory, not fatal: the rider loses auto-connect on the next
|
||||||
|
// launch, never this ride.
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||||
|
let text = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the remembered devices live: beside the recorded rides, in the app's
|
||||||
|
/// own data directory.
|
||||||
|
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||||
|
Ok(dir.join(FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"bikecontrol-known-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir.join(FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pairing_survives_a_reload() {
|
||||||
|
let path = temp();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut known = KnownDevices::load(path.clone());
|
||||||
|
known.remember(
|
||||||
|
"F4:C4:59:03:A1:8E",
|
||||||
|
Some("Zwift Click"),
|
||||||
|
DeviceKind::ClickMinus,
|
||||||
|
);
|
||||||
|
|
||||||
|
let again = KnownDevices::load(path);
|
||||||
|
assert!(again.contains("F4:C4:59:03:A1:8E"));
|
||||||
|
// The same peripheral, as Android spells it.
|
||||||
|
assert!(again.contains("f4:c4:59:03:a1:8e"));
|
||||||
|
assert_eq!(
|
||||||
|
again
|
||||||
|
.first_of(DeviceKind::ClickMinus)
|
||||||
|
.unwrap()
|
||||||
|
.name
|
||||||
|
.as_deref(),
|
||||||
|
Some("Zwift Click")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forgetting_outlives_the_process_too() {
|
||||||
|
// The whole point: "no" has to be remembered as firmly as "yes", or the
|
||||||
|
// next launch connects the neighbour's trainer again.
|
||||||
|
let path = temp().with_extension("forget.json");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut known = KnownDevices::load(path.clone());
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
|
||||||
|
known.forget("aa:bb:cc:dd:ee:ff");
|
||||||
|
|
||||||
|
let again = KnownDevices::load(path);
|
||||||
|
assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(!again.any_of_kind(DeviceKind::Trainer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connecting_again_undoes_a_refusal() {
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.forget("AA:BB:CC:DD:EE:FF");
|
||||||
|
assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
|
||||||
|
assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
|
||||||
|
assert!(known.contains("AA:BB:CC:DD:EE:FF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unidentified_peripheral_is_not_worth_remembering() {
|
||||||
|
let mut known = KnownDevices::default();
|
||||||
|
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
|
||||||
|
assert!(known.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
|
||||||
|
let path = temp().with_extension("corrupt.json");
|
||||||
|
std::fs::write(&path, b"{ this is not json").unwrap();
|
||||||
|
let known = KnownDevices::load(path);
|
||||||
|
assert!(known.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ pub mod derive;
|
|||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod heart_rate;
|
pub mod heart_rate;
|
||||||
|
pub mod known;
|
||||||
|
pub mod settings;
|
||||||
pub mod profile_view;
|
pub mod profile_view;
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
pub mod samples;
|
pub mod samples;
|
||||||
@@ -66,6 +68,7 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// ride lifecycle
|
// ride lifecycle
|
||||||
@@ -96,6 +99,8 @@ pub fn run() {
|
|||||||
commands::set_rider_config,
|
commands::set_rider_config,
|
||||||
commands::safety_limits,
|
commands::safety_limits,
|
||||||
commands::set_safety_limits,
|
commands::set_safety_limits,
|
||||||
|
commands::preferences,
|
||||||
|
commands::set_preferences,
|
||||||
// profiles
|
// profiles
|
||||||
commands::load_profile_from_path,
|
commands::load_profile_from_path,
|
||||||
commands::load_profile_from_text,
|
commands::load_profile_from_text,
|
||||||
@@ -119,6 +124,29 @@ pub fn run() {
|
|||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
// FR-1.5: the hardware the rider paired with last time, before the
|
||||||
|
// first scan pass so the very first thing the scanner sees can be
|
||||||
|
// reconnected rather than merely listed. A missing data directory
|
||||||
|
// costs auto-connect and nothing else, so it is a warning, not a
|
||||||
|
// failed launch.
|
||||||
|
match known::store_path(&handle) {
|
||||||
|
Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"),
|
||||||
|
}
|
||||||
|
// FR-7.4: the rider's mass, bike and drag, before the first tick
|
||||||
|
// reads them. Restored ahead of the ride loop starting so no
|
||||||
|
// snapshot is ever computed against the 105 kg default the struct
|
||||||
|
// falls back to.
|
||||||
|
match settings::store_path(&handle) {
|
||||||
|
Ok(path) => {
|
||||||
|
let state = handle.state::<AppState>();
|
||||||
|
let mut inner = state.lock();
|
||||||
|
let inner = &mut *inner;
|
||||||
|
let (rider, limits) = (&mut inner.inputs.rider, &mut inner.inputs.limits);
|
||||||
|
inner.settings.attach(path, rider, limits);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "rider settings unavailable"),
|
||||||
|
}
|
||||||
// NFR-7: scanning starts immediately, not on a user click.
|
// NFR-7: scanning starts immediately, not on a user click.
|
||||||
handle.state::<AppState>().lock().devices.start_scan();
|
handle.state::<AppState>().lock().devices.start_scan();
|
||||||
state::spawn_ride_loop(handle.clone());
|
state::spawn_ride_loop(handle.clone());
|
||||||
|
|||||||
@@ -333,13 +333,17 @@ fn block_label(block: &Block) -> String {
|
|||||||
amplitude,
|
amplitude,
|
||||||
repeats
|
repeats
|
||||||
),
|
),
|
||||||
|
// No distance in the label. The block already carries `start_x` and
|
||||||
|
// `end_x`, and the frontend renders that span in the rider's own units
|
||||||
|
// (FR-7.5a) — a kilometre baked into the text here would sit inside a
|
||||||
|
// sentence that says miles everywhere else.
|
||||||
Block::Segments { segments } => {
|
Block::Segments { segments } => {
|
||||||
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
|
format!(
|
||||||
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
|
"{} segment{}",
|
||||||
}
|
segments.len(),
|
||||||
Block::Terrain { points } => {
|
if segments.len() == 1 { "" } else { "s" }
|
||||||
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
|
)
|
||||||
format!("terrain · {:.1} km", d / 1000.0)
|
|
||||||
}
|
}
|
||||||
|
Block::Terrain { .. } => "terrain".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Rider setup, remembered across launches (FR-7.4).
|
||||||
|
//!
|
||||||
|
//! Two things were wrong before this module existed, and they were the same
|
||||||
|
//! thing twice.
|
||||||
|
//!
|
||||||
|
//! `RiderConfig` lived only in [`crate::backend::RideInputs`], which meant it
|
||||||
|
//! lived exactly as long as the process — and nothing in the UI ever called
|
||||||
|
//! `set_rider_config`, so in practice every ride was ridden as a **105 kg
|
||||||
|
//! rider on an 8 kg bike**, the struct's own defaults. Mass is not a cosmetic
|
||||||
|
//! setting: it sets the speed a given power produces, the ETA that follows from
|
||||||
|
//! it, the calorie estimate, and how a 6% ramp feels. A 62 kg rider was being
|
||||||
|
//! shown somebody else's ride.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! app_data_dir()/settings.json
|
||||||
|
//! { "version": 1, "rider": {…}, "limits": {…}, "prefs": {…} }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Written whole on every change — it is three small structs, and settings are
|
||||||
|
//! changed by hand at human speed, so this never lands in the ride loop's path.
|
||||||
|
//! Like [`crate::known`] it is *advisory*: an unreadable file costs the rider
|
||||||
|
//! their setup, never their ride, so every failure is logged and swallowed.
|
||||||
|
//!
|
||||||
|
//! ## Why `prefs` is here and not in `RiderConfig`
|
||||||
|
//!
|
||||||
|
//! FTP, maximum heart rate and the unit system change nothing about the
|
||||||
|
//! physics — they decide how a number is *drawn*. `bikecontrol_core::types` is
|
||||||
|
//! the frozen contract the engine and the FIT writer share, and a display
|
||||||
|
//! preference has no business in it. The file keeps them side by side because
|
||||||
|
//! that is where the rider expects to find them; the types stay apart.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bikecontrol_core::types::{RiderConfig, SafetyLimits};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
/// Bumped only if the shape changes incompatibly. An older file with a version
|
||||||
|
/// we do not know is discarded rather than guessed at.
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
const FILE: &str = "settings.json";
|
||||||
|
|
||||||
|
/// Which units the rider reads. Everything is *stored* and *recorded* in SI
|
||||||
|
/// regardless — this is the last conversion before the glass, so a FIT file
|
||||||
|
/// never depends on what the screen was set to.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Units {
|
||||||
|
#[default]
|
||||||
|
Metric,
|
||||||
|
Imperial,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display preferences. Not physics — see the module note.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Preferences {
|
||||||
|
/// Functional threshold power, watts. The reference every power zone is a
|
||||||
|
/// fraction of; zero means the rider has not set one and zones are not
|
||||||
|
/// drawn at all, which is honest — a zone against a guessed FTP is worse
|
||||||
|
/// than no zone.
|
||||||
|
pub ftp_w: u16,
|
||||||
|
/// Maximum heart rate, bpm. Same contract: zero means no HR zones.
|
||||||
|
pub max_hr_bpm: u16,
|
||||||
|
pub units: Units,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Preferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Deliberately no default FTP: an invented threshold would colour
|
||||||
|
// every ride wrong and look authoritative doing it.
|
||||||
|
ftp_w: 0,
|
||||||
|
max_hr_bpm: 0,
|
||||||
|
units: Units::Metric,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file on disk.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
struct Stored {
|
||||||
|
version: u32,
|
||||||
|
rider: RiderConfig,
|
||||||
|
limits: SafetyLimits,
|
||||||
|
prefs: Preferences,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rider's setup, plus where to write it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub prefs: Preferences,
|
||||||
|
/// `None` before [`Settings::attach`] — `AppState::new` runs before there
|
||||||
|
/// is an `AppHandle` to ask for a data directory, so the first moments of
|
||||||
|
/// the process are in-memory only.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Point at the file and read it back over the defaults already in
|
||||||
|
/// `rider` and `limits`.
|
||||||
|
///
|
||||||
|
/// Applied by `&mut` rather than returned because a partial application is
|
||||||
|
/// the one outcome that must not be possible: the rider's mass and the
|
||||||
|
/// safety clamps that bound what can be sent to the trainer come from the
|
||||||
|
/// same file and are adopted in the same breath.
|
||||||
|
pub fn attach(&mut self, path: PathBuf, rider: &mut RiderConfig, limits: &mut SafetyLimits) {
|
||||||
|
self.path = Some(path.clone());
|
||||||
|
let text = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not read settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stored: Stored = match serde_json::from_str(&text) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "settings are unreadable; using defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if stored.version != VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
found = stored.version,
|
||||||
|
expected = VERSION,
|
||||||
|
"settings are from another version; using defaults"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A stored file that fails the same checks the commands apply is not
|
||||||
|
// trusted just because it is on disk — it may predate a tightened
|
||||||
|
// bound, or have been edited by hand.
|
||||||
|
if validate_rider(&stored.rider).is_err() || validate_limits(&stored.limits).is_err() {
|
||||||
|
tracing::warn!("stored settings are out of range; using defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*rider = stored.rider;
|
||||||
|
*limits = stored.limits;
|
||||||
|
self.prefs = stored.prefs;
|
||||||
|
tracing::info!(rider_kg = stored.rider.rider_kg, "settings restored");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the file, atomically. A half-written `settings.json` is discarded
|
||||||
|
/// whole on the next launch, which would silently put the rider back on a
|
||||||
|
/// 105 kg default — the exact failure this module exists to prevent.
|
||||||
|
pub fn save(&self, rider: &RiderConfig, limits: &SafetyLimits) {
|
||||||
|
let Some(path) = &self.path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
rider: *rider,
|
||||||
|
limits: *limits,
|
||||||
|
prefs: self.prefs,
|
||||||
|
};
|
||||||
|
if let Err(e) = write_atomic(path, &stored) {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "could not save settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds worth refusing, with the reason a rider can act on.
|
||||||
|
///
|
||||||
|
/// These are not taste. Each one is a value that makes the ride engine produce
|
||||||
|
/// nonsense rather than merely something unusual — a zero mass divides, a zero
|
||||||
|
/// wheel circumference divides, an efficiency above 1 invents power.
|
||||||
|
pub fn validate_rider(c: &RiderConfig) -> Result<(), String> {
|
||||||
|
let check = |ok: bool, msg: &str| if ok { Ok(()) } else { Err(msg.to_string()) };
|
||||||
|
check(
|
||||||
|
(20.0..=250.0).contains(&c.rider_kg),
|
||||||
|
"Rider mass must be between 20 and 250 kg.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(1.0..=50.0).contains(&c.bike_kg),
|
||||||
|
"Bike mass must be between 1 and 50 kg.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.0005..=0.05).contains(&c.crr),
|
||||||
|
"Rolling resistance is typically 0.002–0.010 for road tyres.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.1..=1.5).contains(&c.cda),
|
||||||
|
"CdA must be between 0.1 and 1.5 m² — a road position is about 0.32.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=1.0).contains(&c.drivetrain_efficiency),
|
||||||
|
"Drivetrain efficiency is a fraction between 0.5 and 1.0 — about 0.97 for a clean chain.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=1.6).contains(&c.air_density),
|
||||||
|
"Air density must be between 0.5 and 1.6 kg/m³ — sea level is 1.225.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=3.5).contains(&c.wheel_circumference_m),
|
||||||
|
"Wheel circumference must be between 0.5 and 3.5 m — a 700×25 is about 2.1.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.1..=0.25).contains(&c.crank_length_m),
|
||||||
|
"Crank length must be between 0.10 and 0.25 m — road cranks are 0.170–0.175.",
|
||||||
|
)?;
|
||||||
|
check(
|
||||||
|
(0.5..=20.0).contains(&c.physical_development_m),
|
||||||
|
"Physical development must be between 0.5 and 20 m per crank revolution.",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_limits(l: &SafetyLimits) -> Result<(), String> {
|
||||||
|
if l.min_gradient_pct >= l.max_gradient_pct {
|
||||||
|
return Err("Gradient limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.min_resistance >= l.max_resistance {
|
||||||
|
return Err("Resistance limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.min_power_w >= l.max_power_w {
|
||||||
|
return Err("Power limits are inverted.".into());
|
||||||
|
}
|
||||||
|
if l.max_power_w > 2000 {
|
||||||
|
return Err("Maximum power above 2000 W is not a limit, it is a hazard.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_prefs(p: &Preferences) -> Result<(), String> {
|
||||||
|
// Zero is the "not set" case for both, and must stay reachable: a rider who
|
||||||
|
// does not know their FTP is better served by no zones than by a guess.
|
||||||
|
if p.ftp_w != 0 && !(50..=600).contains(&p.ftp_w) {
|
||||||
|
return Err("FTP must be between 50 and 600 W, or 0 for no zones.".into());
|
||||||
|
}
|
||||||
|
if p.max_hr_bpm != 0 && !(100..=230).contains(&p.max_hr_bpm) {
|
||||||
|
return Err("Maximum heart rate must be between 100 and 230 bpm, or 0 for no zones.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
|
||||||
|
let text = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beside the remembered devices and the recorded rides.
|
||||||
|
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("no app data directory: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||||
|
Ok(dir.join(FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp(name: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("bikecontrol-settings-test-{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rider_survives_the_process() {
|
||||||
|
let path = temp("roundtrip");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||||
|
rider.rider_kg = 62.0;
|
||||||
|
settings.prefs.ftp_w = 240;
|
||||||
|
settings.save(&rider, &limits);
|
||||||
|
|
||||||
|
let mut again = Settings::default();
|
||||||
|
let mut rider2 = RiderConfig::default();
|
||||||
|
let mut limits2 = SafetyLimits::default();
|
||||||
|
again.attach(path.clone(), &mut rider2, &mut limits2);
|
||||||
|
assert_eq!(rider2.rider_kg, 62.0);
|
||||||
|
assert_eq!(again.prefs.ftp_w, 240);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_leaves_the_defaults_alone() {
|
||||||
|
let path = temp("missing");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path, &mut rider, &mut limits);
|
||||||
|
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||||
|
assert_eq!(settings.prefs, Preferences::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file someone edited by hand must not be able to put a zero mass into
|
||||||
|
/// the engine, which would divide by it on the next tick.
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_file_is_refused_whole() {
|
||||||
|
let path = temp("nonsense");
|
||||||
|
let stored = Stored {
|
||||||
|
version: VERSION,
|
||||||
|
rider: RiderConfig {
|
||||||
|
rider_kg: 0.0,
|
||||||
|
..RiderConfig::default()
|
||||||
|
},
|
||||||
|
limits: SafetyLimits::default(),
|
||||||
|
prefs: Preferences {
|
||||||
|
ftp_w: 300,
|
||||||
|
..Preferences::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_atomic(&path, &stored).unwrap();
|
||||||
|
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut rider = RiderConfig::default();
|
||||||
|
let mut limits = SafetyLimits::default();
|
||||||
|
settings.attach(path.clone(), &mut rider, &mut limits);
|
||||||
|
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
|
||||||
|
// Refused whole: the preferences in the same file do not sneak through.
|
||||||
|
assert_eq!(settings.prefs.ftp_w, 0);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_means_no_zones_rather_than_an_invalid_ftp() {
|
||||||
|
assert!(validate_prefs(&Preferences::default()).is_ok());
|
||||||
|
assert!(validate_prefs(&Preferences {
|
||||||
|
ftp_w: 20,
|
||||||
|
..Preferences::default()
|
||||||
|
})
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ pub struct Inner {
|
|||||||
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
||||||
/// the webview asks for them.
|
/// the webview asks for them.
|
||||||
pub recovered: Vec<Recovered>,
|
pub recovered: Vec<Recovered>,
|
||||||
|
/// The rider's own setup, and where it is written down (FR-7.4).
|
||||||
|
pub settings: crate::settings::Settings,
|
||||||
lap_start_ms: u64,
|
lap_start_ms: u64,
|
||||||
lap_start_m: f64,
|
lap_start_m: f64,
|
||||||
lap_power_sum: f64,
|
lap_power_sum: f64,
|
||||||
@@ -104,6 +106,7 @@ impl Inner {
|
|||||||
laps: Vec::new(),
|
laps: Vec::new(),
|
||||||
last_summary: None,
|
last_summary: None,
|
||||||
recovered: Vec::new(),
|
recovered: Vec::new(),
|
||||||
|
settings: crate::settings::Settings::default(),
|
||||||
lap_start_ms: 0,
|
lap_start_ms: 0,
|
||||||
lap_start_m: 0.0,
|
lap_start_m: 0.0,
|
||||||
lap_power_sum: 0.0,
|
lap_power_sum: 0.0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "BikeControl",
|
"productName": "BikeControl",
|
||||||
"version": "0.1.0",
|
"version": "0.2.6",
|
||||||
"identifier": "paris.tourolle.bikecontrol",
|
"identifier": "paris.tourolle.bikecontrol",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui/dist",
|
"frontendDist": "../ui/dist",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"android": {
|
"android": {
|
||||||
"versionCode": 1100
|
"versionCode": 1206
|
||||||
},
|
},
|
||||||
"shortDescription": "Indoor cycling trainer control",
|
"shortDescription": "Indoor cycling trainer control",
|
||||||
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import HelpOverlay from './components/HelpOverlay.svelte';
|
import HelpOverlay from './components/HelpOverlay.svelte';
|
||||||
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
||||||
import RideScreen from './components/RideScreen.svelte';
|
import RideScreen from './components/RideScreen.svelte';
|
||||||
|
import SettingsScreen from './components/SettingsScreen.svelte';
|
||||||
import SummaryScreen from './components/SummaryScreen.svelte';
|
import SummaryScreen from './components/SummaryScreen.svelte';
|
||||||
import Toasts from './components/Toasts.svelte';
|
import Toasts from './components/Toasts.svelte';
|
||||||
|
|
||||||
@@ -39,6 +40,23 @@
|
|||||||
app.run(fn);
|
app.run(fn);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup is reachable from anywhere, on the key every other application
|
||||||
|
// uses for it, and returns to whichever screen it was opened from.
|
||||||
|
if (e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
app.openSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// On setup, the ride controls are inert — a stray arrow key while reading
|
||||||
|
// the form must not trim the gradient of a ride happening behind it.
|
||||||
|
if (app.screen === 'settings') {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
app.closeSettings();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The summary screen has its own two keys, and swallows the ride controls:
|
// The summary screen has its own two keys, and swallows the ride controls:
|
||||||
// nudging the gradient of a ride that has ended is meaningless, and space
|
// nudging the gradient of a ride that has ended is meaningless, and space
|
||||||
// would silently start a new one out from under the summary.
|
// would silently start a new one out from under the summary.
|
||||||
@@ -151,6 +169,13 @@
|
|||||||
if (!input.pressed) return;
|
if (!input.pressed) return;
|
||||||
const run = (fn: () => Promise<unknown>) => app.run(fn);
|
const run = (fn: () => Promise<unknown>) => app.run(fn);
|
||||||
|
|
||||||
|
// Same rule as the keyboard: setup swallows the ride controls, and the
|
||||||
|
// pod's left button is the way back out of it.
|
||||||
|
if (app.screen === 'settings') {
|
||||||
|
if (input.button === 'left') app.closeSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// As with the keyboard: on the summary the ride controls are inert, and the
|
// As with the keyboard: on the summary the ride controls are inert, and the
|
||||||
// face buttons carry that screen's own two actions instead. Leaving `a` on
|
// face buttons carry that screen's own two actions instead. Leaving `a` on
|
||||||
// toggle-pause here would restart the ride the rider just finished.
|
// toggle-pause here would restart the ride the rider just finished.
|
||||||
@@ -211,6 +236,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if !ready}
|
{:else if !ready}
|
||||||
<div class="boot"><span class="label">Starting…</span></div>
|
<div class="boot"><span class="label">Starting…</span></div>
|
||||||
|
{:else if app.screen === 'settings'}
|
||||||
|
<SettingsScreen />
|
||||||
{:else if app.screen === 'summary'}
|
{:else if app.screen === 'summary'}
|
||||||
<SummaryScreen />
|
<SummaryScreen />
|
||||||
{:else if app.screen === 'ride'}
|
{:else if app.screen === 'ride'}
|
||||||
|
|||||||
@@ -39,6 +39,25 @@
|
|||||||
--power: #dfe8f3;
|
--power: #dfe8f3;
|
||||||
--power-raw: #3f4d5d;
|
--power-raw: #3f4d5d;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Effort zones (see `powerZone` in lib/format.ts). An ordered ramp, not a
|
||||||
|
* categorical palette: cool and quiet at the bottom, hot and loud at the top,
|
||||||
|
* so intensity reads from the colour before the number is even focused on.
|
||||||
|
* Four of the seven are the tones this stylesheet already uses for ok / warn
|
||||||
|
* / climb / bad, which keeps one vocabulary on the screen rather than two.
|
||||||
|
*
|
||||||
|
* Colour never carries the meaning alone — every zoned readout renders "Z4"
|
||||||
|
* beside the swatch. That is what makes this safe for the ~8% of male riders
|
||||||
|
* with a colour vision deficiency, and for a phone in direct sun.
|
||||||
|
*/
|
||||||
|
--zone-1: #7b8b9c;
|
||||||
|
--zone-2: #4aa8ff;
|
||||||
|
--zone-3: #35d9a0;
|
||||||
|
--zone-4: #ffcf4a;
|
||||||
|
--zone-5: #ff9a3c;
|
||||||
|
--zone-6: #ff5a52;
|
||||||
|
--zone-7: #c07cff;
|
||||||
|
|
||||||
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
||||||
--gap: 24px;
|
--gap: 24px;
|
||||||
--edge: 44px;
|
--edge: 44px;
|
||||||
|
|||||||
+223
-232
@@ -1,27 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* The Zwift Click, as two pods (FR-1.4, FR-9.1–9.2).
|
* The Zwift Click (FR-1.4, FR-9.1–9.2).
|
||||||
*
|
*
|
||||||
* A Click v2 is **two peripherals**, and until now the app showed one line
|
* A Click v2 is two peripherals, but it is **one controller**: connected on
|
||||||
* for both: connect, and you got whichever pod answered first, with no way to
|
* its own, the `−` pod delivers all ten buttons — its own paddle and D-pad,
|
||||||
* tell which one that was or that the other was missing entirely. Each pod
|
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
|
||||||
* now has a card of its own — its own state, battery, address and proof that
|
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
|
||||||
* its buttons arrive.
|
* this panel says so rather than presenting two halves that both look
|
||||||
|
* required. Pairing both is not merely redundant: it is the configuration in
|
||||||
|
* which the `−` pod stops reporting its own paddle.
|
||||||
|
*
|
||||||
|
* The `+` pod keeps a row of its own, because it is the fallback that matters
|
||||||
|
* when the `−` pod is flat or left in the garage.
|
||||||
*
|
*
|
||||||
* They are named for the shift paddle each carries, not for the side of the
|
* They are named for the shift paddle each carries, not for the side of the
|
||||||
* bar. Nothing a pod advertises says which end of the handlebar it is
|
* bar. Nothing a pod advertises says which end of the handlebar it is clamped
|
||||||
* clamped to, so left and right would be a guess; the paddle is printed on
|
* to, so left and right would be a guess; the paddle is printed on the pod,
|
||||||
* the pod, and pressing it settles the question on screen (`confirmed`).
|
* and pressing it settles the question on screen (`confirmed`).
|
||||||
*
|
*
|
||||||
* The second job of this panel is to say what to *do* when a pod is missing.
|
* The second job of this panel is to say what to *do* when nothing is
|
||||||
* A Click sleeps within seconds and only advertises while awake (A-4), which
|
* connected — a Click sleeps within seconds and only advertises while awake
|
||||||
* no rider can guess from the words "not connected" — and which is also why
|
* (A-4), which no rider can guess from the words "not connected". But that is
|
||||||
* connecting is not a button they have to win a race with: the running scan
|
* the only thing worth a sentence, and only while it is true: connected, this
|
||||||
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
|
* panel is two lines and a badge. The five-step drill it used to open with
|
||||||
* here are for overriding that, not for driving it.
|
* lives behind a summary now, and the pods' MAC addresses are gone — they
|
||||||
|
* identified nothing a rider could act on.
|
||||||
*/
|
*/
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
|
import { api, type Pod, type PodState } from '../lib/bridge';
|
||||||
|
|
||||||
const controller = $derived(app.controller);
|
const controller = $derived(app.controller);
|
||||||
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
|
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
|
||||||
@@ -29,7 +35,12 @@
|
|||||||
* pod stays missing — and the fix belongs next to the symptom. */
|
* pod stays missing — and the fix belongs next to the symptom. */
|
||||||
const scanning = $derived(app.devices.scanning);
|
const scanning = $derived(app.devices.scanning);
|
||||||
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
||||||
const bothConnected = $derived(pods.length === 2 && pods.every((p) => p.state === 'connected'));
|
/** The 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'));
|
const busy = $derived(pods.some((p) => p.state === 'searching'));
|
||||||
/** A pod reporting the other's paddle: the pair may be filed the wrong way
|
/** A pod reporting the other's paddle: the pair may be filed the wrong way
|
||||||
* round, and the rider is the only one who can say. */
|
* round, and the rider is the only one who can say. */
|
||||||
@@ -53,8 +64,8 @@
|
|||||||
|
|
||||||
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
||||||
const PURPOSE: Record<Pod, string> = {
|
const PURPOSE: Record<Pod, string> = {
|
||||||
minus: 'Shift down · D-pad',
|
|
||||||
plus: 'Shift up · A B Y Z',
|
plus: 'Shift up · A B Y Z',
|
||||||
|
minus: 'All ten buttons, for ~50 s',
|
||||||
};
|
};
|
||||||
|
|
||||||
function connect(pod: Pod) {
|
function connect(pod: Pod) {
|
||||||
@@ -69,109 +80,101 @@
|
|||||||
<section class="click">
|
<section class="click">
|
||||||
<header>
|
<header>
|
||||||
<h2>Zwift Click</h2>
|
<h2>Zwift Click</h2>
|
||||||
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
|
|
||||||
{#if bothConnected}
|
|
||||||
Both pods connected
|
|
||||||
{:else if anyConnected}
|
|
||||||
One pod of two
|
|
||||||
{:else}
|
|
||||||
No pods connected
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if !scanning}
|
{#if !scanning}
|
||||||
<!-- Nothing can be picked up automatically while the scan is off, so
|
<!-- Nothing can be picked up automatically while the scan is off, so
|
||||||
the way to fix that sits here rather than only in the header. -->
|
the way to fix that sits here rather than only in the header. -->
|
||||||
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
||||||
{:else if !bothConnected}
|
{:else if !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())}>
|
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
|
||||||
{busy ? 'Searching…' : 'Connect now'}
|
{busy ? 'Searching…' : 'Find + pod'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if anyConnected}
|
{#if anyConnected}
|
||||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||||
Disconnect both
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="lede">
|
<!-- Only while it is telling the rider something they cannot see. Connected,
|
||||||
|
the badge above has already said it. -->
|
||||||
{#if !scanning}
|
{#if !scanning}
|
||||||
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
|
<p class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p>
|
||||||
on each pod.
|
{:else if minusOnly}
|
||||||
{:else if bothConnected}
|
<p class="lede">
|
||||||
Both pods are connected and will reconnect on their own if one drops.
|
Running on the <strong>− pod</strong>, which relays all ten buttons but stops reporting
|
||||||
{:else}
|
after about a minute unless Zwift has blessed it today. Press a button on the
|
||||||
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
|
<strong>+ pod</strong> for shifting that lasts.
|
||||||
running scan connects it as soon as it does — no need to press anything here.
|
|
||||||
{/if}
|
|
||||||
</p>
|
</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">
|
<div class="pods">
|
||||||
{#each pods as pod (pod.pod)}
|
{#each pods as pod (pod.pod)}
|
||||||
|
{@const dormant = pod.pod === 'minus' && plusLive && pod.state !== 'connected'}
|
||||||
<article class="pod" class:live={pod.state === 'connected'}>
|
<article class="pod" class:live={pod.state === 'connected'}>
|
||||||
<div class="title">
|
<!-- The paddle glyph is the pod's identity — big enough to match
|
||||||
|
against the one printed on the hardware at arm's length. -->
|
||||||
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
|
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
|
||||||
<span class="what">
|
|
||||||
<span class="label">{pod.symbol} pod</span>
|
<div class="what">
|
||||||
<span class="purpose">{PURPOSE[pod.pod]}</span>
|
<span class="pod-name">{pod.symbol} pod</span>
|
||||||
</span>
|
<span class="purpose">
|
||||||
<span class="state {STATE_TONE[pod.state]}">
|
{#if dormant}
|
||||||
<span class="dot"></span>{STATE_TEXT[pod.state]}
|
<!-- Not a fault, and the panel must not let it read as one: this
|
||||||
|
pod is idle because 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="facts">
|
<span class="facts">
|
||||||
<div>
|
{#if pod.batteryPercent != null}<span>{pod.batteryPercent}%</span>{/if}
|
||||||
<dt>Battery</dt>
|
|
||||||
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Buttons seen</dt>
|
|
||||||
<!-- Connected and silent looks exactly like working until you press
|
<!-- Connected and silent looks exactly like working until you press
|
||||||
something, so the count is the honest test of the link. -->
|
something, so the count is the honest test of the link. -->
|
||||||
<dd>
|
{#if pod.state === 'connected'}
|
||||||
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`}
|
<span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
|
||||||
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Address</dt>
|
|
||||||
<dd class="addr">{pod.address ?? '—'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{#if pod.confirmed}
|
|
||||||
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
|
|
||||||
{:else if pod.state === 'connected'}
|
|
||||||
<p class="note">
|
|
||||||
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
{#if pod.contradicted}
|
<span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
|
||||||
<p class="note tone-warn">
|
<span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
|
||||||
This pod sent the other paddle. If the pair is the wrong way round, swap them.
|
</span>
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pod.error}
|
|
||||||
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
|
|
||||||
<p class="note tone-bad">{pod.error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
|
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
|
||||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
|
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
|
||||||
{:else if pod.state === 'searching'}
|
{:else if pod.state === 'searching'}
|
||||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
|
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button>
|
||||||
|
{:else 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}
|
{:else}
|
||||||
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
<button class="btn ghost" onclick={() => connect(pod.pod)}>Find it</button>
|
||||||
Look for it now
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
|
||||||
|
{#if pod.error}<p class="note tone-bad">{pod.error}</p>{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -183,11 +186,9 @@
|
|||||||
{#if mixedUp || controller?.swapped}
|
{#if mixedUp || controller?.swapped}
|
||||||
<div class="swap">
|
<div class="swap">
|
||||||
<span>
|
<span>
|
||||||
{#if mixedUp}
|
{mixedUp
|
||||||
A pod is sending the other pod's paddle — the pair may be filed the wrong way round.
|
? 'A pod is sending the other pod’s paddle — the pair may be filed the wrong way round.'
|
||||||
{:else}
|
: 'The pods are swapped from what their advertisement claims.'}
|
||||||
The pods are swapped from what their advertisement claims.
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
|
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
|
||||||
Swap + / −
|
Swap + / −
|
||||||
@@ -195,82 +196,65 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !bothConnected}
|
{#if !plusLive}
|
||||||
<!--
|
<!--
|
||||||
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
|
FR-1.8. "Not connected" on its own reads as a broken app, and the real
|
||||||
real causes — a sleeping pod and a lapsed unlock — are things only the
|
cause — a pod that is simply asleep — is something only the rider can fix.
|
||||||
rider can fix, so they are spelled out here rather than left to be
|
The lede above says that much; the rest is for the rider it did not work
|
||||||
guessed at.
|
for, and stays folded until they ask, because it is five paragraphs of
|
||||||
|
things that are usually already true.
|
||||||
-->
|
-->
|
||||||
<details class="help" open={!anyConnected}>
|
<details class="help">
|
||||||
<summary>A pod will not connect — what to try</summary>
|
<summary>Still not connecting?</summary>
|
||||||
<ol>
|
<ul>
|
||||||
|
<li><strong>Press a button 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>
|
<li>
|
||||||
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
|
<strong>Or use the − pod</strong> — it relays all ten buttons, but goes quiet after
|
||||||
A Click sleeps within seconds and only advertises while awake, so a pod that is not
|
about a minute unless Zwift has blessed it in the last day.
|
||||||
broadcasting is the normal case, not a fault. The scan below is running: press a
|
|
||||||
button and the pod connects itself, usually within a second or two.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a
|
The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
|
||||||
stopped scan means nothing gets picked up. Restart it above.
|
never depends on a pod.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
</ul>
|
||||||
<strong>Close anything else holding the pod.</strong> One app at a time — Zwift left
|
|
||||||
running in the background keeps the link, and this app will never see the pod.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Bring it closer, or charge it.</strong> A flat pod stops advertising
|
|
||||||
altogether, and after about thirty failed attempts the app stops chasing it and says
|
|
||||||
so on the card.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p class="fallback">
|
|
||||||
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
|
|
||||||
<span class="kbd">−</span> shift, <span class="kbd">↑</span>
|
|
||||||
<span class="kbd">↓</span> trim the gradient. Press
|
|
||||||
<span class="kbd">?</span> for the full list. A ride never depends on a pod.
|
|
||||||
</p>
|
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.click {
|
.click {
|
||||||
margin: 0 var(--edge) 0.8rem;
|
margin: 0 var(--edge) 0.7rem;
|
||||||
padding: 0.9rem 1rem 1rem;
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
background: var(--bg-lift);
|
background: var(--bg-lift);
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.05rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
.lede {
|
||||||
margin: 0.55rem 0 0;
|
margin: 0.45rem 0 0;
|
||||||
font-size: 0.86rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lede strong {
|
.lede strong {
|
||||||
color: var(--ink);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -279,43 +263,44 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pods {
|
.actions .btn {
|
||||||
display: grid;
|
padding: 0.45em 0.8em;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
font-size: 0.85rem;
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod {
|
.pods {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.3rem;
|
||||||
padding: 0.8rem 0.9rem;
|
margin-top: 0.6rem;
|
||||||
border-radius: 0.55rem;
|
}
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
|
/* A row, not a card: two pods stacked read as one controller with a fallback,
|
||||||
|
which is what they are. Two equal cards read as two things to pair. */
|
||||||
|
.pod {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 0.7rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod.live {
|
.pod.live {
|
||||||
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
|
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The paddle glyph is the pod's identity — big enough to match against the
|
|
||||||
one printed on the hardware at arm's length. */
|
|
||||||
.paddle {
|
.paddle {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 2.1rem;
|
width: 1.9rem;
|
||||||
height: 2.1rem;
|
height: 1.9rem;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: var(--hairline);
|
background: var(--hairline);
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 1.3rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -329,96 +314,78 @@
|
|||||||
.what {
|
.what {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1 1 10rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
/* Not `.label`: that is app.css's uppercase, letter-spaced section label,
|
||||||
font-size: 1rem;
|
and a pod's name is a name, not a heading. */
|
||||||
|
.pod-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purpose {
|
.purpose {
|
||||||
font-size: 0.78rem;
|
font-size: 0.76rem;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
margin-left: auto;
|
font-size: 0.84rem;
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.facts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.facts div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last {
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.addr {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note strong {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-top: auto;
|
margin-left: auto;
|
||||||
padding-top: 0.15rem;
|
}
|
||||||
|
|
||||||
|
.controls .btn {
|
||||||
|
padding: 0.4em 0.7em;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.swap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-top: 0.6rem;
|
flex-wrap: wrap;
|
||||||
padding: 0.55rem 0.75rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: rgba(255, 207, 74, 0.07);
|
background: rgba(255, 207, 74, 0.07);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 0.85rem;
|
font-size: 0.83rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap button {
|
.swap button {
|
||||||
@@ -426,34 +393,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.55rem;
|
||||||
font-size: 0.86rem;
|
font-size: 0.83rem;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-dim);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help ol {
|
/* `display: flex` on a summary drops the browser's own marker, and a
|
||||||
margin: 0.5rem 0 0;
|
disclosure with nothing to disclose-looking about it does not read as one. */
|
||||||
padding-left: 1.2rem;
|
summary::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help ul {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help li {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help strong {
|
.help strong {
|
||||||
color: var(--ink);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback {
|
/* Narrow: the state and the buttons drop below the pod's name rather than
|
||||||
margin: 0.5rem 0 0;
|
crushing it. */
|
||||||
color: var(--ink-dim);
|
:global([data-size='compact']) .facts,
|
||||||
line-height: 1.6;
|
:global([data-size='compact']) .state {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-size='compact']) .controls {
|
||||||
|
order: 4;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin-left: 2.3rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
|||||||
['L', 'Insert lap marker'],
|
['L', 'Insert lap marker'],
|
||||||
['P', 'Profiles and routes'],
|
['P', 'Profiles and routes'],
|
||||||
['D', 'Device / connection screen'],
|
['D', 'Device / connection screen'],
|
||||||
|
[',', 'Rider setup — weight, FTP, units'],
|
||||||
['R', 'Ride screen'],
|
['R', 'Ride screen'],
|
||||||
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
||||||
['S', 'Save the FIT file (summary screen)'],
|
['S', 'Save the FIT file (summary screen)'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import { axisValue } from '../lib/format';
|
import { axisValue, dist, distUnit, elev, elevUnit } from '../lib/format';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
let yaml = $state('');
|
let yaml = $state('');
|
||||||
@@ -136,9 +136,11 @@
|
|||||||
<span class="label">Loaded</span>
|
<span class="label">Loaded</span>
|
||||||
<strong>{loaded.name}</strong>
|
<strong>{loaded.name}</strong>
|
||||||
<span class="sample-sub">
|
<span class="sample-sub">
|
||||||
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
|
{loaded.totalMetres ? `${dist(loaded.totalMetres, app.units, 1)} ${distUnit(app.units)}` : ''}
|
||||||
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
||||||
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
{loaded.totalAscentM != null
|
||||||
|
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} up`
|
||||||
|
: ''}
|
||||||
{loaded.looping ? '· loops' : ''}
|
{loaded.looping ? '· loops' : ''}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
<em>{b.kind}</em>
|
<em>{b.kind}</em>
|
||||||
{b.label}
|
{b.label}
|
||||||
<span class="quiet">
|
<span class="quiet">
|
||||||
{axisValue(b.unit, b.endX - b.startX)}
|
{axisValue(b.unit, b.endX - b.startX, app.units)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
sub?: string | null;
|
sub?: string | null;
|
||||||
dim?: boolean;
|
dim?: boolean;
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
|
/**
|
||||||
|
* A short tag beside the label — the effort zone, in practice.
|
||||||
|
*
|
||||||
|
* It sits next to the *label* rather than the number so the number keeps
|
||||||
|
* its full weight, and it is a word rather than a colour alone: `Z4` still
|
||||||
|
* reads on a phone in the sun and to a rider who cannot separate the amber
|
||||||
|
* from the green (see the zone note in app.css).
|
||||||
|
*/
|
||||||
|
badge?: { label: string; colour: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,11 +33,15 @@
|
|||||||
sub = null,
|
sub = null,
|
||||||
dim = false,
|
dim = false,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
badge = null,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||||
|
<span class="head">
|
||||||
<span class="label">{label}</span>
|
<span class="label">{label}</span>
|
||||||
|
{#if badge}<span class="badge" style:color={badge.colour}>{badge.label}</span>{/if}
|
||||||
|
</span>
|
||||||
<span class="value" style:color={colour}>
|
<span class="value" style:color={colour}>
|
||||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,6 +56,21 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: var(--type-label);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
/* No pill: a box around a two-character tag is more furniture than the tag
|
||||||
|
is worth, and this stylesheet earns its structure from space. */
|
||||||
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
|
|||||||
@@ -11,12 +11,19 @@
|
|||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import {
|
import {
|
||||||
clock,
|
clock,
|
||||||
|
dist,
|
||||||
|
distUnit,
|
||||||
duration,
|
duration,
|
||||||
|
elev,
|
||||||
|
elevUnit,
|
||||||
finishAt,
|
finishAt,
|
||||||
km,
|
hrZone,
|
||||||
MODE_LABEL,
|
MODE_LABEL,
|
||||||
num,
|
num,
|
||||||
|
powerZone,
|
||||||
signed,
|
signed,
|
||||||
|
speed,
|
||||||
|
speedUnit,
|
||||||
targetText,
|
targetText,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
import { viewport } from '../lib/viewport.svelte';
|
import { viewport } from '../lib/viewport.svelte';
|
||||||
@@ -33,6 +40,9 @@
|
|||||||
const show = $derived(viewport.plan.show);
|
const show = $derived(viewport.plan.show);
|
||||||
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
||||||
|
|
||||||
|
const units = $derived(app.units);
|
||||||
|
const prefs = $derived(app.prefs);
|
||||||
|
|
||||||
const frame = $derived(app.frame);
|
const frame = $derived(app.frame);
|
||||||
const snap = $derived(frame?.snapshot ?? null);
|
const snap = $derived(frame?.snapshot ?? null);
|
||||||
const d = $derived(frame?.derived ?? null);
|
const d = $derived(frame?.derived ?? null);
|
||||||
@@ -81,13 +91,20 @@
|
|||||||
const podChip = $derived.by(() => {
|
const podChip = $derived.by(() => {
|
||||||
const c = app.controller;
|
const c = app.controller;
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
|
// The − pod relays its twin, so a `+` pod that is not connected while the
|
||||||
if (missing.length === 0) return null;
|
// `−` pod is up is the *intended* configuration, not a missing device —
|
||||||
|
// and this chip was reporting it as one, contradicting the device screen.
|
||||||
|
if (c.minus.state === 'connected') return null;
|
||||||
|
const missing = c.plus.state === 'connected' ? [c.minus] : [c.minus, c.plus];
|
||||||
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
||||||
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
||||||
return {
|
return {
|
||||||
tone: searching ? 'tone-warn' : 'tone-idle',
|
tone: searching ? 'tone-warn' : 'tone-idle',
|
||||||
label: searching ? `Looking for the ${names}…` : `No ${names}`,
|
label: searching
|
||||||
|
? `Looking for the ${names}…`
|
||||||
|
: c.plus.state === 'connected'
|
||||||
|
? '+ pod only — no D-pad'
|
||||||
|
: `No ${names}`,
|
||||||
// The keyboard is always the fallback, which is what keeps a missing pod
|
// The keyboard is always the fallback, which is what keeps a missing pod
|
||||||
// an annoyance rather than the end of the ride.
|
// an annoyance rather than the end of the ride.
|
||||||
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
||||||
@@ -190,10 +207,10 @@
|
|||||||
*/
|
*/
|
||||||
const speedSub = $derived.by(() => {
|
const speedSub = $derived.by(() => {
|
||||||
if (speedFault) return speedFault;
|
if (speedFault) return speedFault;
|
||||||
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
|
const now = `now ${speed(snap?.virtual_speed_kph ?? 0, units)}`;
|
||||||
const trainer = snap?.telemetry.speed_kph;
|
const trainer = snap?.telemetry.speed_kph;
|
||||||
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
||||||
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
|
return (trainer != null ? `${now} · trainer ${speed(trainer, units)}` : now) + coasting;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusChip = $derived.by(() => {
|
const statusChip = $derived.by(() => {
|
||||||
@@ -220,13 +237,66 @@
|
|||||||
const routeSummary = $derived.by(() => {
|
const routeSummary = $derived.by(() => {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const bits: string[] = [];
|
const bits: string[] = [];
|
||||||
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
|
if (profile.totalMetres) bits.push(`${dist(profile.totalMetres, units, 1)} ${distUnit(units)}`);
|
||||||
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
||||||
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
|
if (profile.totalAscentM != null) {
|
||||||
|
bits.push(`${elev(profile.totalAscentM, units)} ${elevUnit(units)} up`);
|
||||||
|
}
|
||||||
if (profile.looping) bits.push('loops');
|
if (profile.looping) bits.push('loops');
|
||||||
return bits.join(' · ');
|
return bits.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effort as a zone, not just a number (see `powerZone` in lib/format.ts).
|
||||||
|
*
|
||||||
|
* Read off the *rolling* average rather than the instantaneous watts: at 4 Hz
|
||||||
|
* the raw figure crosses two zone boundaries every pedal stroke, and a
|
||||||
|
* colour that strobes is worse than no colour. `null` whenever the rider has
|
||||||
|
* not set an FTP — the readout then draws exactly as it always did.
|
||||||
|
*/
|
||||||
|
const pZone = $derived(powerZone(d?.rollingPowerW, prefs.ftpW));
|
||||||
|
const hZone = $derived(hrZone(snap?.telemetry.heart_rate_bpm, prefs.maxHrBpm));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gradient, as a shape.
|
||||||
|
*
|
||||||
|
* A signed decimal has to be read; a wedge is seen. The angle is exaggerated
|
||||||
|
* — real road gradients are almost flat at true scale, and 6% drawn honestly
|
||||||
|
* is indistinguishable from 3% at 40 px wide — and clamped so a profile with
|
||||||
|
* a 30% wall cannot draw a vertical cliff.
|
||||||
|
*/
|
||||||
|
const wedge = $derived.by(() => {
|
||||||
|
const clamped = Math.max(-15, Math.min(15, gradient));
|
||||||
|
// 22 px of rise across 38 px of run at the clamp, which reads as a
|
||||||
|
// recognisable hill without leaving the line box.
|
||||||
|
const rise = (clamped / 15) * 11;
|
||||||
|
return { y1: 12 + rise, y2: 12 - rise };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is coming, from the profile's own block list.
|
||||||
|
*
|
||||||
|
* The route chart says where the rider *is*; every professional app also says
|
||||||
|
* what is about to happen, because that is what decides whether to shift now
|
||||||
|
* or hold on. The data was already computed Rust-side and thrown away here.
|
||||||
|
*/
|
||||||
|
const nextUp = $derived.by(() => {
|
||||||
|
if (!profile || !d) return null;
|
||||||
|
const at = d.positionX;
|
||||||
|
const block = profile.blocks.find((b) => b.startX > at);
|
||||||
|
if (!block) return null;
|
||||||
|
const away = block.startX - at;
|
||||||
|
// Close in, the small unit reads better than a fraction of the big one:
|
||||||
|
// "in 460 m" is a distance a rider can feel, "in 0.46 km" is arithmetic.
|
||||||
|
const inWords =
|
||||||
|
block.unit !== 'metres'
|
||||||
|
? duration(away)
|
||||||
|
: away < 1000
|
||||||
|
? `${elev(away, units)} ${elevUnit(units)}`
|
||||||
|
: `${dist(away, units, 1)} ${distUnit(units)}`;
|
||||||
|
return { label: block.label, away: inWords };
|
||||||
|
});
|
||||||
|
|
||||||
const openRoutes = () => (app.showProfiles = true);
|
const openRoutes = () => (app.showProfiles = true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -257,17 +327,36 @@
|
|||||||
<span class="dot"></span>{podChip.label}
|
<span class="dot"></span>{podChip.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
<!-- One chip, not two. The mode and the target it produced are a single
|
||||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
fact — "Manual grade, +2.5%" — and splitting them cost a whole chip
|
||||||
|
of the header's width to repeat the word "target" (FR-9.8). -->
|
||||||
|
<span class="chip mode">
|
||||||
|
{MODE_LABEL[ride?.mode ?? 'ManualGrade']}
|
||||||
|
<span class="target">{targetText(ride?.target ?? null)}</span>
|
||||||
|
</span>
|
||||||
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
||||||
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
||||||
|
<!-- Hidden on a phone (see below): the setup screen is a one-off, it has
|
||||||
|
its own way in from the device screen, and the header is the one row
|
||||||
|
on this layout that cannot afford a second line. -->
|
||||||
|
<button class="btn ghost setup" title="Rider setup" onclick={() => app.openSettings()}>
|
||||||
|
Rider <span class="kbd">,</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- The hero. -->
|
<!-- The hero. -->
|
||||||
{#if show.routeChart}
|
{#if show.routeChart}
|
||||||
<section class="route">
|
<section class="route">
|
||||||
|
<!-- The chart owns the slack; the next-up line takes the one text row it
|
||||||
|
needs. Without this wrapper the chart is 100% of the section and the
|
||||||
|
line is clipped by the section's own `overflow: hidden`. -->
|
||||||
|
<div class="route-chart">
|
||||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||||
|
</div>
|
||||||
|
{#if nextUp}
|
||||||
|
<span class="next"><span class="label">Next</span>{nextUp.label} in {nextUp.away}</span>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -314,19 +403,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="To go"
|
label="To go"
|
||||||
value={remaining != null ? km(remaining, 2) : '—'}
|
value={remaining != null ? dist(remaining, units, 2) : '—'}
|
||||||
unit={remaining != null ? 'km' : ''}
|
unit={remaining != null ? distUnit(units) : ''}
|
||||||
size="big"
|
size="big"
|
||||||
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
|
sub={d?.distanceTotalM ? `of ${dist(d.distanceTotalM, units, 1)} ${distUnit(units)}` : null}
|
||||||
dim={remaining == null}
|
dim={remaining == null}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Speed"
|
label="Speed"
|
||||||
value={num(d?.displaySpeedKph ?? 0, 1)}
|
value={speed(d?.displaySpeedKph ?? 0, units)}
|
||||||
unit="km/h"
|
unit={speedUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
sub={speedSub}
|
sub={speedSub}
|
||||||
/>
|
/>
|
||||||
|
<div class="grade-cell">
|
||||||
<Readout
|
<Readout
|
||||||
label="Gradient"
|
label="Gradient"
|
||||||
value={signed(gradient, 1)}
|
value={signed(gradient, 1)}
|
||||||
@@ -335,6 +425,13 @@
|
|||||||
colour={gradeColour}
|
colour={gradeColour}
|
||||||
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
||||||
/>
|
/>
|
||||||
|
<!-- The same number as a slope. Seen, not read (see `wedge`). -->
|
||||||
|
<svg class="wedge" viewBox="0 0 40 24" aria-hidden="true" style:color={gradeColour}>
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2}" stroke="currentColor" stroke-width="2.6"
|
||||||
|
stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2} L38 22 L2 22 Z" fill="currentColor" opacity="0.14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="Gear"
|
label="Gear"
|
||||||
value={gear.value}
|
value={gear.value}
|
||||||
@@ -350,14 +447,14 @@
|
|||||||
<section class="detail">
|
<section class="detail">
|
||||||
<Readout
|
<Readout
|
||||||
label="Elevation"
|
label="Elevation"
|
||||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
value={d?.elevationM != null ? elev(d.elevationM, units) : '—'}
|
||||||
unit={d?.elevationM != null ? 'm' : ''}
|
unit={d?.elevationM != null ? elevUnit(units) : ''}
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing left"
|
label="Climbing left"
|
||||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
value={d?.ascentRemainingM != null ? elev(d.ascentRemainingM, units) : '—'}
|
||||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
unit={d?.ascentRemainingM != null ? elevUnit(units) : ''}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Compact keeps three of the five, and elapsed time is one of them: it is
|
Compact keeps three of the five, and elapsed time is one of them: it is
|
||||||
@@ -366,8 +463,16 @@
|
|||||||
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
||||||
-->
|
-->
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
|
<Readout
|
||||||
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
|
label="Covered"
|
||||||
|
value={dist(snap?.virtual_distance_m ?? 0, units, 2)}
|
||||||
|
unit={distUnit(units)}
|
||||||
|
/>
|
||||||
|
<Readout
|
||||||
|
label="Ascended"
|
||||||
|
value={elev(snap?.elevation_gain_m ?? 0, units)}
|
||||||
|
unit={elevUnit(units)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
||||||
</section>
|
</section>
|
||||||
@@ -380,8 +485,9 @@
|
|||||||
value={num(d?.rollingPowerW ?? 0, 0)}
|
value={num(d?.rollingPowerW ?? 0, 0)}
|
||||||
unit="W"
|
unit="W"
|
||||||
size="mid"
|
size="mid"
|
||||||
colour="var(--power)"
|
colour={pZone?.colour ?? 'var(--power)'}
|
||||||
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
badge={pZone ? { label: pZone.short, colour: pZone.colour } : null}
|
||||||
|
sub={`${pZone ? pZone.name + ' · ' : ''}now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Cadence"
|
label="Cadence"
|
||||||
@@ -395,6 +501,9 @@
|
|||||||
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
size="mid"
|
size="mid"
|
||||||
|
colour={hZone?.colour ?? 'var(--ink)'}
|
||||||
|
badge={hZone ? { label: hZone.short, colour: hZone.colour } : null}
|
||||||
|
sub={hZone?.name ?? null}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Averages, normalised power, work and calories are what the summary screen
|
Averages, normalised power, work and calories are what the summary screen
|
||||||
@@ -517,18 +626,60 @@
|
|||||||
background: #131b26;
|
background: #131b26;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip.mode .target {
|
||||||
|
color: var(--route);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The gradient number and its slope, side by side and sharing a colour. */
|
||||||
|
.grade-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wedge {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-size='compact']) .chips .setup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.chip.target {
|
.chip.target {
|
||||||
color: var(--route);
|
color: var(--route);
|
||||||
background: rgba(69, 208, 255, 0.1);
|
background: rgba(69, 208, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.route {
|
.route {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 2 1 0;
|
flex: 2 1 0;
|
||||||
min-height: var(--route-min);
|
min-height: var(--route-min);
|
||||||
padding: 0 var(--edge);
|
padding: 0 var(--edge);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-chart {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- pre-ride launch panel ---------------------------------------- */
|
/* ---- pre-ride launch panel ---------------------------------------- */
|
||||||
|
|
||||||
.launch {
|
.launch {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import 'uplot/dist/uPlot.min.css';
|
import 'uplot/dist/uPlot.min.css';
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { dist, elev } from '../lib/format';
|
||||||
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@
|
|||||||
|
|
||||||
let { profile, positionX, revision }: Props = $props();
|
let { profile, positionX, revision }: Props = $props();
|
||||||
|
|
||||||
|
/** Axis labels are the one part of this chart that a display preference
|
||||||
|
* reaches, so a change of units rebuilds it — see the effect below. */
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | null>(null);
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
let plot: uPlot | null = null;
|
let plot: uPlot | null = null;
|
||||||
let disposeSize: (() => void) | null = null;
|
let disposeSize: (() => void) | null = null;
|
||||||
@@ -56,12 +62,15 @@
|
|||||||
axes: [
|
axes: [
|
||||||
axis({
|
axis({
|
||||||
values: (_u, splits) =>
|
values: (_u, splits) =>
|
||||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
splits.map((v) => (isMetres ? dist(v, units, 1) : formatMinutes(v))),
|
||||||
}),
|
}),
|
||||||
axis({
|
axis({
|
||||||
side: 3,
|
side: 3,
|
||||||
size: 46,
|
size: 46,
|
||||||
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
|
// Metres of elevation follow the rider's units; a profile's own
|
||||||
|
// channel (percent, watts) is already in the unit it means.
|
||||||
|
values: (_u, splits) =>
|
||||||
|
splits.map((v) => (source?.isElevation ? elev(v, units) : v.toFixed(0))),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -122,7 +131,8 @@
|
|||||||
// is a single array walk rather than a chart teardown.
|
// is a single array walk rather than a chart teardown.
|
||||||
let builtFor: string | null = null;
|
let builtFor: string | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
const key =
|
||||||
|
host && profile ? `${profile.name}|${profile.source}|${profile.totalX}|${units}` : null;
|
||||||
if (key === builtFor) return;
|
if (key === builtFor) return;
|
||||||
builtFor = key;
|
builtFor = key;
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Rider setup (FR-7.4).
|
||||||
|
*
|
||||||
|
* This screen did not exist, and its absence was the app's largest single
|
||||||
|
* lie: `RiderConfig::default()` is a 105 kg rider on an 8 kg bike, and with
|
||||||
|
* no way to change it every rider was shown a stranger's speed, a stranger's
|
||||||
|
* ETA and a stranger's calorie count. Mass is not a preference, it is half
|
||||||
|
* the physics.
|
||||||
|
*
|
||||||
|
* Three rules hold here:
|
||||||
|
*
|
||||||
|
* 1. **Rust owns validity.** Every commit round-trips through the command,
|
||||||
|
* which validates, persists and hands back what it accepted — and that
|
||||||
|
* is what lands in the form. A field can never end up showing a value
|
||||||
|
* the engine is not using.
|
||||||
|
* 2. **Commit on change, not on a Save button.** There is no "unsaved"
|
||||||
|
* state to lose, and no button to forget to press. Fields commit when
|
||||||
|
* they are left, so a half-typed "7" on the way to "72" is never sent.
|
||||||
|
* 3. **What matters is on top.** Weight, FTP and units are the three a
|
||||||
|
* rider actually sets. Everything below them is aerodynamic and
|
||||||
|
* drivetrain detail that has a defensible default, so it is folded away
|
||||||
|
* rather than made to look equally important.
|
||||||
|
*/
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { api } from '../lib/bridge';
|
||||||
|
import { fromMass, massUnit, toMass } from '../lib/format';
|
||||||
|
import type { Preferences, RiderConfig, SafetyLimits, Units } from '../lib/types';
|
||||||
|
|
||||||
|
let rider = $state<RiderConfig | null>(null);
|
||||||
|
let limits = $state<SafetyLimits | null>(null);
|
||||||
|
const prefs = $derived(app.prefs);
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
|
/** Bumped after every commit so the inputs re-read the accepted values —
|
||||||
|
* an input the rider has typed into keeps its own DOM value otherwise, and
|
||||||
|
* a rejected 900 kg would sit there looking accepted. */
|
||||||
|
let revision = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void (async () => {
|
||||||
|
rider = await api.riderConfig();
|
||||||
|
limits = await api.safetyLimits();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the whole config, spread over what Rust last gave us.
|
||||||
|
*
|
||||||
|
* Spread rather than rebuilt: `RiderConfig` carries fields this form does not
|
||||||
|
* show (the load channel, the fixed drivetrain loss), and a form that
|
||||||
|
* reconstructs the struct would quietly reset them to serde defaults every
|
||||||
|
* time somebody changed their weight.
|
||||||
|
*/
|
||||||
|
async function commitRider(patch: Partial<RiderConfig>) {
|
||||||
|
if (!rider) return;
|
||||||
|
const next = { ...rider, ...patch };
|
||||||
|
const accepted = await app.run(() => api.setRiderConfig(next));
|
||||||
|
if (accepted) rider = accepted;
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitLimits(patch: Partial<SafetyLimits>) {
|
||||||
|
const base = limits;
|
||||||
|
if (!base) return;
|
||||||
|
const accepted = await app.run(() => api.setSafetyLimits({ ...base, ...patch }));
|
||||||
|
if (accepted) limits = accepted;
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitPrefs(patch: Partial<Preferences>) {
|
||||||
|
await app.savePrefs({ ...prefs, ...patch });
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total mass is what the physics actually uses, so it is worth seeing. */
|
||||||
|
const totalMass = $derived(rider ? rider.rider_kg + rider.bike_kg : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One field. `value` is read through `key` so that a commit — accepted or
|
||||||
|
rejected — reseats the input from the truth Rust returned.
|
||||||
|
-->
|
||||||
|
{#snippet field(
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
step: number,
|
||||||
|
unit: string,
|
||||||
|
hint: string | null,
|
||||||
|
commit: (v: number) => void,
|
||||||
|
)}
|
||||||
|
<label class="field">
|
||||||
|
<span class="name">{label}</span>
|
||||||
|
<span class="input">
|
||||||
|
{#key revision}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{step}
|
||||||
|
value={Number.isFinite(value) ? Number(value.toFixed(4)) : 0}
|
||||||
|
onchange={(e) => commit(e.currentTarget.valueAsNumber)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
<span class="unit">{unit}</span>
|
||||||
|
</span>
|
||||||
|
{#if hint}<span class="hint">{hint}</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<header>
|
||||||
|
<h1>Rider setup</h1>
|
||||||
|
<button class="btn ghost" onclick={() => app.closeSettings()}>
|
||||||
|
Done <span class="kbd">Esc</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
{#if !rider || !limits}
|
||||||
|
<p class="waiting">Reading your setup…</p>
|
||||||
|
{:else}
|
||||||
|
<section class="card">
|
||||||
|
<h2>You</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field(
|
||||||
|
'Weight',
|
||||||
|
toMass(rider.rider_kg, units),
|
||||||
|
0.5,
|
||||||
|
massUnit(units),
|
||||||
|
'Sets speed, ETA and calories',
|
||||||
|
(v) => commitRider({ rider_kg: fromMass(v, units) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Bike',
|
||||||
|
toMass(rider.bike_kg, units),
|
||||||
|
0.1,
|
||||||
|
massUnit(units),
|
||||||
|
`${toMass(totalMass, units).toFixed(1)} ${massUnit(units)} on the road`,
|
||||||
|
(v) => commitRider({ bike_kg: fromMass(v, units) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'FTP',
|
||||||
|
prefs.ftpW,
|
||||||
|
5,
|
||||||
|
'W',
|
||||||
|
// Zero is a real answer, not an empty box: it is how a rider says
|
||||||
|
// "I do not know mine", and the screen then shows plain watts
|
||||||
|
// rather than a zone measured against a guess.
|
||||||
|
prefs.ftpW ? 'Colours power by zone' : '0 — no power zones',
|
||||||
|
(v) => commitPrefs({ ftpW: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Max heart rate',
|
||||||
|
prefs.maxHrBpm,
|
||||||
|
1,
|
||||||
|
'bpm',
|
||||||
|
prefs.maxHrBpm ? 'Colours heart rate by zone' : '0 — no heart-rate zones',
|
||||||
|
(v) => commitPrefs({ maxHrBpm: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="name">Units</span>
|
||||||
|
<div class="segmented">
|
||||||
|
{#each [['metric', 'km · kg'], ['imperial', 'mi · lb']] as [value, label]}
|
||||||
|
<button
|
||||||
|
class:on={units === value}
|
||||||
|
onclick={() => commitPrefs({ units: value as Units })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="hint">Display only — rides record in SI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Everything below has a defensible default and changes the ride subtly
|
||||||
|
rather than obviously. Folded, so the three settings that matter are not
|
||||||
|
buried among nine that do not.
|
||||||
|
-->
|
||||||
|
<details class="card">
|
||||||
|
<summary><h2>Bike and physics</h2></summary>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field('CdA', rider.cda, 0.005, 'm²', 'Road position ≈ 0.32', (v) =>
|
||||||
|
commitRider({ cda: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Rolling resistance', rider.crr, 0.0005, 'Crr', 'Road tyre ≈ 0.004', (v) =>
|
||||||
|
commitRider({ crr: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Drivetrain',
|
||||||
|
rider.drivetrain_efficiency * 100,
|
||||||
|
0.5,
|
||||||
|
'%',
|
||||||
|
'Clean chain ≈ 97%',
|
||||||
|
(v) => commitRider({ drivetrain_efficiency: v / 100 }),
|
||||||
|
)}
|
||||||
|
{@render field('Air density', rider.air_density, 0.005, 'kg/m³', 'Sea level 1.225', (v) =>
|
||||||
|
commitRider({ air_density: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Wheel',
|
||||||
|
rider.wheel_circumference_m * 1000,
|
||||||
|
5,
|
||||||
|
'mm',
|
||||||
|
'700×25 ≈ 2100 mm',
|
||||||
|
(v) => commitRider({ wheel_circumference_m: v / 1000 }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Crank',
|
||||||
|
rider.crank_length_m * 1000,
|
||||||
|
2.5,
|
||||||
|
'mm',
|
||||||
|
'Road 170–175 mm',
|
||||||
|
(v) => commitRider({ crank_length_m: v / 1000 }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Real gear',
|
||||||
|
rider.physical_development_m,
|
||||||
|
0.1,
|
||||||
|
'm/rev',
|
||||||
|
'Through the Zwift Cog — 34×14 ≈ 5.1',
|
||||||
|
(v) => commitRider({ physical_development_m: v }),
|
||||||
|
)}
|
||||||
|
{@render field(
|
||||||
|
'Descent floor',
|
||||||
|
rider.descent_load_floor_pct,
|
||||||
|
0.5,
|
||||||
|
'%',
|
||||||
|
'Keeps load under the pedals downhill',
|
||||||
|
(v) => commitRider({ descent_load_floor_pct: v }),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- SAF-3. These bound everything sent to the trainer, whatever asked
|
||||||
|
for it, so they are shown last and phrased as limits, not targets. -->
|
||||||
|
<details class="card">
|
||||||
|
<summary><h2>Safety limits</h2></summary>
|
||||||
|
<div class="grid">
|
||||||
|
{@render field('Gradient floor', limits.min_gradient_pct, 0.5, '%', null, (v) =>
|
||||||
|
commitLimits({ min_gradient_pct: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Gradient ceiling', limits.max_gradient_pct, 0.5, '%', null, (v) =>
|
||||||
|
commitLimits({ max_gradient_pct: v }),
|
||||||
|
)}
|
||||||
|
{@render field('Resistance floor', limits.min_resistance, 1, 'L', null, (v) =>
|
||||||
|
commitLimits({ min_resistance: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Resistance ceiling', limits.max_resistance, 1, 'L', null, (v) =>
|
||||||
|
commitLimits({ max_resistance: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Power floor', limits.min_power_w, 5, 'W', null, (v) =>
|
||||||
|
commitLimits({ min_power_w: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
{@render field('Power ceiling', limits.max_power_w, 5, 'W', 'Clamped at transmission', (v) =>
|
||||||
|
commitLimits({ max_power_w: Math.round(v) }),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--edge) 3rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: var(--bg-lift);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `display: flex` on a summary drops the browser's own marker, and a
|
||||||
|
disclosure with nothing to disclose-looking about it does not read as one. */
|
||||||
|
summary::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||||
|
gap: 0.7rem 1rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--route) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
/* The spinners are 12 px of target on a screen where nothing else is under
|
||||||
|
48, and they steal the width the number needs. */
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::-webkit-outer-spin-button,
|
||||||
|
input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.on {
|
||||||
|
background: var(--route);
|
||||||
|
color: #04121a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
* urgent. It says where the file already is, whatever the rider does next.
|
* urgent. It says where the file already is, whatever the rider does next.
|
||||||
*/
|
*/
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { clock, km, num } from '../lib/format';
|
import { clock, dist, distUnit, elev, elevUnit, num } from '../lib/format';
|
||||||
import Readout from './Readout.svelte';
|
import Readout from './Readout.svelte';
|
||||||
|
|
||||||
const s = $derived(app.summary);
|
const s = $derived(app.summary);
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
/** Time not spent riding. Only worth showing when it is not zero. */
|
/** Time not spent riding. Only worth showing when it is not zero. */
|
||||||
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
||||||
@@ -64,15 +65,15 @@
|
|||||||
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
||||||
<Readout
|
<Readout
|
||||||
label="Distance"
|
label="Distance"
|
||||||
value={km(s.distanceM, 2)}
|
value={dist(s.distanceM, units, 2)}
|
||||||
unit="km"
|
unit={distUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--route)"
|
colour="var(--route)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing"
|
label="Climbing"
|
||||||
value={num(s.ascentM, 0)}
|
value={elev(s.ascentM, units)}
|
||||||
unit="m"
|
unit={elevUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import type {
|
|||||||
InputAck,
|
InputAck,
|
||||||
LapSummary,
|
LapSummary,
|
||||||
Notice,
|
Notice,
|
||||||
|
Preferences,
|
||||||
RideFrame,
|
RideFrame,
|
||||||
RideState,
|
RideState,
|
||||||
RideSummary,
|
RideSummary,
|
||||||
SampleProfile,
|
SampleProfile,
|
||||||
|
Units,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type Screen = 'connect' | 'ride' | 'summary';
|
export type Screen = 'connect' | 'ride' | 'summary' | 'settings';
|
||||||
|
|
||||||
let toastSeq = 0;
|
let toastSeq = 0;
|
||||||
|
|
||||||
@@ -40,6 +42,14 @@ class AppStore {
|
|||||||
controller = $state<ControllerStatus | null>(null);
|
controller = $state<ControllerStatus | null>(null);
|
||||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||||
revision = $state(0);
|
revision = $state(0);
|
||||||
|
/**
|
||||||
|
* Display preferences (FR-7.4). Held here rather than read where they are
|
||||||
|
* needed so that changing units or FTP redraws every readout at once —
|
||||||
|
* `format.ts` stays pure and takes them as an argument.
|
||||||
|
*/
|
||||||
|
prefs = $state<Preferences>({ ftpW: 0, maxHrBpm: 0, units: 'metric' });
|
||||||
|
/** The screen to return to when settings close. */
|
||||||
|
settingsReturn: Screen = 'connect';
|
||||||
|
|
||||||
/** Bounded chart history — raw power, rolling power. */
|
/** Bounded chart history — raw power, rolling power. */
|
||||||
readonly power = new History(2);
|
readonly power = new History(2);
|
||||||
@@ -53,6 +63,20 @@ class AppStore {
|
|||||||
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
|
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get units(): Units {
|
||||||
|
return this.prefs.units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open settings from wherever the rider is, and remember where that was. */
|
||||||
|
openSettings(): void {
|
||||||
|
if (this.screen !== 'settings') this.settingsReturn = this.screen;
|
||||||
|
this.screen = 'settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSettings(): void {
|
||||||
|
this.screen = this.settingsReturn;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter the ride screen.
|
* Enter the ride screen.
|
||||||
*
|
*
|
||||||
@@ -83,7 +107,7 @@ class AppStore {
|
|||||||
* parallel set that can drift.
|
* parallel set that can drift.
|
||||||
*/
|
*/
|
||||||
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
|
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
|
||||||
const [ride, devices, samples, summary, recovered, controller] = await Promise.all([
|
const [ride, devices, samples, summary, recovered, controller, prefs] = await Promise.all([
|
||||||
api.rideState(),
|
api.rideState(),
|
||||||
api.deviceList(),
|
api.deviceList(),
|
||||||
api.sampleProfiles(),
|
api.sampleProfiles(),
|
||||||
@@ -93,12 +117,14 @@ class AppStore {
|
|||||||
// *change*, so a webview reload with both pods already connected would
|
// *change*, so a webview reload with both pods already connected would
|
||||||
// otherwise show two empty slots.
|
// otherwise show two empty slots.
|
||||||
api.controllerStatus(),
|
api.controllerStatus(),
|
||||||
|
api.preferences(),
|
||||||
]);
|
]);
|
||||||
this.ride = ride;
|
this.ride = ride;
|
||||||
this.devices = devices;
|
this.devices = devices;
|
||||||
this.samples = samples;
|
this.samples = samples;
|
||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
|
this.prefs = prefs;
|
||||||
// A ride already in progress (a reload, or an autostart) belongs on screen
|
// A ride already in progress (a reload, or an autostart) belongs on screen
|
||||||
// immediately — nobody wants to click past a device list mid-effort.
|
// immediately — nobody wants to click past a device list mid-effort.
|
||||||
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
|
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
|
||||||
@@ -205,6 +231,17 @@ class AppStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a preference and keep the screen honest about what stuck.
|
||||||
|
*
|
||||||
|
* Rust validates and returns the accepted value, so what lands in `prefs` is
|
||||||
|
* what is actually stored — never what the input box happened to contain.
|
||||||
|
*/
|
||||||
|
async savePrefs(next: Preferences): Promise<void> {
|
||||||
|
const accepted = await this.run(() => api.setPreferences(next));
|
||||||
|
if (accepted) this.prefs = accepted;
|
||||||
|
}
|
||||||
|
|
||||||
/** Leave the summary and set up for another ride. */
|
/** Leave the summary and set up for another ride. */
|
||||||
async newRide(): Promise<void> {
|
async newRide(): Promise<void> {
|
||||||
await this.run(() => api.reset());
|
await this.run(() => api.reset());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
InputAck,
|
InputAck,
|
||||||
LapSummary,
|
LapSummary,
|
||||||
Notice,
|
Notice,
|
||||||
|
Preferences,
|
||||||
ProfileView,
|
ProfileView,
|
||||||
Recovered,
|
Recovered,
|
||||||
RideFrame,
|
RideFrame,
|
||||||
@@ -139,6 +140,8 @@ export const api = {
|
|||||||
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
||||||
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
||||||
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
||||||
|
preferences: () => call<Preferences>('preferences'),
|
||||||
|
setPreferences: (prefs: Preferences) => call<Preferences>('set_preferences', { prefs }),
|
||||||
|
|
||||||
// profiles
|
// profiles
|
||||||
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dist, elev, hrZone, percentOf, powerZone, speed, toMass, fromMass } from './format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display layer is where a rider's preference is applied and nowhere else,
|
||||||
|
* so these tests exist to pin two properties: the conversions are exact, and a
|
||||||
|
* zone is never invented from a reference the rider has not given.
|
||||||
|
*/
|
||||||
|
describe('units', () => {
|
||||||
|
it('leaves metric alone and converts imperial exactly', () => {
|
||||||
|
expect(dist(42195, 'metric', 2)).toBe('42.20');
|
||||||
|
expect(dist(1609.344, 'imperial', 3)).toBe('1.000');
|
||||||
|
expect(elev(1000, 'metric')).toBe('1000');
|
||||||
|
expect(elev(304.8, 'imperial')).toBe('1000');
|
||||||
|
expect(speed(32.18688, 'imperial')).toBe('20.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a mass through the form and back', () => {
|
||||||
|
const kg = 72.5;
|
||||||
|
expect(fromMass(toMass(kg, 'imperial'), 'imperial')).toBeCloseTo(kg, 6);
|
||||||
|
expect(toMass(kg, 'metric')).toBe(kg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing rather than NaN when there is no value', () => {
|
||||||
|
expect(dist(null, 'metric')).toBe('—');
|
||||||
|
expect(elev(undefined, 'imperial')).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effort zones', () => {
|
||||||
|
it('places power on the Coggan boundaries', () => {
|
||||||
|
const ftp = 250;
|
||||||
|
expect(powerZone(100, ftp)?.short).toBe('Z1'); // 40%
|
||||||
|
expect(powerZone(175, ftp)?.short).toBe('Z2'); // 70%
|
||||||
|
expect(powerZone(220, ftp)?.short).toBe('Z3'); // 88%
|
||||||
|
expect(powerZone(250, ftp)?.short).toBe('Z4'); // 100%
|
||||||
|
expect(powerZone(290, ftp)?.short).toBe('Z5'); // 116%
|
||||||
|
expect(powerZone(350, ftp)?.short).toBe('Z6'); // 140%
|
||||||
|
expect(powerZone(500, ftp)?.short).toBe('Z7'); // 200%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a boundary value in the lower zone', () => {
|
||||||
|
// 55% of FTP is the top of zone 1, not the bottom of zone 2.
|
||||||
|
expect(powerZone(137.5, 250)?.short).toBe('Z1');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The whole contract of `ftpW: 0` — no reference, no colour. */
|
||||||
|
it('refuses to invent a zone without a reference', () => {
|
||||||
|
expect(powerZone(300, 0)).toBeNull();
|
||||||
|
expect(hrZone(150, 0)).toBeNull();
|
||||||
|
expect(percentOf(300, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not colour a rider who is not pedalling', () => {
|
||||||
|
expect(powerZone(0, 250)).toBeNull();
|
||||||
|
expect(powerZone(null, 250)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places heart rate as a fraction of maximum', () => {
|
||||||
|
expect(hrZone(100, 190)?.short).toBe('Z1'); // 53%
|
||||||
|
expect(hrZone(125, 190)?.short).toBe('Z2'); // 66%
|
||||||
|
expect(hrZone(150, 190)?.short).toBe('Z3'); // 79%
|
||||||
|
expect(hrZone(170, 190)?.short).toBe('Z4'); // 89%
|
||||||
|
expect(hrZone(185, 190)?.short).toBe('Z5'); // 97%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the percentage the zone came from', () => {
|
||||||
|
expect(percentOf(275, 250)).toBe('110% of FTP');
|
||||||
|
});
|
||||||
|
});
|
||||||
+141
-7
@@ -1,4 +1,5 @@
|
|||||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||||
|
import type { Units } from './types';
|
||||||
|
|
||||||
const EM_DASH = '—';
|
const EM_DASH = '—';
|
||||||
|
|
||||||
@@ -32,11 +33,6 @@ export function finishAt(secondsFromNow: number | null | undefined): string {
|
|||||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function km(metres: number | null | undefined, digits = 2): string {
|
|
||||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
|
||||||
return (metres / 1000).toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function num(v: number | null | undefined, digits = 0): string {
|
export function num(v: number | null | undefined, digits = 0): string {
|
||||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||||
return v.toFixed(digits);
|
return v.toFixed(digits);
|
||||||
@@ -51,8 +47,8 @@ export function axisLabel(unit: 'seconds' | 'metres'): string {
|
|||||||
return unit === 'metres' ? 'distance' : 'time';
|
return unit === 'metres' ? 'distance' : 'time';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
export function axisValue(unit: 'seconds' | 'metres', x: number, units: Units): string {
|
||||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
return unit === 'metres' ? `${dist(x, units, 1)} ${distUnit(units)}` : clock(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rssiBars(rssi: number): number {
|
export function rssiBars(rssi: number): number {
|
||||||
@@ -102,3 +98,141 @@ export function connectionText(
|
|||||||
return { label: 'Idle', tone: 'idle' };
|
return { label: 'Idle', tone: 'idle' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Units (FR-7.4's neighbour)
|
||||||
|
//
|
||||||
|
// SI everywhere behind this line. The engine computes in metres and km/h, the
|
||||||
|
// FIT file records in metres and km/h, and a rider who prefers miles changes
|
||||||
|
// *only* what the glass says. Every function here therefore takes the unit
|
||||||
|
// system as an argument rather than reading a module-level setting: pure
|
||||||
|
// functions are what let the ride screen redraw the moment the preference
|
||||||
|
// changes, and what keeps a recorded activity independent of a display choice.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KM_PER_MILE = 1.609344;
|
||||||
|
const M_PER_FOOT = 0.3048;
|
||||||
|
const KG_PER_LB = 0.45359237;
|
||||||
|
|
||||||
|
export function distUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mi' : 'km';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elevUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'ft' : 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speedUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mph' : 'km/h';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function massUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'lb' : 'kg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres → the rider's long-distance unit. */
|
||||||
|
export function dist(metres: number | null | undefined, units: Units, digits = 2): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
const km = metres / 1000;
|
||||||
|
return (units === 'imperial' ? km / KM_PER_MILE : km).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres of climbing → metres or feet. Always whole: nobody climbs 0.4 m. */
|
||||||
|
export function elev(metres: number | null | undefined, units: Units): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? metres / M_PER_FOOT : metres).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speed(kph: number | null | undefined, units: Units, digits = 1): string {
|
||||||
|
if (kph == null || !Number.isFinite(kph)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? kph / KM_PER_MILE : kph).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kilograms → kg or lb. For the settings form, which edits SI underneath. */
|
||||||
|
export function toMass(kg: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? kg / KG_PER_LB : kg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMass(value: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? value * KG_PER_LB : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Effort zones
|
||||||
|
//
|
||||||
|
// A watt is a fact; a zone is what it *costs you*, and it is the difference
|
||||||
|
// between a number and a number that means something. Every professional
|
||||||
|
// training app colours effort this way and this one did not, so the biggest
|
||||||
|
// figure on the ride screen was the same shade of white at 90 W and at 400 W.
|
||||||
|
//
|
||||||
|
// Two rules hold here:
|
||||||
|
//
|
||||||
|
// 1. **No reference, no zone.** `ftp` or `maxHr` of zero means the rider has
|
||||||
|
// not told us, and an invented threshold would paint every ride with a
|
||||||
|
// confident lie. `null` is the honest answer and the callers draw the
|
||||||
|
// plain number.
|
||||||
|
// 2. **Colour never carries it alone.** Every zone has a short name that is
|
||||||
|
// rendered beside the swatch, so the meaning survives a colour-blind
|
||||||
|
// rider, a sun-washed phone and a black-and-white screenshot.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Zone {
|
||||||
|
/** One-based, as riders say them: "zone 4". */
|
||||||
|
index: number;
|
||||||
|
/** What it is called in a training plan. */
|
||||||
|
name: string;
|
||||||
|
/** Short form for a chip beside a number: "Z4". */
|
||||||
|
short: string;
|
||||||
|
/** A CSS custom property reference, so themes stay in the stylesheet. */
|
||||||
|
colour: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coggan's seven power zones, as fractions of FTP. The boundaries are the
|
||||||
|
* conventional ones — 55 / 75 / 90 / 105 / 120 / 150 % — which is what makes a
|
||||||
|
* "zone 3 ride" here mean the same thing it means in the rider's plan.
|
||||||
|
*/
|
||||||
|
const POWER_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.55, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.75, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 1.05, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-4)' } },
|
||||||
|
{ upto: 1.2, zone: { index: 5, name: 'VO₂ max', short: 'Z5', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: 1.5, zone: { index: 6, name: 'Anaerobic', short: 'Z6', colour: 'var(--zone-6)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 7, name: 'Sprint', short: 'Z7', colour: 'var(--zone-7)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Five heart-rate zones as fractions of maximum. */
|
||||||
|
const HR_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.6, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.7, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.8, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 5, name: 'Maximum', short: 'Z5', colour: 'var(--zone-6)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function zoneFor(
|
||||||
|
table: { upto: number; zone: Zone }[],
|
||||||
|
value: number | null | undefined,
|
||||||
|
reference: number,
|
||||||
|
): Zone | null {
|
||||||
|
// Not pedalling is not zone 1. A stopped rider in "Recovery" green is the
|
||||||
|
// screen claiming an effort that is not happening.
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
const ratio = value / reference;
|
||||||
|
return table.find((row) => ratio <= row.upto)?.zone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerZone(watts: number | null | undefined, ftp: number): Zone | null {
|
||||||
|
return zoneFor(POWER_ZONES, watts, ftp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrZone(bpm: number | null | undefined, maxHr: number): Zone | null {
|
||||||
|
return zoneFor(HR_ZONES, bpm, maxHr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentage of the reference, for the subtitle under a zoned number. */
|
||||||
|
export function percentOf(value: number | null | undefined, reference: number): string | null {
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return `${Math.round((value / reference) * 100)}% of FTP`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,10 +70,15 @@ export interface RideSnapshot {
|
|||||||
profile_progress: number | null;
|
profile_progress: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which FTMS channel the computed load is sent on. */
|
||||||
|
export type LoadChannel = 'Gradient' | 'Power';
|
||||||
|
|
||||||
export interface RiderConfig {
|
export interface RiderConfig {
|
||||||
rider_kg: number;
|
rider_kg: number;
|
||||||
bike_kg: number;
|
bike_kg: number;
|
||||||
crr: number;
|
crr: number;
|
||||||
|
/** Fixed drivetrain and flywheel loss, watts. */
|
||||||
|
rolling_loss_w: number;
|
||||||
cda: number;
|
cda: number;
|
||||||
drivetrain_efficiency: number;
|
drivetrain_efficiency: number;
|
||||||
air_density: number;
|
air_density: number;
|
||||||
@@ -83,6 +88,33 @@ export interface RiderConfig {
|
|||||||
descent_load_floor_pct: number;
|
descent_load_floor_pct: number;
|
||||||
/** Development of the real gear through the Zwift Cog, m per crank rev. */
|
/** Development of the real gear through the Zwift Cog, m per crank rev. */
|
||||||
physical_development_m: number;
|
physical_development_m: number;
|
||||||
|
load_channel: LoadChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- src-tauri/src/settings.rs -----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the rider reads, not what is recorded. Every stored value and every FIT
|
||||||
|
* field stays SI whatever this says — the conversion is the last step before
|
||||||
|
* the glass.
|
||||||
|
*/
|
||||||
|
export type Units = 'metric' | 'imperial';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display preferences (FR-7.4's neighbours). Deliberately *not* part of
|
||||||
|
* `RiderConfig`: none of this reaches the physics, and `crates/core` is the
|
||||||
|
* frozen contract the engine and the FIT writer share.
|
||||||
|
*/
|
||||||
|
export interface Preferences {
|
||||||
|
/**
|
||||||
|
* Functional threshold power, watts. **Zero means unset**, and zones are then
|
||||||
|
* not drawn at all — a zone measured against a guessed FTP is worse than no
|
||||||
|
* zone, because it looks authoritative.
|
||||||
|
*/
|
||||||
|
ftpW: number;
|
||||||
|
/** Maximum heart rate, bpm. Zero means unset, same contract as `ftpW`. */
|
||||||
|
maxHrBpm: number;
|
||||||
|
units: Units;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SafetyLimits {
|
export interface SafetyLimits {
|
||||||
|
|||||||
Reference in New Issue
Block a user