Ride the drivetrain, command the load in watts

Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:21:08 +02:00
co-authored by Claude Opus 5
parent f2c4cb2120
commit 7b511db3dc
44 changed files with 6636 additions and 950 deletions
Generated
+561 -17
View File
@@ -56,6 +56,126 @@ dependencies = [
"num-traits",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.91"
@@ -121,6 +241,9 @@ dependencies = [
"anyhow",
"bikecontrol-ble",
"bikecontrol-core",
"bikecontrol-fit",
"chrono",
"keepawake",
"roxmltree",
"serde",
"serde_json",
@@ -243,6 +366,19 @@ dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bluez-async"
version = "0.8.2"
@@ -326,8 +462,8 @@ dependencies = [
"tokio",
"tokio-stream",
"uuid",
"windows",
"windows-future",
"windows 0.61.3",
"windows-future 0.2.1",
]
[[package]]
@@ -491,6 +627,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -623,14 +768,38 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
]
[[package]]
@@ -646,13 +815,24 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"darling_core 0.23.0",
"quote",
"syn 2.0.119",
]
@@ -717,6 +897,37 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -907,6 +1118,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -934,6 +1172,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1082,6 +1340,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.33"
@@ -1443,6 +1714,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1866,6 +2143,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "keepawake"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5521b450ec179362595d5cbda7c3abd5da3af3dff58456434ad3ca33c95226b7"
dependencies = [
"cfg-if",
"derive_builder",
"objc2-core-foundation",
"objc2-io-kit",
"thiserror 2.0.19",
"windows 0.62.2",
"zbus",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -1941,6 +2233,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -2208,7 +2506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.4",
]
@@ -2297,6 +2597,20 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"bitflags 2.13.1",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -2377,6 +2691,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2402,6 +2726,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2490,6 +2820,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2535,6 +2876,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2802,6 +3157,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3042,7 +3410,7 @@ version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -3362,7 +3730,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3433,7 +3801,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3578,7 +3946,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3603,7 +3971,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -3656,6 +4024,19 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -4088,6 +4469,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -4404,7 +4796,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -4428,7 +4820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.19",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4484,11 +4876,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -4500,6 +4904,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -4534,7 +4947,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -4581,6 +5005,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4710,6 +5144,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@@ -4941,7 +5384,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4997,6 +5440,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -5056,3 +5560,43 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.119",
"winnow 1.0.4",
]
+6 -12
View File
@@ -22,10 +22,10 @@ The app drives the hardware end to end: it scans over BlueZ, connects, acquires
control, feeds Indoor Bike Data into the ride engine, and writes control targets back at
4 Hz. Recording to FIT is the remaining gap.
The synthetic rider is still available for GUI work with no hardware on the desk —
`BIKECONTROL_MOCK=1`, or `BIKECONTROL_DEMO=1` which also loads the sample route and starts
riding. Both need the `mock-ride` feature, which is on by default;
`--no-default-features` produces a binary that can only ever show real trainer data.
There is no synthetic rider and no ride-without-a-trainer mode. The ride screen is gated on
an FTMS trainer that has accepted the control point, and with no trainer attached the ride
reads zero — a session that could be finished without any of it having happened is worse
than no session at all.
---
@@ -50,12 +50,6 @@ cd ..
./target/debug/bikecontrol-app
```
Open straight onto a running ride with the sample GPX loaded:
```bash
BIKECONTROL_DEMO=1 ./target/debug/bikecontrol-app
```
### Development mode (hot reload)
```bash
@@ -249,5 +243,5 @@ ui Svelte 5 + uPlot — renders snapshots, issues intents
`crates/core/src/types.rs` is the shared contract between all of them. Change it
deliberately.
The app selects its data source by Cargo feature: `mock-ride` (default) drives a synthetic
rider; `real-session` wraps the real ride engine and needs a live telemetry source.
The app has one data source: `src-tauri/src/session_backend.rs`, which wraps the real ride
engine and is fed by live FTMS telemetry. Nothing fabricates rider data.
+13 -4
View File
@@ -176,9 +176,17 @@ button we have found drives them.
> two-button test returning 4, 7, 4, 7, 4, 7. The first capture had been pressed out of
> order.
> **Open:** all ten buttons arrived over the **single** pod at `f4:c4:59:03:a1:8e`
> (type byte `0x0B`). What the second pod (`c0:4a:0e:f9:a8:78`, `0x0A`) contributes is
> unknown — it may mirror the same state, or carry nothing we need.
> **The pods are not disjoint — answered 2026-08-05.** All ten buttons arrived over the
> **single** pod at `f4:c4:59:03:a1:8e` (type byte `0x0B`), and with both pods connected the
> `+` paddle arrives **twice**: once from the pod it is printed on and once relayed by its
> twin. `` arrives once. Measured from a ride log rather than a capture — one press of `+`
> moved the gear by two (7 → 9 → 11), one press of `` by one.
>
> Two consequences, both handled in `controller::Buttons`. The app must **merge the pair into
> one controller**: a button is held when either pod says so, and only that aggregate's edges
> may act, or every `+` press shifts twice. And **a pod sending its twin's paddle is normal**,
> 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.
### 2.3.2 The D100's own Zwift service — telemetry, not shifting
@@ -379,7 +387,7 @@ bikecontrol/
| 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.9 | Allow riding with the trainer alone; on-screen and keyboard controls substitute | Must |
| FR-1.10 | A connect or reconnect attempt in flight is cancellable: disconnect and app exit abandon it rather than queue behind it, and any half-open GATT link is closed on the way out | Must |
| FR-1.11 | Auto-reconnect is bounded. On giving up, the trainer settles in `Lost` carrying the reason — never silently back to `Idle` | Must |
| FR-1.11 | Auto-reconnect is bounded, for the controller as well as the trainer. Giving up is announced as a terminal state carrying the reason — never a silent return to `Idle`, and never an actor that stops without saying so | Must |
| FR-1.12 | Scanning resumes on its own whenever no trainer link is held or being attempted, so a disconnect or a failed connect does not leave a frozen device list | Should |
**Cancellation note (FR-1.10):** a connect is a long operation — up to `scan_timeout` before the
@@ -633,6 +641,7 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| 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-10 | **No busy-waiting** — a supervisor whose event source has closed drops it. No loop may spin on a permanently-ready future, and no arm of a `biased` select may starve the one that carries the shutdown command |
| NFR-11 | **Screen stays lit** — a live ride (running or paused) holds a system idle inhibitor, so the display never blanks or locks under a rider whose hands are on the bars. Held for the ride, not for the app: it is taken when the ride starts and released when it ends, however it ends. Failing to take it costs a blanked screen, never a ride |
---
+178 -18
View File
@@ -26,8 +26,78 @@ use tokio::sync::{broadcast, mpsc, oneshot};
use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT};
use crate::error::FtmsError;
use crate::scan::{self, TrainerSelector};
use crate::zwift::{self, Button, ButtonTracker};
use crate::scan::{self, ScanKind};
use crate::zwift::{self, Button, ButtonTracker, PodId};
/// How to pick a controller pod out of a scan.
///
/// Deliberately not [`crate::scan::TrainerSelector`]. A pair of Click pods
/// advertise the *same* local name — both of ours are plain `Zwift Click` — so
/// name matching cannot tell them apart and whichever answered first won. That
/// is the whole reason the connection felt arbitrary: ask for "the Click" and
/// you get a coin toss between the pod with the D-pad and the one without.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PodSelector {
/// A specific pod, by address. What the app uses once a scan has seen the
/// pair, because an address cannot be confused with its twin.
Address(String),
/// The pod whose manufacturer-data type byte says it is this one (§2.3.1).
/// Used before either pod has been seen by a scan.
Pod(PodId),
/// Any Click pod at all. The fallback for hardware whose type byte we do
/// not recognise — better a controller in the wrong slot than none.
Any,
}
impl PodSelector {
/// Does this peripheral match?
pub fn matches(&self, d: &scan::DiscoveredDevice) -> bool {
self.matches_parts(&d.address, d.zwift_kind())
}
/// The matching rule, factored out so it can be unit-tested without a
/// `PeripheralId` (which only the platform backend can construct).
pub(crate) fn matches_parts(&self, address: &str, kind: Option<zwift::DeviceKind>) -> bool {
match self {
// An address is matched without insisting on the manufacturer data:
// a pod that is mid-connection may not be advertising it, and the
// address alone already identifies exactly one peripheral.
PodSelector::Address(a) => address.eq_ignore_ascii_case(a),
PodSelector::Pod(pod) => kind.and_then(|k| k.pod_id()) == Some(*pod),
PodSelector::Any => kind.is_some_and(|k| k.is_click()),
}
}
/// Human-readable, for error messages the rider will actually read.
pub fn describe(&self) -> String {
match self {
PodSelector::Address(a) => format!("Click pod at {a}"),
PodSelector::Pod(p) => format!("the {} Click pod", p.symbol()),
PodSelector::Any => "any Click pod".to_string(),
}
}
/// Which pod this asks for, when it asks for a particular one at all.
pub fn pod(&self) -> Option<PodId> {
match self {
PodSelector::Pod(p) => Some(*p),
_ => None,
}
}
}
/// Scan until a pod matching `selector` appears, or `timeout` elapses.
pub async fn find_pod(
adapter: &Adapter,
selector: &PodSelector,
timeout: Duration,
) -> Result<Peripheral, FtmsError> {
// Unfiltered: a Click advertises Zwift's own service, never FTMS.
scan::find_matching(adapter, ScanKind::All, timeout, &selector.describe(), |d| {
selector.matches(d)
})
.await
}
/// Tunables for [`ClickClient`].
#[derive(Debug, Clone)]
@@ -55,6 +125,11 @@ pub enum ClickEvent {
Connected {
address: String,
name: Option<String>,
/// Which pod this turned out to be, from its own advertisement — not
/// from what was asked for. Connecting by address says nothing about
/// which paddle the pod carries, and the app would otherwise have to
/// assume it got what it requested.
pod: Option<PodId>,
},
/// The link dropped. Any held button has already been reported as released.
/// The actor is retrying — this is not terminal.
@@ -84,18 +159,36 @@ enum Cmd {
pub struct ClickClient {
cmd_tx: mpsc::Sender<Cmd>,
events_tx: broadcast::Sender<ClickEvent>,
address: String,
name: Option<String>,
pod: Option<PodId>,
}
impl ClickClient {
/// The pod's address. Carried on the handle rather than left to the event
/// stream because [`ClickEvent::Connected`] for the *first* session is sent
/// before the caller has had a chance to subscribe — a caller that learned
/// its identity only from events would sit there believing it had connected
/// to nothing.
pub fn address(&self) -> &str {
&self.address
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
/// Which pod of a pair this turned out to be, where it said so (§2.3.1).
pub fn pod(&self) -> Option<PodId> {
self.pod
}
/// Connect to a Click and start streaming events.
///
/// Returns once the pod has answered the handshake, so a caller that gets
/// an `Ok` knows the controller is genuinely talking — not merely that a
/// BLE link exists.
pub async fn connect(
selector: TrainerSelector,
config: ClickConfig,
) -> Result<Self, FtmsError> {
pub async fn connect(selector: PodSelector, config: ClickConfig) -> Result<Self, FtmsError> {
let adapter = scan::default_adapter().await?;
Self::connect_with_adapter(adapter, selector, config).await
}
@@ -108,7 +201,7 @@ impl ClickClient {
/// a hang (FR-1.10). Any link the abandoned attempt had opened is closed
/// before this returns (SAF-9).
pub async fn connect_cancellable(
selector: TrainerSelector,
selector: PodSelector,
config: ClickConfig,
cancel: impl Future<Output = ()>,
) -> Result<Option<Self>, FtmsError> {
@@ -137,7 +230,7 @@ impl ClickClient {
/// As [`ClickClient::connect`], but on a caller-supplied adapter.
pub async fn connect_with_adapter(
adapter: Adapter,
selector: TrainerSelector,
selector: PodSelector,
config: ClickConfig,
) -> Result<Self, FtmsError> {
Self::connect_on(adapter, selector, config, &InFlight::default()).await
@@ -145,7 +238,7 @@ impl ClickClient {
async fn connect_on(
adapter: Adapter,
selector: TrainerSelector,
selector: PodSelector,
config: ClickConfig,
in_flight: &InFlight,
) -> Result<Self, FtmsError> {
@@ -153,21 +246,34 @@ impl ClickClient {
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?;
let (address, name, pod) = (session.address.clone(), session.name.clone(), session.pod);
// Sent for symmetry with a reconnect. Nobody is subscribed yet, which is
// why the same three facts also ride out on the handle below.
let _ = events_tx.send(ClickEvent::Connected {
address: session.address.clone(),
name: session.name.clone(),
address: address.clone(),
name: name.clone(),
pod,
});
let actor = Actor {
adapter,
selector,
// Reconnect to *this* pod, not to whatever now answers the original
// description. Chasing `the pod` after a drop could land on the
// twin if the type bytes are not what we think they are, and then
// both slots would be the same peripheral. Only an address we
// actually read is an improvement on what we were asked for.
selector: if address.is_empty() {
selector
} else {
PodSelector::Address(address.clone())
},
config,
events_tx: events_tx.clone(),
tracker: ButtonTracker::new(),
};
tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications)));
Ok(Self { cmd_tx, events_tx })
Ok(Self { cmd_tx, events_tx, address, name, pod })
}
/// Subscribe to controller events. Late subscribers see only what arrives
@@ -204,21 +310,23 @@ struct Session {
peripheral: Peripheral,
address: String,
name: Option<String>,
/// Which pod this is, as it advertised itself.
pod: Option<PodId>,
subscribed: Vec<Characteristic>,
}
/// Find the pod, connect, subscribe, and complete the `RideOn` handshake.
async fn open_session(
adapter: &Adapter,
selector: &TrainerSelector,
selector: &PodSelector,
config: &ClickConfig,
in_flight: &InFlight,
) -> Result<(Session, Notifications), FtmsError> {
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
// Cancelling past this point would otherwise strand the link (FR-1.10).
in_flight.hold(peripheral.clone());
match setup_session(peripheral.clone()).await {
match setup_session(peripheral.clone(), selector.pod()).await {
Ok(session) => {
in_flight.released();
Ok(session)
@@ -235,7 +343,10 @@ async fn open_session(
}
}
async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
async fn setup_session(
peripheral: Peripheral,
asked_for: Option<PodId>,
) -> Result<(Session, Notifications), FtmsError> {
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await?;
}
@@ -293,6 +404,12 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
.as_ref()
.map(|d| d.address.clone())
.unwrap_or_default(),
// A peripheral that is already connected may no longer carry its
// advertisement, so fall back to the pod we went looking for.
pod: described
.as_ref()
.and_then(|d| d.pod_id())
.or(asked_for),
name: described.and_then(|d| d.name),
peripheral,
subscribed,
@@ -303,7 +420,7 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
struct Actor {
adapter: Adapter,
selector: TrainerSelector,
selector: PodSelector,
config: ClickConfig,
events_tx: broadcast::Sender<ClickEvent>,
tracker: ButtonTracker,
@@ -357,6 +474,11 @@ impl Actor {
loop {
if self.config.backoff.exhausted(attempt) {
tracing::warn!("click: giving up after {attempt} reconnect attempts");
// Say so before going away. Leaving silently strands the
// supervisor holding a handle whose actor is gone, with
// nothing to tell the rider why the buttons stopped working
// (FR-1.11).
let _ = self.events_tx.send(ClickEvent::GaveUp { attempts: attempt });
return;
}
let delay = self.config.backoff.delay(attempt);
@@ -399,6 +521,7 @@ impl Actor {
let _ = self.events_tx.send(ClickEvent::Connected {
address: session.address.clone(),
name: session.name.clone(),
pod: session.pod,
});
current = session;
notifications = stream;
@@ -492,6 +615,43 @@ mod tests {
assert_eq!(c.backoff.max_attempts, None);
}
/// The bug this whole selector exists to kill: both pods of a pair
/// advertise the identical local name, so "the Click" is not a thing you
/// can ask a scan for. Which one you got was a race.
#[test]
fn a_pod_is_picked_by_its_type_byte_not_by_a_name_they_both_share() {
let minus = Some(zwift::DeviceKind::from_type_byte(0x0B));
let plus = Some(zwift::DeviceKind::from_type_byte(0x0A));
let want_minus = PodSelector::Pod(PodId::Minus);
assert!(want_minus.matches_parts("f4:c4:59:03:a1:8e", minus));
assert!(!want_minus.matches_parts("c0:4a:0e:f9:a8:78", plus));
let want_plus = PodSelector::Pod(PodId::Plus);
assert!(want_plus.matches_parts("c0:4a:0e:f9:a8:78", plus));
assert!(!want_plus.matches_parts("f4:c4:59:03:a1:8e", minus));
}
#[test]
fn an_address_identifies_a_pod_that_has_stopped_advertising() {
// Reconnect runs against a pod that may no longer be broadcasting its
// manufacturer data. Insisting on the type byte here would mean never
// finding it again.
let s = PodSelector::Address("F4:C4:59:03:A1:8E".into());
assert!(s.matches_parts("f4:c4:59:03:a1:8e", None));
assert!(!s.matches_parts("c0:4a:0e:f9:a8:78", None));
}
#[test]
fn any_takes_a_click_but_not_a_trainer_speaking_the_zwift_protocol() {
// The D100 advertises Zwift's custom service with no manufacturer data.
// Connecting to it as a controller would wedge the trainer link.
let s = PodSelector::Any;
assert!(s.matches_parts("aa", Some(zwift::DeviceKind::from_type_byte(0x0A))));
assert!(!s.matches_parts("aa", None));
assert!(!s.matches_parts("aa", Some(zwift::DeviceKind::Unknown(0x77))));
}
#[test]
fn events_compare_by_value() {
assert_eq!(
+150 -1
View File
@@ -37,6 +37,7 @@ use crate::error::FtmsError;
use crate::indoor_bike_data::{self, hex};
use crate::scan::{self, TrainerSelector};
use crate::uuids;
use crate::zwift;
// ---------------------------------------------------------------------------
// Configuration
@@ -316,6 +317,7 @@ impl FtmsClient {
halted: false,
ride_start: Instant::now(),
last_health_check: Instant::now(),
zwift_cadence: None,
};
tokio::spawn(actor.run(cmd_rx, Some(connected.notifications)));
@@ -581,9 +583,24 @@ struct Actor {
halted: bool,
ride_start: Instant,
last_health_check: Instant,
/// Latest cadence from the trainer's Zwift channel, and when it arrived.
///
/// This trainer declares no cadence in its Fitness Machine Feature bits and
/// sends none in Indoor Bike Data, so FTMS alone cannot supply it — see
/// [`crate::zwift::RidingData`]. Riding data arrives at about 1 Hz against
/// Indoor Bike Data's ~5 Hz, so it is held here and stamped onto the faster
/// stream rather than published as telemetry of its own.
zwift_cadence: Option<(f32, Instant)>,
}
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3);
/// How long a cadence from the Zwift channel stays believable.
///
/// Generous against its ~1 Hz rate, but finite: a rider who stops pedalling
/// must read as stopped, and a channel that dies must not leave the last
/// cadence under the ride forever — with speed taken from cadence × gear, a
/// stale value is a ride that keeps rolling on its own.
const ZWIFT_CADENCE_TTL: Duration = Duration::from_secs(4);
impl Actor {
async fn run(
@@ -658,7 +675,41 @@ impl Actor {
// -- inbound ----------------------------------------------------------
/// The Zwift-channel cadence, if one arrived recently enough to trust.
fn fresh_zwift_cadence(&self) -> Option<f32> {
self.zwift_cadence
.filter(|(_, at)| at.elapsed() < ZWIFT_CADENCE_TTL)
.map(|(rpm, _)| rpm)
}
fn handle_notification(&mut self, n: ValueNotification) {
if zwift::is_zwift_uuid(n.uuid) {
// Any characteristic in the trainer's Zwift service, not just the
// one the Click uses — which one a trainer talks on is undocumented.
// Only riding-data frames (type 0x03) mean anything; the channel
// carries other traffic and `decode_riding_data` rejects all of it.
match zwift::decode_riding_data(&n.value) {
Some(data) => {
tracing::debug!(
uuid = %n.uuid,
raw = %hex(&n.value),
cadence_rpm = data.cadence_rpm(),
power_w = data.power_w,
"zwift riding data"
);
self.zwift_cadence = Some((data.cadence_rpm(), Instant::now()));
}
// Logged, not swallowed. If cadence never arrives, the question
// is whether the channel is silent or merely speaking a dialect
// we do not decode, and only these lines can tell the two apart.
None => tracing::debug!(
uuid = %n.uuid,
raw = %hex(&n.value),
"zwift frame that is not riding data"
),
}
return;
}
if n.uuid == uuids::INDOOR_BIKE_DATA {
tracing::trace!(raw = %hex(&n.value), "0x2AD2 indoor bike data");
match indoor_bike_data::decode(&n.value) {
@@ -670,7 +721,14 @@ impl Actor {
"indoor bike data had trailing bytes we do not understand"
);
}
let telemetry = data.to_telemetry(self.elapsed_ms());
let mut telemetry = data.to_telemetry(self.elapsed_ms());
// FTMS carries no cadence on this trainer; the Zwift
// channel does. Fill it in, but never overwrite a cadence
// FTMS did report — a trainer that declares one is the
// better authority on it.
if telemetry.cadence_rpm.is_none() {
telemetry.cadence_rpm = self.fresh_zwift_cadence();
}
let _ = self.telemetry_tx.send(telemetry);
let _ = self.events_tx.send(FtmsEvent::Telemetry(telemetry));
}
@@ -1344,6 +1402,15 @@ async fn setup_session(
}
}
// The trainer's Zwift channel, which is where its cadence lives — FTMS on
// this hardware reports none at all (see `zwift::RidingData`), and with the
// speed taken from cadence × gear, no cadence means no ride.
//
// Optional in every sense: a trainer without the service simply rides
// without cadence, and nothing here may cost us the FTMS session that is
// already working. Hence the warnings rather than `?`.
subscribe_zwift_channel(&peripheral, &chars).await;
let mut notif_rx = notif_rx;
// FR-2.1: control first, everything else after.
@@ -1384,6 +1451,88 @@ async fn setup_session(
))
}
/// Subscribe to the trainer's Zwift channel and complete the `RideOn`
/// handshake, so it starts reporting cadence.
///
/// The handshake is the part that is easy to miss. Subscribing alone is not
/// enough: like the Click, the trainer's Zwift service says nothing until it
/// has been greeted, so a client that only subscribes sits there receiving
/// silence and concludes the device has no cadence to give. Every symptom of
/// that is indistinguishable from a trainer that genuinely has none.
///
/// Entirely best-effort. A trainer without the service, or one that refuses the
/// write, rides without cadence; none of it may disturb the FTMS session, which
/// is the part that actually controls the resistance.
async fn subscribe_zwift_channel(
peripheral: &Peripheral,
chars: &std::collections::BTreeSet<Characteristic>,
) {
let zwift_char = |uuid: Uuid| chars.iter().find(|c| c.uuid == uuid).cloned();
let zwift_chars: Vec<&Characteristic> = chars
.iter()
.filter(|c| zwift::is_zwift_uuid(c.uuid))
.collect();
if zwift_chars.is_empty() {
tracing::info!("trainer exposes no Zwift channel; it will report no cadence");
return;
}
// Listed because which characteristic a *trainer* publishes riding data on
// is not documented — the Click's is `ASYNC`, and betting on that being
// universal is what this list exists to disprove or confirm.
for c in &zwift_chars {
tracing::debug!(uuid = %c.uuid, properties = ?c.properties, "zwift characteristic");
}
// Subscribe to every notifying characteristic in the service, exactly as
// the Click path does, rather than guessing which one carries cadence. A
// subscription we did not need costs nothing; the one we failed to make
// costs the entire ride, because speed comes from cadence.
let mut subscribed = 0;
for c in &zwift_chars {
if !c
.properties
.intersects(btleplug::api::CharPropFlags::NOTIFY | btleplug::api::CharPropFlags::INDICATE)
{
continue;
}
match peripheral.subscribe(c).await {
Ok(()) => {
subscribed += 1;
tracing::debug!(uuid = %c.uuid, "subscribed to a Zwift characteristic");
}
Err(e) => tracing::warn!(uuid = %c.uuid, error = %e, "Zwift subscribe failed"),
}
}
if subscribed == 0 {
tracing::warn!("the trainer's Zwift service notifies on nothing; no cadence");
return;
}
// Subscribed above, before writing, so the reply cannot outrun us.
let Some(sync_rx) = zwift_char(zwift::SYNC_RX) else {
// Subscribed but ungreetable. Worth saying: if cadence never arrives,
// this line is the reason.
tracing::warn!("trainer has a Zwift channel but no sync RX to greet it on");
return;
};
let write_type = if sync_rx
.properties
.contains(btleplug::api::CharPropFlags::WRITE_WITHOUT_RESPONSE)
{
WriteType::WithoutResponse
} else {
WriteType::WithResponse
};
match peripheral
.write(&sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
.await
{
Ok(()) => tracing::info!("greeted the trainer's Zwift channel; expecting cadence"),
Err(e) => tracing::warn!(error = %e, "Zwift handshake write failed; expect no cadence"),
}
}
async fn read_capabilities(
peripheral: &Peripheral,
find: &impl Fn(Uuid) -> Option<Characteristic>,
+2 -2
View File
@@ -57,7 +57,7 @@ pub mod scan;
pub mod uuids;
pub mod zwift;
pub use click::{ClickClient, ClickConfig, ClickEvent};
pub use click::{ClickClient, ClickConfig, ClickEvent, PodSelector};
pub use capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
UnsupportedTarget,
@@ -74,5 +74,5 @@ pub use scan::{
pub use uuids::FITNESS_MACHINE_SERVICE;
pub use zwift::{
Button, ButtonBitmask, ClickButtons, DeviceKind as ZwiftDeviceKind,
MessageType as ZwiftMessageType,
MessageType as ZwiftMessageType, PodId,
};
+40 -4
View File
@@ -59,6 +59,19 @@ impl DiscoveredDevice {
.and_then(|d| zwift::DeviceKind::from_manufacturer_data(d))
}
/// True when this is a controller pod the app can drive — a Click, not a
/// trainer that merely speaks the Zwift protocol. The D100 advertises the
/// custom service with no manufacturer data, which is exactly the case this
/// separates out.
pub fn is_click_pod(&self) -> bool {
self.zwift_kind().is_some_and(|k| k.is_click())
}
/// Which pod of a pair this is, where the advertisement says so (FR-1.4).
pub fn pod_id(&self) -> Option<zwift::PodId> {
self.zwift_kind().and_then(|k| k.pod_id())
}
/// Best-effort human label.
pub fn label(&self) -> String {
match &self.name {
@@ -224,7 +237,29 @@ pub async fn find_peripheral(
selector: &TrainerSelector,
timeout: Duration,
) -> Result<Peripheral, FtmsError> {
let kind = selector.scan_kind();
find_matching(
adapter,
selector.scan_kind(),
timeout,
&selector.describe(),
|d| selector.matches(d),
)
.await
}
/// Scan until a peripheral satisfying `matches` appears, or `timeout` elapses.
///
/// The predicate form exists because a controller is not selected the way a
/// trainer is: a Click pod is picked out by the type byte in its manufacturer
/// data (§2.3.1), which no `TrainerSelector` variant can express. `what`
/// describes the search well enough to read in an error message.
pub async fn find_matching(
adapter: &Adapter,
kind: ScanKind,
timeout: Duration,
what: &str,
matches: impl Fn(&DiscoveredDevice) -> bool,
) -> Result<Peripheral, FtmsError> {
adapter.start_scan(kind.filter()).await?;
let deadline = tokio::time::Instant::now() + timeout;
@@ -234,12 +269,13 @@ pub async fn find_peripheral(
'search: loop {
for p in adapter.peripherals().await?.into_iter() {
if let Some(d) = describe(&p).await {
if selector.matches(&d) {
if matches(&d) {
tracing::info!(
address = %d.address,
name = d.label(),
rssi = ?d.rssi,
"matched trainer"
%what,
"matched peripheral"
);
found = Some(p);
break 'search;
@@ -256,7 +292,7 @@ pub async fn find_peripheral(
tracing::debug!(error = %e, "stop_scan failed");
}
found.ok_or_else(|| FtmsError::NotFound(selector.describe()))
found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
}
#[cfg(test)]
+132 -10
View File
@@ -103,17 +103,73 @@ pub fn is_zwift_uuid(uuid: Uuid) -> bool {
// Device discrimination
// ---------------------------------------------------------------------------
/// Which of a pair of pods this is.
///
/// A Click v2 is **two independent peripherals** (§2.3.1, FR-1.4), each with
/// its own address, battery and link — so the app has to track two of
/// everything, and the rider has to be told which of the two is missing.
///
/// They are named for the **shift paddle** each one carries rather than for the
/// side of the bar they clamp to. Left and right would be a guess: nothing in
/// the advertisement says which end of the handlebar a pod is on, and a rider
/// who mounts them the other way round makes the label a lie. The paddle is
/// printed on the pod, so `+` and `` are checkable by eye and by pressing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PodId {
/// The pod carrying the `` paddle: shift down, and the D-pad.
Minus,
/// The pod carrying the `+` paddle: shift up, and the lettered face buttons.
Plus,
}
impl PodId {
pub const BOTH: [PodId; 2] = [PodId::Minus, PodId::Plus];
pub fn other(self) -> Self {
match self {
PodId::Minus => PodId::Plus,
PodId::Plus => PodId::Minus,
}
}
/// Lowercase, stable — used as a key by the app and the webview.
pub fn as_str(self) -> &'static str {
match self {
PodId::Minus => "minus",
PodId::Plus => "plus",
}
}
/// How the pod is written on screen.
pub fn symbol(self) -> &'static str {
match self {
PodId::Minus => "",
PodId::Plus => "+",
}
}
/// The paddle whose press proves a pod is the one we filed it under.
pub fn paddle(self) -> Button {
match self {
PodId::Minus => Button::Minus,
PodId::Plus => Button::Plus,
}
}
}
/// What kind of Zwift peripheral is advertising, from the first byte of its
/// manufacturer data (§2.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
/// `0x09` — Click v1. Unencrypted; the easy case.
ClickV1,
/// `0x0A` / `0x0B` — Click v2. The target hardware (§2.3).
ClickV2,
/// `0x03` — Play, left pod. *Unverified.*
/// `0x0B` / `0x0A` — Click v2. The target hardware (§2.3). **One byte per
/// pod, not a version marker** — confirmed on our own pair, where
/// `f4:c4:59:03:a1:8e` advertises `0x0B` and `c0:4a:0e:f9:a8:78` `0x0A`.
ClickV2(PodId),
/// `0x03` — Play, left-hand pod (`` paddle). *Unverified.*
PlayLeft,
/// `0x02` — Play, right pod. *Unverified.*
/// `0x02` — Play, right-hand pod (`+` paddle). *Unverified.*
PlayRight,
/// A Zwift device we do not have a byte for. Carries the raw value so the
/// probe can report it rather than swallow it.
@@ -122,10 +178,21 @@ pub enum DeviceKind {
impl DeviceKind {
/// Classify from the device type byte.
///
/// Which v2 byte is which pod is a **starting guess**: §2.3.1 confirms one
/// byte per pod, but no capture names them. It is read by analogy with
/// Play, whose `+` pod is the lower byte (`0x02`) and `` pod the higher
/// (`0x03`), and it agrees with the one thing we did observe — the D-pad
/// frames, which live beside the `` paddle, came from the `0x0B` pod.
///
/// The guess does not have to be right. The app confirms each pod the first
/// time a paddle is pressed on it, and swaps the pair if the pods answer to
/// the other name.
pub fn from_type_byte(b: u8) -> Self {
match b {
0x09 => DeviceKind::ClickV1,
0x0A | 0x0B => DeviceKind::ClickV2,
0x0B => DeviceKind::ClickV2(PodId::Minus),
0x0A => DeviceKind::ClickV2(PodId::Plus),
0x03 => DeviceKind::PlayLeft,
0x02 => DeviceKind::PlayRight,
other => DeviceKind::Unknown(other),
@@ -140,13 +207,28 @@ impl DeviceKind {
/// True for a Click of either generation — the devices this app drives.
pub fn is_click(self) -> bool {
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2)
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2(_))
}
/// Which pod of a pair this is, where the advertisement says so.
///
/// `None` for a v1 Click, which is a single unit, and for anything we
/// cannot place — a pod with no id is still connectable, it just cannot be
/// filed under one of the two slots on the connection screen.
pub fn pod_id(self) -> Option<PodId> {
match self {
DeviceKind::ClickV2(side) => Some(side),
DeviceKind::PlayLeft => Some(PodId::Minus),
DeviceKind::PlayRight => Some(PodId::Plus),
DeviceKind::ClickV1 | DeviceKind::Unknown(_) => None,
}
}
pub fn describe(self) -> String {
match self {
DeviceKind::ClickV1 => "Zwift Click v1".into(),
DeviceKind::ClickV2 => "Zwift Click v2".into(),
DeviceKind::ClickV2(PodId::Minus) => "Zwift Click v2 ( pod)".into(),
DeviceKind::ClickV2(PodId::Plus) => "Zwift Click v2 (+ pod)".into(),
DeviceKind::PlayLeft => "Zwift Play (left)".into(),
DeviceKind::PlayRight => "Zwift Play (right)".into(),
DeviceKind::Unknown(b) => format!("unrecognised Zwift device (type byte 0x{b:02x})"),
@@ -620,8 +702,17 @@ mod tests {
#[test]
fn device_type_bytes_classify() {
assert_eq!(DeviceKind::from_type_byte(0x09), DeviceKind::ClickV1);
assert_eq!(DeviceKind::from_type_byte(0x0A), DeviceKind::ClickV2);
assert_eq!(DeviceKind::from_type_byte(0x0B), DeviceKind::ClickV2);
// §2.3.1: one byte per pod, not a version marker. The two v2 bytes must
// therefore land on *different* pods — a mapping that collapsed them
// would put both pods in one slot and lose the other entirely.
assert_eq!(
DeviceKind::from_type_byte(0x0B),
DeviceKind::ClickV2(PodId::Minus)
);
assert_eq!(
DeviceKind::from_type_byte(0x0A),
DeviceKind::ClickV2(PodId::Plus)
);
assert_eq!(DeviceKind::from_type_byte(0x02), DeviceKind::PlayRight);
assert_eq!(DeviceKind::from_type_byte(0xFE), DeviceKind::Unknown(0xFE));
assert!(DeviceKind::from_type_byte(0x0B).is_click());
@@ -632,11 +723,42 @@ mod tests {
fn manufacturer_data_needs_at_least_one_byte() {
assert_eq!(
DeviceKind::from_manufacturer_data(&[0x0A, 0x00]),
Some(DeviceKind::ClickV2)
Some(DeviceKind::ClickV2(PodId::Plus))
);
assert_eq!(DeviceKind::from_manufacturer_data(&[]), None);
}
#[test]
fn a_pair_of_pods_covers_both_ids_and_nothing_else_claims_one() {
let minus = DeviceKind::from_type_byte(0x0B).pod_id();
let plus = DeviceKind::from_type_byte(0x0A).pod_id();
assert_eq!(minus, Some(PodId::Minus));
assert_eq!(plus, Some(PodId::Plus));
assert_eq!(minus.map(PodId::other), plus);
// A v1 Click is a single unit: giving it an id would fill a slot the
// rider has no pod for.
assert_eq!(DeviceKind::ClickV1.pod_id(), None);
assert_eq!(DeviceKind::Unknown(0x77).pod_id(), None);
}
#[test]
fn pod_names_are_the_keys_the_webview_switches_on() {
assert_eq!(PodId::Minus.as_str(), "minus");
assert_eq!(PodId::Plus.as_str(), "plus");
assert_eq!(PodId::BOTH, [PodId::Minus, PodId::Plus]);
}
#[test]
fn a_pod_is_named_for_the_paddle_that_proves_which_one_it_is() {
// The whole point of naming them `+` and `` rather than left and
// right: pressing the paddle settles the question, and no guess about
// how the pods are mounted can make the label wrong.
assert_eq!(PodId::Plus.paddle(), Button::Plus);
assert_eq!(PodId::Minus.paddle(), Button::Minus);
assert_eq!(PodId::Plus.symbol(), "+");
}
#[test]
fn handshake_is_ride_on_plus_suffix() {
assert_eq!(
+174 -6
View File
@@ -30,17 +30,35 @@
//! wrong: it would let a rider spin up a 15% wall at 45 km/h without ever
//! producing the watts that requires, and gradient would become decoration.
//!
//! The loop closes on cadence. For a given road speed, the selected gear
//! # What commands the trainer
//!
//! [`Gearing::load_gradient_pct`] — a direct computation, not a feedback loop.
//! A gear changes the leverage between crank and wheel, so the load it implies
//! is the road force scaled by the gear's development relative to the bike's
//! real one. Shifting therefore lands on the pedals the moment it happens.
//!
//! An earlier design servoed the load toward a cadence target instead. It was
//! abandoned because a servo can only find the right load by first being wrong:
//! at a gentle enough gain not to oscillate against the rider's own cadence
//! variation, a shift took seconds to be felt, which is not what a shift is.
//!
//! # What the cadence loop is still for
//!
//! The servo ([`Gearing::update`], [`Gearing::correction_pct`]) survives as a
//! *readout*, not a control input. For a given road speed the selected gear
//! implies a cadence:
//!
//! ```text
//! target_cadence = road_speed × 60 / development
//! ```
//!
//! If the rider is turning faster than that they are spinning out, so add load;
//! slower, and they are grinding, so shed it. The trainer's own cadence reading
//! closes the loop, which is why this had to wait for the Zwift-channel decode
//! (§2.1.1) — FTMS on this trainer reports no cadence at all.
//! and the signed distance between that and the rider's actual cadence says
//! whether they are spinning out or grinding — worth showing them, and worth
//! recording, but no longer added to what the trainer is asked for. Nothing
//! reads it into the control path; see `RideSession::tick`.
//!
//! Either way this had to wait for the Zwift-channel decode (§2.1.1) — FTMS on
//! this trainer reports no cadence at all.
use serde::{Deserialize, Serialize};
@@ -48,6 +66,14 @@ use serde::{Deserialize, Serialize};
/// enough to recover a spun-out descent, bounded so a runaway loop cannot ask
/// for a cliff.
const MAX_CORRECTION_PCT: f32 = 8.0;
/// Widest load the gear model may command, in gradient percent.
///
/// Separate from the servo's bound, and deliberately so: this one caps what the
/// rider is *asked to push against*, not how far a feedback loop may wander. A
/// top gear on a steep climb legitimately reaches well past the servo's range,
/// and 16% is about the steepest thing worth reproducing under a rider before
/// it stops being training and starts being a wall.
const MAX_LOAD_PCT: f32 = 16.0;
/// Gradient percent applied per rpm of cadence error, per second. Deliberately
/// gentle: shifting should settle over a second or two, not snap, and an
/// aggressive gain oscillates against the rider's own cadence variation.
@@ -174,6 +200,12 @@ impl Gearing {
self.correction_pct += error * GAIN_PCT_PER_RPM_S * dt;
self.correction_pct =
self.correction_pct.clamp(-MAX_CORRECTION_PCT, MAX_CORRECTION_PCT);
} else {
// Inside the deadband, bleed off. Holding the last value
// instead would freeze whatever transient the rider passed
// through on the way to riding the gear correctly, and
// leave the readout claiming an error that is over.
self.decay(dt);
}
}
None => self.decay(dt),
@@ -365,13 +397,149 @@ impl Gearing {
let scaled = f * (self.development_m() / physical);
let pct = scaled / (mass * crate::physics::GRAVITY) * 100.0;
if pct.is_finite() {
pct.clamp(-MAX_CORRECTION_PCT * 2.0, MAX_CORRECTION_PCT * 2.0)
pct.clamp(-MAX_LOAD_PCT, MAX_LOAD_PCT)
} else {
0.0
}
}
}
impl Gearing {
/// The watts the road is asking for at this speed — what to command when
/// the load is expressed as power rather than slope.
///
/// Simply `F × v`, because that is what power is. Note there is no gear
/// term: at a *given speed* the power required is the same in every gear,
/// which is not a flaw in the model but the definition of a gear. The gear
/// enters through the speed, since speed is cadence × development — so
/// shifting up at the same cadence raises the speed and with it the demand,
/// while what changes at the pedal is the force (see
/// [`Gearing::pedal_force_n`]) and the cadence needed to hold it.
///
/// On a trainer whose power target is a ceiling rather than a setpoint,
/// commanding this makes the ride self-correcting: the rider accelerates
/// when they exceed it and slows when they fall short, and next tick it is
/// recomputed at the new speed.
pub fn load_power_w(&self, resistive_n: f32, speed_mps: f32) -> f32 {
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
let v = if speed_mps.is_finite() { speed_mps.max(0.0) } else { 0.0 };
let w = f * v;
// A descent asks for negative power, which no brake can supply — the
// honest floor is zero, and the safety limits raise it to whatever the
// trainer's minimum really is.
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
/// Force the rider's leg feels at the pedal, newtons.
///
/// Work is force × distance on both sides of the crank. Over one crank
/// revolution the pedal travels `2πr` and the bike travels `development`,
/// and the work done is the same quantity seen twice:
///
/// ```text
/// F_pedal × 2πr = F_road × development
/// ```
///
/// Note which development appears. The trainer is asked for
/// `F_road × (virtual / physical)` at the wheel, and the rider's crank
/// turns through the *physical* gear, so the two physical terms cancel and
/// what the leg feels is `F_road × virtual / 2πr` — precisely what a real
/// bike in that gear would feel. That the virtual gear lands on the pedal
/// exactly as a real one would is the check that the whole scheme is
/// mechanically honest, not merely plausible.
///
/// Purely a readout: nothing downstream of the wheel changes what is
/// commanded. What it is *for* is judging a gear ladder — 40 N is freewheel
/// -light and 600 N is a gear nobody can turn, and neither is visible from
/// a gradient.
pub fn pedal_force_n(&self, resistive_n: f32, crank_length_m: f32) -> f32 {
let crank = if crank_length_m.is_finite() && crank_length_m > 0.01 {
crank_length_m
} else {
0.1725
};
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
let pedal = f * self.development_m() / (std::f32::consts::TAU * crank);
if pedal.is_finite() {
pedal
} else {
0.0
}
}
}
#[cfg(test)]
mod pedal_tests {
use super::*;
use crate::physics::resistive_force_n;
use crate::types::RiderConfig;
#[test]
fn a_longer_gear_is_heavier_at_the_pedal() {
let c = RiderConfig::default();
let f = resistive_force_n(8.0, 3.0, &c);
let mut g = Gearing::default();
g.set_gear(1);
let easy = g.pedal_force_n(f, c.crank_length_m);
g.set_gear(12);
let hard = g.pedal_force_n(f, c.crank_length_m);
assert!(hard > easy * 3.0, "top gear must be far heavier: {hard} vs {easy}");
}
#[test]
fn longer_cranks_lighten_the_pedal() {
// More leverage, less force, same work — the whole reason crank length
// is a number worth knowing.
let c = RiderConfig::default();
let f = resistive_force_n(8.0, 3.0, &c);
let g = Gearing::default();
let short = g.pedal_force_n(f, 0.165);
let long = g.pedal_force_n(f, 0.175);
assert!(long < short, "longer cranks must feel lighter: {long} vs {short}");
// Inverse in the radius, so the ratio is exact.
assert!((short / long - 0.175 / 0.165).abs() < 0.001);
}
#[test]
fn the_work_balance_holds_across_the_crank() {
// The identity the function encodes: over one crank revolution the
// rider does the same work whichever side of the crank you measure.
let c = RiderConfig::default();
let mut g = Gearing::default();
g.set_gear(8);
let road_n = resistive_force_n(8.0, 4.0, &c);
let pedal_n = g.pedal_force_n(road_n, c.crank_length_m);
let at_the_pedal = pedal_n * std::f32::consts::TAU * c.crank_length_m;
let at_the_road = road_n * g.development_m();
assert!(
(at_the_pedal - at_the_road).abs() < at_the_road * 1e-4,
"{at_the_pedal} J at the pedal vs {at_the_road} J at the road"
);
}
#[test]
fn a_flat_road_in_a_middling_gear_is_a_force_a_person_can_produce() {
// Sanity on the absolute scale, which is the only thing that makes this
// readout worth showing: easy flat riding should be tens of newtons.
let c = RiderConfig::default();
let g = Gearing::default();
let pedal = g.pedal_force_n(resistive_force_n(8.0, 0.0, &c), c.crank_length_m);
assert!(
(20.0..250.0).contains(&pedal),
"flat at 29 km/h should be light but not nothing: {pedal} N"
);
}
#[test]
fn absurd_cranks_do_not_produce_absurd_forces() {
let g = Gearing::default();
assert!(g.pedal_force_n(100.0, 0.0).is_finite());
assert!(g.pedal_force_n(100.0, f32::NAN).is_finite());
assert_eq!(g.pedal_force_n(f32::NAN, 0.1725), 0.0);
}
}
#[cfg(test)]
mod load_tests {
use super::*;
+88 -28
View File
@@ -11,7 +11,16 @@
//! F_gravity = m × g × sin(atan(gradient))
//! F_rolling = m × g × Crr × cos(atan(gradient))
//! F_aero = ½ × ρ × CdA × v²
//! a = (F_propulsive F_gravity F_rolling F_aero) / m
//! F_loss = rolling_loss_w / max(v, v_min)
//! a = (F_propulsive F_gravity F_rolling F_aero F_loss) / m
//! ```
//!
//! `F_loss` is the trainer's own fixed drag, held as a power because that is
//! how it presents. Dividing by speed makes it small when moving fast and large
//! when slowing, which is what brings a coast to a halt instead of an
//! asymptote.
//!
//! ```text
//! v += a × Δt (clamped at ≥ 0)
//! ```
@@ -66,9 +75,15 @@ pub struct PhysicsState {
impl PhysicsState {
/// Advance the simulation by `dt` seconds under `power_w` at `gradient_pct`.
///
/// Must model inertia (FR-7.3) — speed accelerates toward equilibrium
/// rather than snapping to it — and must never produce negative speed,
/// NaN, or unbounded values for any finite input.
/// **Not on the ride path.** The ride takes its speed from the drivetrain
/// (`RideSession::tick`), which has no inertia because a chain has none.
/// This integrator is retained as the reference implementation of the force
/// balance: it is what [`equilibrium_speed_mps`] — the coasting target — is
/// cross-validated against, and its tests are the coverage for [`Forces`].
/// Do not reintroduce it as a speed source without saying why.
///
/// Never produces negative speed, NaN, or unbounded values for any finite
/// input.
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
if dt <= 0.0 {
@@ -116,32 +131,64 @@ impl PhysicsState {
}
}
/// Pull the modelled speed toward one the trainer actually measured.
/// Advance the ride at a speed the drivetrain dictates, rather than one
/// integrated from the force balance.
///
/// The model knows what a bike *would* do for a given power and gradient;
/// the trainer knows how fast its flywheel is really turning. Neither alone
/// is right on a single-cog drivetrain: pure physics lets the rider "coast"
/// downhill at 39 km/h while spinning out against no resistance, and pure
/// trainer speed would cap descents at whatever cadence the one gear allows.
/// On a bike the wheel is locked to the cranks: road speed *is* cadence ×
/// development, and no force balance gets a say in it. When the trainer is
/// reporting cadence this is the honest speed, and the force balance moves
/// to the other side of the loop — it decides how *hard* that cadence is to
/// hold (see [`crate::gearing::Gearing::load_gradient_pct`]), not how fast
/// it carries the rider.
///
/// `weight` is the fraction of the gap closed **per second**, so the result
/// does not depend on tick rate — a 4 Hz and a 10 Hz loop converge the same.
pub fn correct_toward(&mut self, measured_mps: f32, weight: f32, dt: f32) {
if !measured_mps.is_finite() || measured_mps < 0.0 || !dt.is_finite() || dt <= 0.0 {
/// `tau_s` is the time constant of a first-order approach to `target_mps`.
/// Cadence arrives a few times a second and varies within a single pedal
/// stroke; stepping the speed straight onto it would make the readout jump
/// and the distance integral gritty. Small enough that a real change of
/// pace still lands promptly.
pub fn advance_at(&mut self, target_mps: f32, gradient_pct: f32, tau_s: f32, dt: f32) {
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
if dt <= 0.0 {
return;
}
let w = weight.clamp(0.0, 1.0);
if w == 0.0 {
return;
}
// Fraction of the gap to close this tick, from the per-second rate.
let alpha = 1.0 - (1.0 - w).powf(dt.min(MAX_DT_S));
let corrected = self.speed_mps + (measured_mps - self.speed_mps) * alpha;
if corrected.is_finite() {
self.speed_mps = corrected.clamp(0.0, MAX_SPEED_MPS);
let target = sanitise(target_mps, 0.0).clamp(0.0, MAX_SPEED_MPS);
let tau = sanitise(tau_s, 0.0).max(0.0);
let v0 = self.speed_mps.clamp(0.0, MAX_SPEED_MPS);
let v1 = if tau <= 0.0 {
target
} else {
// Exact solution of the first-order lag over the step, so the
// result does not depend on tick rate.
let alpha = 1.0 - (-dt / tau).exp();
v0 + (target - v0) * alpha
};
let v1 = if v1.is_finite() { v1.clamp(0.0, MAX_SPEED_MPS) } else { 0.0 };
// An exponential approach to zero never arrives, and a ride left
// reporting 6e-45 m/s is stopped in every sense except the one the
// readout uses. Below a millimetre a second, call it stopped.
self.speed_mps = if target <= 0.0 && v1 < 0.001 { 0.0 } else { v1 };
let gradient =
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
let sin_theta = (gradient / 100.0).atan().sin();
let ds = (0.5 * (v0 + self.speed_mps) * dt) as f64;
self.distance_m += ds;
let climb = ds as f32 * sin_theta;
if climb > 0.0 {
self.elevation_gain_m += climb;
}
}
// There was a `correct_toward` here, blending the modelled speed toward the
// trainer's own. It is gone on purpose. A flywheel keeps turning long after
// the rider stops, so its speed is not evidence of road speed, and blending
// toward it held the ride at 22 km/h up a 3.5% climb on zero watts —
// the model wanted to decelerate and the flywheel outvoted it. Speed now
// comes from the drivetrain, or from the road when the rider is coasting;
// see `RideSession::tick`.
pub fn speed_kph(&self) -> f32 {
self.speed_mps * 3.6
}
@@ -160,6 +207,8 @@ struct Forces {
resistive_n: f32,
/// `½ρ·CdA`; multiplied by v² to give drag.
drag_k: f32,
/// Fixed power loss, watts. Becomes a force by dividing by speed.
loss_w: f32,
mass_kg: f32,
}
@@ -184,6 +233,7 @@ impl Forces {
Self {
wheel_power_w: power * efficiency,
loss_w: sanitise(cfg.rolling_loss_w, 0.0).max(0.0),
sin_theta: theta.sin(),
resistive_n: mass * GRAVITY * (theta.sin() + crr * theta.cos()),
drag_k: 0.5 * rho * cda,
@@ -191,12 +241,18 @@ impl Forces {
}
}
/// The fixed loss as a force at this speed. Divided by the same floor the
/// propulsive term uses, so neither blows up at a standstill.
fn loss_n(&self, v: f32) -> f32 {
self.loss_w / v.max(MIN_SPEED_MPS)
}
fn acceleration(&self, v: f32) -> f32 {
let propulsive = self.wheel_power_w / v.max(MIN_SPEED_MPS);
// Rolling resistance and gravity are folded together, so at a
// standstill on the flat the net is a small negative that the ≥0 clamp
// absorbs — the rider does not roll backwards.
let net = propulsive - self.resistive_n - self.drag_k * v * v;
let net = propulsive - self.resistive_n - self.drag_k * v * v - self.loss_n(v);
let a = net / self.mass_kg;
if a.is_finite() {
a
@@ -263,7 +319,7 @@ pub fn resistive_force_n(speed_mps: f32, gradient_pct: f32, cfg: &RiderConfig) -
} else {
0.0
};
let f = forces.resistive_n + forces.drag_k * v * v;
let f = forces.resistive_n + forces.drag_k * v * v + forces.loss_n(v);
if f.is_finite() {
f
} else {
@@ -305,14 +361,18 @@ mod tests {
#[test]
fn equilibrium_matches_hand_computed_flat_case() {
// 250 W on the flat with the default rider: solve P·η = F_roll·v + k·v³.
// 250 W on the flat with the default rider. This is a *power* balance
// — the force balance multiplied through by v — so the residual is in
// watts, and the fixed loss enters it as itself rather than divided by
// speed: P·η = loss + F_roll·v + k·v³.
let c = cfg();
let v = equilibrium_speed_mps(250.0, 0.0, &c);
let m = c.total_mass_kg();
let f_roll = m * GRAVITY * c.crr;
let drag = 0.5 * c.air_density * c.cda;
let balance = 250.0 * c.drivetrain_efficiency - (f_roll * v + drag * v * v * v);
assert!(balance.abs() < 0.5, "residual force {balance} N at v={v}");
let balance =
250.0 * c.drivetrain_efficiency - (c.rolling_loss_w + f_roll * v + drag * v * v * v);
assert!(balance.abs() < 0.5, "residual power {balance} W at v={v}");
// Sanity: a 75 kg rider at 250 W on the flat sits around 40 km/h.
assert!((35.0..45.0).contains(&(v * 3.6)), "{} km/h", v * 3.6);
}
+797 -55
View File
@@ -8,7 +8,8 @@ use crate::gearing::Gearing;
use crate::physics::PhysicsState;
use crate::profile::{Position, Profile};
use crate::types::{
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
ControlMode, ControlTarget, LoadChannel, RideSnapshot, RiderConfig, SafetyLimits, SpeedSource,
Telemetry,
};
/// Something the session wants the outside world to do or know about.
@@ -38,6 +39,40 @@ pub enum RideStatus {
/// inside that without a timer, and avoids churning the trainer with values it
/// cannot resolve anyway.
const GRADIENT_EPSILON_PCT: f32 = 0.05;
/// Grid the *computed* road load is snapped to, watts.
///
/// The road power goes as v³, so an unquantised target would change on nearly
/// every tick as the speed wanders and spend the whole write budget on
/// differences no rider can feel. Snapping to five watts rate-limits the writes
/// by construction, without an epsilon in `changed_meaningfully` — which would
/// also have coarsened the deliberate wattages an ERG profile asks for, and
/// those must arrive exactly as written.
const LOAD_POWER_STEP_W: f32 = 5.0;
/// Time constant for the speed following cadence × gear, seconds.
///
/// Cadence arrives a few times a second and genuinely varies within one pedal
/// stroke, so the speed is filtered rather than stepped. Short enough that a
/// shift or a surge shows up almost at once, long enough that the readout does
/// not flicker at the rate the pedals go round.
const DRIVETRAIN_TAU_S: f32 = 0.8;
/// Above this the rider is driving the bike; at or below it they are coasting
/// and the flywheel is merely spinning down. Low enough that soft pedalling
/// still counts as riding, high enough that a trainer reporting a few stray
/// watts at rest does not.
///
/// This is the only reliable way to tell the two apart on this hardware:
/// cadence cannot, because the flywheel keeps the cranks turning for a rider
/// who has stopped.
const COASTING_POWER_W: f32 = 15.0;
/// Time constant for the speed settling to what a gradient sustains on no
/// power, seconds. Longer than the drivetrain's, because a rider easing off
/// should feel the bike run down rather than hit a wall — but nothing like the
/// minutes real momentum would give them, which on a climb is the difference
/// between stopping and freewheeling uphill for a quarter of a kilometre.
const COAST_TAU_S: f32 = 1.5;
pub struct RideSession {
pub config: RiderConfig,
@@ -55,6 +90,8 @@ pub struct RideSession {
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
/// fast the rider travels for a given power.
pub gearing: Gearing,
/// Which rule decided the speed on the last tick. Diagnostic only.
speed_source: SpeedSource,
elapsed_ms: u64,
last_target: Option<ControlTarget>,
}
@@ -72,6 +109,7 @@ impl RideSession {
manual_resistance: 0,
erg_watts: 150,
gearing: Gearing::default(),
speed_source: SpeedSource::Stopped,
elapsed_ms: 0,
last_target: None,
}
@@ -154,6 +192,49 @@ impl RideSession {
self.last_target
}
/// Cadence: measured if the trainer reports it, inferred from wheel speed
/// if not.
///
/// The inference is exact, not a fudge. With a Zwift Cog there is one
/// sprocket and no freewheel between the cranks and the flywheel, so cadence
/// and wheel speed are locked by the bike's physical development:
///
/// ```text
/// cadence = wheel_speed × 60 / physical_development
/// ```
///
/// It is the same rigid drivetrain the virtual gears are already built on,
/// read in the other direction — and the trainer's own speed is the one
/// signal this hardware reports reliably on every Indoor Bike Data packet.
/// Depending on it instead of the vendor Zwift channel takes the whole ride
/// off an undocumented protocol and puts it on a standard FTMS field.
///
/// This is not a workaround for a bug we might later fix. The D100 is a
/// rebadged Magene T110 with cadence disabled in firmware, and the absence
/// is confirmed by others rather than only observed here:
/// <https://github.com/cagnulein/qdomyos-zwift/issues/3282> — "Cadence is
/// not broadcasted, at least not in the 0.106 firmware version". Inference
/// is the only source of cadence this trainer will ever have.
///
/// Note what this makes the virtual speed: `trainer_speed × virtual /
/// physical`. Shifting up covers more ground per flywheel revolution and
/// costs proportionally more to turn — which is exactly what a gear is.
///
/// A measured cadence still wins where one exists. It is the same number on
/// this drivetrain, and on a bike with a freewheel it would be the only
/// honest one.
fn effective_cadence(&self, telemetry: &Telemetry) -> Option<f32> {
if let Some(rpm) = telemetry.cadence_rpm.filter(|c| c.is_finite() && *c >= 0.0) {
return Some(rpm);
}
let speed_kph = telemetry.speed_kph.filter(|s| s.is_finite() && *s >= 0.0)?;
let development = self.config.physical_development_m;
if !development.is_finite() || development <= 0.1 {
return None;
}
Some((speed_kph / 3.6 * 60.0 / development).clamp(0.0, 250.0))
}
/// Advance the ride by one tick.
///
/// Feeds telemetry into the physics model, advances the profile, and
@@ -180,25 +261,71 @@ impl RideSession {
// A trainer that reports no power is a trainer the rider is not
// pushing; nothing here may panic on a partial FTMS packet.
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
self.physics
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
// Pull the model back toward what the flywheel is really doing.
// Pure physics lets a spun-out rider "coast" downhill at 39 km/h.
// Keep the cadence servo running purely as a readout of how far the
// rider is from the cadence their gear implies; the load itself is
// computed directly below rather than servoed toward it.
self.gearing
.update(telemetry.cadence_rpm, self.physics.speed_mps, dt);
let gradient = self.simulated_gradient_pct();
// Correct toward the trainer's own measured speed — NOT toward
// cadence x virtual gear. That would be circular: the servo's target
// cadence is derived from speed, so making speed follow cadence
// leaves it nothing to correct and the gears stop doing anything.
// Physics owns the speed; cadence is the servo's feedback signal.
if let Some(kph) = telemetry.speed_kph {
self.physics
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
}
// The drivetrain decides the speed; the road decides how hard it is.
//
// A bike's wheel is locked to its cranks, so while the rider is
// driving it, road speed is cadence × development and nothing else.
// The force balance is not bypassed — it moves to the other end of
// the loop, setting the load the trainer applies (below). Too big a
// gear on a climb therefore does what it does outdoors: the load
// becomes unholdable, cadence falls, and the speed falls with it.
//
// Power, not cadence, is what says the rider is driving. On a
// direct-drive trainer the flywheel keeps the cranks turning after
// the rider stops, so cadence alone cannot tell riding from
// freewheeling — it reads a healthy 80 rpm for someone doing
// nothing at all.
let driving = power_w > COASTING_POWER_W;
let cadence = self.effective_cadence(&telemetry);
let (source, target_mps, tau) = match (cadence, driving) {
// Riding: the drivetrain owns the speed outright.
(Some(rpm), true) => (
SpeedSource::Drivetrain,
rpm / 60.0 * self.gearing.development_m(),
DRIVETRAIN_TAU_S,
),
// Coasting: the rider has stopped contributing, so the road
// decides alone. The target is the speed this gradient sustains
// on no power — zero on anything uphill, a real freewheeling
// speed on a descent.
//
// Approached with a lag rather than integrated as momentum, to
// stay consistent with the riding case: that has no momentum
// either (speed is cadence × gear, instantly), and a model that
// is momentum-free while pedalling but momentum-rich while
// coasting is two models, not one. It is also what the rider is
// actually experiencing — they are not moving, and stopping on
// a climb should feel like stopping.
(Some(_), false) => (
SpeedSource::Coasting,
crate::physics::equilibrium_speed_mps(0.0, gradient, &self.config),
COAST_TAU_S,
),
// Neither a measured cadence nor a wheel speed to infer one
// from: no drivetrain, no ride.
//
// There is deliberately no fallback to integrating power. That
// is a different model — one where gears change the load and not
// the speed — and substituting it silently means a dead
// telemetry feed presents as a ride that merely feels a bit off,
// for as long as it takes someone to notice. Stopping says it
// outright. This now requires the trainer to be reporting
// nothing at all, which is a genuine fault.
(None, _) => (SpeedSource::NoCadence, 0.0, COAST_TAU_S),
};
self.speed_source = source;
self.physics.advance_at(target_mps, gradient, tau, dt);
// Kept as a readout only, and fed the same cadence the speed was
// built from — passing the raw telemetry here would have it servo
// against a cadence the ride is not using. With a rigid drivetrain
// it is a tautology by construction, which is exactly what a rigid
// drivetrain means.
self.gearing
.update(cadence, self.physics.speed_mps, dt);
}
// Only a running ride commands the trainer. When paused or finished the
@@ -209,13 +336,47 @@ impl RideSession {
// still counts; the physics above already used the true route
// gradient, so the descent stays as fast as the terrain says.
let target = match target {
// Gear offset applies to what the TRAINER is asked for, not
// to what the physics simulated: shifting changes effort,
// not the speed the terrain implies.
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
percent: (percent + self.gearing.correction_pct())
.max(self.config.descent_load_floor_pct),
},
// Gearing applies to what the TRAINER is asked for, not to
// what the physics simulated: shifting changes effort, not
// the speed the terrain implies.
//
// This *replaces* the route gradient rather than adding to
// it. `resistive_force_n` is given that same gradient, so
// gravity is already inside the force being scaled; adding
// `percent` back on top would charge the rider for the hill
// twice.
ControlTarget::Gradient { percent } => {
let resistive_n = crate::physics::resistive_force_n(
self.physics.speed_mps,
percent,
&self.config,
);
// Same load, two ways of saying it. Which one the
// trainer can actually act on is a property of the
// hardware, not of the ride — see `LoadChannel`.
match self.config.load_channel {
LoadChannel::Gradient => {
let load_pct = self.gearing.load_gradient_pct(
resistive_n,
self.config.total_mass_kg(),
self.config.physical_development_m,
);
ControlTarget::Gradient {
percent: load_pct.max(self.config.descent_load_floor_pct),
}
}
LoadChannel::Power => {
let watts = self
.gearing
.load_power_w(resistive_n, self.physics.speed_mps);
let snapped = (watts / LOAD_POWER_STEP_W).round()
* LOAD_POWER_STEP_W;
ControlTarget::Power {
watts: snapped.clamp(0.0, u16::MAX as f32) as u16,
}
}
}
}
other => other,
};
let clamped = self.limits.clamp(target);
@@ -251,6 +412,32 @@ impl RideSession {
virtual_distance_m: self.physics.distance_m,
gradient_pct: self.simulated_gradient_pct(),
elevation_gain_m: self.physics.elevation_gain_m,
gear: self.gearing.gear(),
gear_count: self.gearing.gear_count(),
development_m: self.gearing.development_m(),
// A stationary rider has no implied cadence, and reporting the one
// their gear would imply at a speed they are not doing would be a
// number the screen cannot justify.
target_cadence_rpm: match self.status {
RideStatus::Running => self.gearing.target_cadence_rpm(self.physics.speed_mps),
_ => 0.0,
},
speed_source: match self.status {
RideStatus::Running => self.speed_source,
_ => SpeedSource::Stopped,
},
// A rider who is not riding is pushing against nothing.
pedal_force_n: match self.status {
RideStatus::Running => self.gearing.pedal_force_n(
crate::physics::resistive_force_n(
self.physics.speed_mps,
self.simulated_gradient_pct(),
&self.config,
),
self.config.crank_length_m,
),
_ => 0.0,
},
mode: self.mode,
target: self.last_target,
profile_progress: self.profile_progress(),
@@ -338,13 +525,34 @@ mod tests {
use super::*;
use crate::profile::{Block, Channel, Extent, Segment, Waveform};
/// A session on the **gradient** channel.
///
/// Most of this suite predates the power channel and tests the slope
/// arithmetic, which is still exactly right for `LoadChannel::Gradient`.
/// Named plainly rather than silently defaulted, so that a test asserting a
/// gradient target is visibly asking for one.
fn session() -> RideSession {
let config = RiderConfig {
load_channel: LoadChannel::Gradient,
..RiderConfig::default()
};
RideSession::new(config, SafetyLimits::default())
}
/// A session on the **power** channel — what actually ships.
fn power_session() -> RideSession {
RideSession::new(RiderConfig::default(), SafetyLimits::default())
}
/// A rider putting `watts` in and turning the cranks at a plausible rate.
///
/// Cadence is not optional garnish here: speed comes from the drivetrain,
/// so a sample with power but no cadence is a bike that is not moving. Any
/// test that wants a moving rider needs both.
fn powered(watts: i16) -> Telemetry {
Telemetry {
power_w: Some(watts),
cadence_rpm: Some(if watts > 0 { 85.0 } else { 0.0 }),
..Default::default()
}
}
@@ -505,19 +713,27 @@ mod tests {
// ---- gradient offset -------------------------------------------------
#[test]
fn manual_grade_commands_the_trim_directly() {
fn manual_grade_sets_the_road_and_the_load_follows_it() {
// The trim states what the road is doing. What the trainer is *asked*
// for is the load that road implies through the selected gear, which is
// not the same number — see `crate::gearing`. The two must not be
// conflated: the snapshot reports the road, the command reports the
// load, and only the first is the rider's trim verbatim.
let mut s = session();
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
let flat = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
s.nudge_gradient(0.5);
s.nudge_gradient(0.5);
let events = s.tick(powered(0), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 1.0);
assert_eq!(snapshot_of(&events).gradient_pct, 1.0);
let climbing = gradient_of(commands(&events)[0]);
assert_eq!(snapshot_of(&events).gradient_pct, 1.0, "the road is the trim");
assert!(climbing > flat, "a 1% road must load more than a flat: {climbing} vs {flat}");
s.reset_gradient_offset();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
let events = s.tick(powered(0), 1.0);
assert_eq!(snapshot_of(&events).gradient_pct, 0.0);
assert!(gradient_of(commands(&events)[0]) < climbing, "resetting must shed the load");
}
#[test]
@@ -534,13 +750,18 @@ mod tests {
}],
});
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.0);
let steep = gradient_of(commands(&s.tick(powered(200), 1.0))[0]);
s.nudge_gradient(-1.5);
let events = s.tick(powered(200), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 2.5);
// And the physics see the trimmed gradient too, not the raw profile.
// The road is the profile plus the trim, exactly. The load commanded
// for it is a separate quantity (see `crate::gearing`) — all that is
// owed here is that trimming the road down eases the pedals.
assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
assert!(
gradient_of(commands(&events)[0]) < steep,
"trimming 1.5% off the route must shed load"
);
}
#[test]
@@ -657,8 +878,13 @@ mod tests {
#[test]
fn custom_limits_are_honoured() {
// Gradient limits, so a gradient channel — the power channel has its
// own bounds and is covered separately.
let mut s = RideSession::new(
RiderConfig::default(),
RiderConfig {
load_channel: LoadChannel::Gradient,
..RiderConfig::default()
},
SafetyLimits {
min_gradient_pct: -2.0,
max_gradient_pct: 3.0,
@@ -676,11 +902,17 @@ mod tests {
fn an_unchanged_target_is_not_resent() {
let mut s = session();
s.start();
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
// Ride up to terminal speed first. The commanded load carries the
// rider's own drag, which rises with v², so while they are still
// accelerating the target is genuinely changing and writing it is
// correct. What FR-2.8 forbids is churn once nothing is moving.
for _ in 0..600 {
s.tick(powered(200), 1.0);
}
for _ in 0..50 {
assert!(
commands(&s.tick(powered(200), 1.0)).is_empty(),
"a steady target must not be re-sent (FR-2.8)"
"a settled ride must not be re-sent (FR-2.8)"
);
}
s.nudge_gradient(1.0);
@@ -721,8 +953,11 @@ mod tests {
for _ in 0..6000 {
writes += commands(&s.tick(powered(200), 0.1)).len();
}
// 6 % of gradient at a 0.05 % threshold is ~120 writes over 600 s.
assert!(writes <= 130, "{writes} writes in 600 s");
// The ramp itself is ~120 writes at a 0.05 % threshold; the rider's
// drag changing as they speed up on it accounts for the rest. Still one
// write every four seconds against a 4 Hz cap — two orders of magnitude
// of headroom, which is what this test is actually guarding.
assert!(writes <= 200, "{writes} writes in 600 s");
assert!(writes > 100);
}
@@ -981,33 +1216,540 @@ mod tests {
);
}
/// The gradient the session most recently asked the trainer for.
fn commanded_gradient(s: &RideSession) -> f32 {
match s.last_target() {
Some(ControlTarget::Gradient { percent }) => percent,
other => panic!("expected a gradient target, got {other:?}"),
}
}
#[test]
fn the_gear_servo_finds_load_when_the_rider_spins_out() {
// The descent failure that started this: steep downhill, rider spinning
// far faster than the gear implies. The commanded gradient must come
// back up so there is something to push against.
fn a_shift_changes_the_load_on_the_very_next_tick() {
// The point of the direct model. A rider who shifts and feels nothing
// for three seconds has not shifted, they have waited.
let mut s = session();
s.start();
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
// A descent is ridden in a BIG gear — as on a real bike. In a short
// gear the bike simply outruns the rider's legs and the honest answer
// is that they are freewheeling, not that the trainer owes them load.
while s.gearing.shift_up() {}
s.nudge_gradient(-6.0);
s.gearing.set_gear(6);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
for _ in 0..40 {
s.tick(t, 0.25);
}
let before = commanded_gradient(&s);
let spun_out = Telemetry {
s.gearing.shift_up();
s.tick(t, 0.25);
let after = commanded_gradient(&s);
assert!(
after > before + 0.05,
"one tick after shifting up the load must be higher: {before} -> {after}"
);
}
#[test]
fn the_gear_the_rider_selects_is_what_reaches_the_trainer() {
// Bottom gear and top gear on the same road must not command the same
// load, or the shifter is decoration.
let mut s = session();
s.start();
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
s.gearing.set_gear(1);
for _ in 0..40 {
s.tick(t, 0.25);
}
let bottom = commanded_gradient(&s);
s.gearing.set_gear(s.gearing.gear_count());
s.tick(t, 0.25);
let top = commanded_gradient(&s);
assert!(top > bottom, "top gear must load more: {top} vs {bottom}");
}
#[test]
fn the_route_gradient_is_not_charged_twice() {
// The commanded load already contains gravity, because the force it
// scales was computed at the route's gradient. A 6% climb in a gear
// close to the bike's real one should command something in the
// neighbourhood of 6% — not 12%.
let mut s = session();
s.start();
s.config.physical_development_m = s.gearing.development_m();
s.nudge_gradient(6.0);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
for _ in 0..40 {
s.tick(t, 0.25);
}
let commanded = commanded_gradient(&s);
assert!(
(commanded - 6.0).abs() < 2.0,
"6% road in a 1:1 gear should command about 6%, got {commanded}"
);
}
/// Settle a ride at a fixed cadence and gear, and report its speed.
fn ride_at(cadence: f32, gear: usize) -> RideSession {
let mut s = session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(cadence),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
s
}
/// Ride up to speed on `gradient`, then stop pedalling for `seconds` while
/// the flywheel keeps the cranks turning. Returns (riding, coasting) km/h.
fn coast_after_riding(gradient: f32, seconds: f32) -> (f32, f32) {
let mut s = session();
s.start();
s.nudge_gradient(gradient);
let riding = Telemetry {
power_w: Some(250),
cadence_rpm: Some(85.0),
speed_kph: Some(28.0),
..Default::default()
};
for _ in 0..200 {
s.tick(riding, 0.25);
}
let moving = s.physics().speed_kph();
let coasting = Telemetry {
power_w: Some(0),
// The flywheel drives the cranks: cadence stays healthy for a
// rider who has stopped doing anything.
cadence_rpm: Some(80.0),
speed_kph: Some(26.0),
..Default::default()
};
for _ in 0..((seconds / 0.25) as u32) {
s.tick(coasting, 0.25);
}
(moving, s.physics().speed_kph())
}
#[test]
fn stopping_pedalling_on_a_climb_stops_the_bike() {
// The flywheel reports 80 rpm throughout, so anything keyed on cadence
// alone would have the rider still climbing at speed. Power is what
// says they have stopped.
let (moving, coasting) = coast_after_riding(3.5, 5.0);
assert!(moving > 20.0, "should have been riding: {moving}");
assert!(
coasting < 1.5,
"five seconds after stopping on a 3.5% climb, expected a halt, got {coasting} kph"
);
}
#[test]
fn coasting_a_descent_keeps_rolling() {
// The mirror image, and the reason coasting is not simply "stop": a
// rider who stops pedalling downhill speeds up, and must not be brought
// to a halt by a rule written for climbs.
let (_, coasting) = coast_after_riding(-5.0, 5.0);
assert!(
coasting > 15.0,
"freewheeling down a 5% descent should carry on, got {coasting} kph"
);
}
#[test]
fn speed_is_never_negative() {
// Whatever the road, the telemetry or the gear, a readout that says the
// rider is travelling backwards is never right.
for gradient in [-25.0, -8.0, 0.0, 8.0, 25.0] {
for (power, cadence) in
[(0u16, None), (0, Some(0.0)), (0, Some(95.0)), (400, Some(95.0))]
{
let mut s = session();
s.start();
s.nudge_gradient(gradient);
let t = Telemetry {
power_w: Some(power as i16),
cadence_rpm: cadence,
speed_kph: Some(0.0),
..Default::default()
};
for _ in 0..200 {
let snap = snapshot_of(&s.tick(t, 0.25));
assert!(
snap.virtual_speed_kph >= 0.0,
"negative speed {} at {gradient}% / {power} W / {cadence:?}",
snap.virtual_speed_kph
);
assert!(snap.virtual_distance_m >= 0.0, "distance went backwards");
}
}
}
}
/// The watts commanded after settling at a given flywheel speed and gear.
fn commanded_watts(gear: usize, trainer_kph: f32) -> u16 {
let mut s = power_session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(trainer_kph),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => watts,
other => panic!("expected a power target, got {other:?}"),
}
}
#[test]
fn the_road_gradient_reaches_the_trainer_as_watts() {
// The D100 will honour the power channel or the gradient channel, not
// both, so gravity has to travel on whichever one we chose. It does:
// the climb is in the commanded watts, which is the whole reason
// dropping the gradient channel costs nothing.
let watts = |grade: f32| {
let mut s = power_session();
s.start();
s.gearing.set_gear(6);
s.nudge_gradient(grade);
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => watts,
other => panic!("expected a power target, got {other:?}"),
}
};
let flat = watts(0.0);
let climb = watts(3.0);
let descent = watts(-5.0);
assert!(climb > flat * 2, "a 3% climb must cost far more: {climb} vs {flat}");
assert!(descent < flat, "a descent must cost less: {descent} vs {flat}");
assert!(
descent >= power_session().limits.min_power_w,
"never below what the trainer can hold"
);
}
#[test]
fn the_power_channel_is_what_ships() {
// The D100 declares 50-600 W in 1 W steps and 0-6% inclination in 0.1%
// steps, and only the former has room for gearing to show up in.
assert_eq!(RiderConfig::default().load_channel, LoadChannel::Power);
}
#[test]
fn a_longer_gear_demands_more_watts() {
// Not because power depends on the gear — at a given speed it does not
// — but because a longer gear turns the same cadence into more speed,
// and the road charges for speed.
let bottom = commanded_watts(1, 20.0);
let top = commanded_watts(12, 20.0);
assert!(top > bottom * 3, "top gear must cost far more: {top} vs {bottom}");
}
#[test]
fn the_commanded_watts_are_a_plausible_road_load() {
// Sanity on the absolute scale. This is the number the trainer will
// hold as a ceiling, so if it is wrong the ride is wrong.
let watts = commanded_watts(6, 20.0);
assert!(
(40..=250).contains(&watts),
"a middling gear on the flat should be ordinary riding: {watts} W"
);
}
#[test]
fn the_commanded_load_is_quantised_so_it_does_not_churn() {
// The write budget depends on this: road power goes as v cubed, and an
// unquantised target would change on nearly every tick.
for gear in [2usize, 6, 10] {
for kph in [12.0f32, 18.0, 25.0] {
let watts = commanded_watts(gear, kph);
assert_eq!(
watts % LOAD_POWER_STEP_W as u16,
0,
"{watts} W is off the {LOAD_POWER_STEP_W} W grid"
);
}
}
}
#[test]
fn a_steady_ride_on_the_power_channel_stays_inside_the_write_budget() {
// FR-2.8 caps writes at 4 Hz. Once the speed has settled the demand
// should stop moving entirely.
let mut s = power_session();
s.start();
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
for _ in 0..200 {
s.tick(t, 0.25);
}
let mut writes = 0;
for _ in 0..200 {
writes += commands(&s.tick(t, 0.25)).len();
}
assert!(writes <= 2, "a settled ride rewrote the target {writes} times");
}
#[test]
fn a_descent_never_asks_the_brake_for_negative_watts() {
// No brake can push. The floor is zero, and the safety limits raise it
// to whatever the trainer's real minimum is.
let mut s = power_session();
s.start();
s.nudge_gradient(-12.0);
let t = Telemetry {
power_w: Some(0),
cadence_rpm: Some(80.0),
speed_kph: Some(35.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => {
assert!(watts <= s.limits.max_power_w);
}
other => panic!("expected a power target, got {other:?}"),
}
}
#[test]
fn speed_is_cadence_times_the_gear() {
// The drivetrain constraint, exactly as on a bike: the wheel is locked
// to the cranks, so this is arithmetic, not a force balance.
let s = ride_at(90.0, 6);
let expected = 90.0 / 60.0 * s.gearing.development_m();
let actual = s.physics().speed_mps;
assert!(
(actual - expected).abs() < 0.05,
"90 rpm in a {:.1} m gear is {expected:.2} m/s, got {actual:.2}",
s.gearing.development_m()
);
}
#[test]
fn a_bigger_gear_at_the_same_cadence_goes_faster() {
let small = ride_at(90.0, 2).physics().speed_mps;
let big = ride_at(90.0, 11).physics().speed_mps;
assert!(big > small * 1.5, "a longer gear must travel further: {big} vs {small}");
}
#[test]
fn a_higher_cadence_in_the_same_gear_goes_faster() {
let slow = ride_at(60.0, 6).physics().speed_mps;
let fast = ride_at(100.0, 6).physics().speed_mps;
assert!(fast > slow, "spinning faster must go faster: {fast} vs {slow}");
}
#[test]
fn a_rigid_drivetrain_leaves_the_cadence_servo_nothing_to_say() {
// With speed taken from cadence, the cadence the gear implies IS the
// cadence the rider is turning. The servo is a tautology here, which is
// what a rigid drivetrain means — and why it drives nothing.
let s = ride_at(90.0, 6);
assert!(
s.gearing.correction_pct().abs() < 0.01,
"expected no correction, got {}",
s.gearing.correction_pct()
);
}
#[test]
fn wheel_speed_infers_cadence_on_a_single_cog_drivetrain() {
// The trainer reports no cadence over FTMS but always reports speed,
// and with one sprocket the two are locked. 20 km/h through a 5.1 m
// development is 5.56 m/s / 5.1 m = 1.09 rev/s = 65 rpm.
let mut s = session();
s.config.physical_development_m = 5.1;
let t = Telemetry {
power_w: Some(150),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
let rpm = s.effective_cadence(&t).expect("speed alone must yield a cadence");
assert!((rpm - 65.4).abs() < 0.5, "expected ~65 rpm, got {rpm}");
// And it drives the ride, rather than the ride standing still.
s.start();
for _ in 0..80 {
s.tick(t, 0.25);
}
assert!(s.physics().speed_mps > 1.0, "inferred cadence must move the bike");
assert_eq!(
snapshot_of(&s.tick(t, 0.25)).speed_source,
SpeedSource::Drivetrain
);
}
#[test]
fn a_measured_cadence_beats_an_inferred_one() {
// On this drivetrain they agree; on a bike with a freewheel only the
// measured one is honest, so it must win where it exists.
let s = session();
let t = Telemetry {
cadence_rpm: Some(95.0),
speed_kph: Some(20.0),
..Default::default()
};
assert_eq!(s.effective_cadence(&t), Some(95.0));
}
#[test]
fn the_gear_scales_the_trainers_own_speed() {
// What inference makes the model: virtual speed is the trainer's speed
// times the gear ratio. Top gear covers more ground per flywheel
// revolution than bottom, for the same flywheel.
let ride = |gear: usize| {
let mut s = session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: None,
speed_kph: Some(25.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
s.physics().speed_kph()
};
let bottom = ride(1);
let top = ride(12);
assert!(top > bottom * 2.0, "top gear must cover more ground: {top} vs {bottom}");
}
#[test]
fn a_silent_trainer_still_stops_the_ride() {
// The inference needs a wheel speed. With neither cadence nor speed
// there is genuinely nothing to ride on, and that must still be loud.
let mut s = session();
s.start();
let t = Telemetry {
power_w: Some(250),
cadence_rpm: None,
speed_kph: None,
..Default::default()
};
let mut snap = None;
for _ in 0..40 {
snap = Some(snapshot_of(&s.tick(t, 0.25)));
}
let snap = snap.unwrap();
assert_eq!(snap.virtual_speed_kph, 0.0);
assert_eq!(snap.speed_source, SpeedSource::NoCadence);
}
#[test]
fn without_cadence_the_ride_does_not_move_and_says_why() {
// Deliberate. Speed comes from the drivetrain, and with no cadence
// there is no drivetrain to read. Inventing a speed from power instead
// would let a broken cadence feed pass for a working ride that merely
// felt a little wrong — for however long it took someone to notice.
let mut s = session();
s.start();
let t = Telemetry { power_w: Some(250), cadence_rpm: None, ..Default::default() };
let mut snap = None;
for _ in 0..80 {
snap = Some(snapshot_of(&s.tick(t, 0.25)));
}
let snap = snap.unwrap();
assert_eq!(snap.virtual_speed_kph, 0.0);
assert_eq!(
snap.speed_source,
SpeedSource::NoCadence,
"the snapshot must name the fault, not just report a stopped bike"
);
}
#[test]
fn the_snapshot_names_which_rule_set_the_speed() {
let mut s = session();
s.start();
s.tick(powered(200), 0.25);
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
SpeedSource::Drivetrain
);
let coasting = Telemetry {
power_w: Some(0),
cadence_rpm: Some(80.0),
..Default::default()
};
assert_eq!(
snapshot_of(&s.tick(coasting, 0.25)).speed_source,
SpeedSource::Coasting
);
s.pause();
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
SpeedSource::Stopped
);
}
#[test]
fn a_descent_in_a_big_gear_carries_the_rider() {
// Replaces `the_gear_servo_finds_load_when_the_rider_spins_out`, whose
// premise a rigid drivetrain removes: you cannot spin out of a gear
// that is locked to the wheel. Turning 120 rpm in top gear now simply
// *is* going fast, which is the outcome that test was reaching for by
// a much longer route.
let mut s = session();
s.start();
s.nudge_gradient(-6.0);
while s.gearing.shift_up() {}
let spinning = Telemetry {
power_w: Some(40),
cadence_rpm: Some(120.0),
..Default::default()
};
for _ in 0..200 {
s.tick(spun_out, 0.25);
s.tick(spinning, 0.25);
}
let expected = 120.0 / 60.0 * s.gearing.development_m();
assert!(
s.gearing.correction_pct() > 0.5,
"servo should have added load, got {}",
s.gearing.correction_pct()
(s.physics().speed_mps - expected).abs() < 0.05,
"120 rpm in top gear is {expected:.1} m/s, got {:.1}",
s.physics().speed_mps
);
}
}
+129 -19
View File
@@ -83,9 +83,11 @@ impl SafetyLimits {
/// Where the base target comes from (§5.4). Note that in the full design
/// gearing and gradient are simultaneously active; mode selects the *source* of
/// the base gradient, not whether shifting works.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ControlMode {
/// Rider sets gradient directly; no profile running.
/// Rider sets gradient directly; no profile running. The default: it is the
/// mode a session with nothing loaded is already in.
#[default]
ManualGrade,
/// Rider sets raw resistance; physics ignored.
Resistance,
@@ -102,6 +104,20 @@ pub struct RiderConfig {
pub bike_kg: f32,
/// Coefficient of rolling resistance.
pub crr: f32,
/// A fixed power loss that never goes away, watts.
///
/// Everything a trainer costs you that does not scale the way road forces
/// do: belt and bearing drag, the chain, the flywheel's own bearings. On
/// this hardware that is about 12 W.
///
/// Held as a *power* rather than a force because that is how it presents —
/// a constant tax on what you put in. Converted where it is used by
/// dividing by speed, so it is a small force when you are moving fast and a
/// large one as you slow down. That asymmetry is the point: it barely
/// touches a fast descent and it is what brings a coast to an actual halt
/// rather than a long asymptotic drift.
#[serde(default = "default_rolling_loss_w")]
pub rolling_loss_w: f32,
/// Drag coefficient × frontal area, m².
pub cda: f32,
/// Fraction of measured power reaching the wheel.
@@ -109,17 +125,24 @@ pub struct RiderConfig {
/// Air density, kg/m³.
pub air_density: f32,
pub wheel_circumference_m: f32,
/// How strongly the trainer's own reported speed pulls the modelled speed
/// back toward it, as a fraction of the gap closed per second.
/// Crank length — the radius of the circle the pedal travels, metres.
///
/// `0.0` is pure physics: correct for a real bike, but on a single-cog
/// drivetrain the rider spins out against no resistance on a descent while
/// the model happily reports 39 km/h. `1.0` would track the flywheel
/// exactly, capping descents at whatever the one gear allows. The default
/// keeps physics in charge while refusing to drift far from what the
/// hardware measures.
#[serde(default = "default_trainer_speed_weight")]
pub trainer_speed_weight: f32,
/// The last lever in the chain. Work is force × distance either side of it:
/// per crank revolution the pedal travels `2πr` while the bike travels
/// `development`, so the force the rider's leg feels is
///
/// ```text
/// F_pedal = F_road × development / (2πr)
/// ```
///
/// It changes nothing about what is commanded — the trainer is asked for a
/// force at the wheel, and leverage past the wheel is the rider's own
/// business — but it is the only way to say what a gear will actually feel
/// like, which is what makes a gear ladder sane or absurd.
///
/// Road bikes are 170175 mm; nothing sold is near 300 mm.
#[serde(default = "default_crank_length")]
pub crank_length_m: f32,
/// The steepest descent the trainer is ever *asked* to simulate.
///
/// On a real descent a trainer unloads almost completely, and on a
@@ -138,18 +161,25 @@ pub struct RiderConfig {
/// A 34T chainring on a 14T cog with a 2.1 m wheel is 5.1 m.
#[serde(default = "default_physical_development")]
pub physical_development_m: f32,
/// Which FTMS channel the computed load is sent on. See [`LoadChannel`].
#[serde(default)]
pub load_channel: LoadChannel,
}
fn default_physical_development() -> f32 {
5.1
}
fn default_descent_load_floor() -> f32 {
-1.0
fn default_rolling_loss_w() -> f32 {
12.0
}
fn default_trainer_speed_weight() -> f32 {
0.3
fn default_crank_length() -> f32 {
0.1725
}
fn default_descent_load_floor() -> f32 {
-1.0
}
impl Default for RiderConfig {
@@ -158,13 +188,15 @@ impl Default for RiderConfig {
rider_kg: 105.0,
bike_kg: 8.0,
crr: 0.004,
rolling_loss_w: default_rolling_loss_w(),
cda: 0.32,
drivetrain_efficiency: 0.97,
air_density: 1.225,
wheel_circumference_m: 2.1,
trainer_speed_weight: default_trainer_speed_weight(),
crank_length_m: default_crank_length(),
descent_load_floor_pct: default_descent_load_floor(),
physical_development_m: default_physical_development(),
load_channel: LoadChannel::default(),
}
}
}
@@ -175,10 +207,69 @@ impl RiderConfig {
}
}
/// How the computed road load is expressed to the trainer.
///
/// The load itself is the same physics either way; this only chooses the FTMS
/// channel it is sent on, and the right answer is whichever one the hardware
/// actually acts on.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoadChannel {
/// `SetIndoorBikeSimulationParameters` (0x11) — tell the trainer the slope
/// and let its own model produce the resistance.
Gradient,
/// `SetTargetPower` (0x05) — command the watts the road demands at the
/// current speed, recomputed every tick.
///
/// The default, because on the D100 it is the channel with room to work.
/// Its declared inclination range is 06% in 0.1% steps and refuses
/// negatives outright, so a gearing difference that should be dramatic
/// arrives as a fraction of a percent; its power range is 50600 W in 1 W
/// steps. Whether 0x11 does anything at all on this trainer is still
/// unconfirmed (TASK-2), whereas power is declared in its feature bits and
/// is what the physics computes natively.
///
/// This is not ERG. On the D100 the target is a **ceiling**, not a
/// setpoint: the brake absorbs up to that many watts and no more, so
/// anything the rider produces beyond it becomes speed instead of being
/// resisted away.
///
/// That is very nearly what a road is. Command the watts the road demands
/// at the current speed and the rest follows on its own — push harder than
/// the road asks and you accelerate, ease off and you slow, and because the
/// demand is recomputed from the new speed each tick the whole thing
/// settles where the physics says it should. A true ERG setpoint would
/// fight the rider instead, pressing harder the slower they went.
#[default]
Power,
}
/// Which rule decided the virtual speed on a given tick.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpeedSource {
/// The rider is driving: speed is cadence × the selected gear.
Drivetrain,
/// The rider has stopped putting power in: speed runs down to whatever the
/// gradient sustains on none.
Coasting,
/// No cadence reaching the engine, so there is no drivetrain to read and
/// the ride is held at a stop. A fault, not a mode: cadence comes from the
/// trainer's Zwift channel, and this says it is not arriving.
NoCadence,
/// The ride is not running. The default, because a snapshot that has not
/// been through a tick describes a bike nobody is on.
#[default]
Stopped,
}
/// A snapshot of the ride, pushed to the UI each tick. This is what the
/// frontend renders; it should contain everything the ride screen needs and
/// nothing it does not.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// `Default` is the zeroed ride — nothing elapsed, nothing moving, no gear
/// engaged. It exists for test fixtures and for the first frame before a tick
/// has run, so that adding a field here does not force every construction site
/// to be edited. Note that it is NOT a valid ride state: `gear_count` of zero
/// means no cassette, which the UI renders as a dash rather than "gear 0 of 0".
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct RideSnapshot {
pub elapsed_ms: u64,
pub telemetry: Telemetry,
@@ -186,10 +277,29 @@ pub struct RideSnapshot {
pub virtual_speed_kph: f32,
/// Virtual distance travelled, metres.
pub virtual_distance_m: f64,
/// Gradient currently commanded, percent.
/// The gradient of the *road*, percent — the profile's slope plus the
/// rider's trim. Not what the trainer was asked for: gearing sits between
/// the two, so see `target` for that.
pub gradient_pct: f32,
/// Cumulative elevation gained, metres.
pub elevation_gain_m: f32,
/// Selected virtual gear, one-based (FR-4.1).
pub gear: usize,
/// How many gears there are to choose from.
pub gear_count: usize,
/// Metres travelled per crank revolution in the selected gear.
pub development_m: f32,
/// Cadence the selected gear implies at the current speed, rpm — what the
/// rider would be turning if they were riding this gear honestly.
pub target_cadence_rpm: f32,
/// Force the rider's leg is pushing against at the pedal, newtons. The
/// commanded gradient in the units the legs actually work in — see
/// [`crate::gearing::Gearing::pedal_force_n`].
pub pedal_force_n: f32,
/// Which rule produced `virtual_speed_kph` this tick. Diagnostic: the three
/// behave very differently, and "the speed is wrong" is not answerable
/// without knowing which one was in charge. See `RideSession::tick`.
pub speed_source: SpeedSource,
pub mode: ControlMode,
/// The target most recently sent to the trainer, post-clamp.
pub target: Option<ControlTarget>,
+3
View File
@@ -52,6 +52,8 @@ pub struct FitSummary {
pub avg_power_w: Option<u16>,
/// Peak power. `None` if no sample reported power.
pub max_power_w: Option<u16>,
/// Mean cadence over samples that reported one. `None` if none did.
pub avg_cadence_rpm: Option<u8>,
/// Estimated rider energy expenditure in kilocalories, derived from
/// measured mechanical work — see [`Aggregates::calories`].
pub total_calories: Option<u16>,
@@ -216,6 +218,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
total_ascent_m: clamp_u16(session_agg.ascent_m),
avg_power_w: session_agg.avg_power(),
max_power_w: session_agg.max_power,
avg_cadence_rpm: session_agg.avg_cadence(),
total_calories: session_agg.calories(log.start.rider_kg),
gaps: log.gaps(end_ms).len(),
recovered_from_crash: !log.clean_shutdown,
+6
View File
@@ -602,6 +602,12 @@ mod tests {
resistance_level: Some(7),
..Default::default()
},
gear: 6,
gear_count: 12,
development_m: 5.7,
target_cadence_rpm: 92.0,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
pedal_force_n: 120.0,
virtual_speed_kph: 31.5,
virtual_distance_m: 105.0,
gradient_pct: 3.5,
+6
View File
@@ -387,6 +387,12 @@ mod tests {
virtual_distance_m: elapsed_ms as f64 * 0.009,
gradient_pct: 1.5,
elevation_gain_m: elapsed_ms as f32 * 0.000_135,
gear: 6,
gear_count: 12,
development_m: 5.7,
target_cadence_rpm: 94.7,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
pedal_force_n: 120.0,
mode: ControlMode::ManualGrade,
target: None,
profile_progress: None,
+6
View File
@@ -38,6 +38,12 @@ fn snapshot(second: u64) -> RideSnapshot {
virtual_distance_m: second as f64 * 8.2,
gradient_pct: ((second % 20) as f32 - 10.0) / 2.0,
elevation_gain_m: second as f32 * 0.15,
gear: 6,
gear_count: 12,
development_m: 5.7,
target_cadence_rpm: 90.0,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
pedal_force_n: 120.0,
mode: ControlMode::Profile,
target: None,
profile_progress: Some(second as f32 / 600.0),
+26
View File
@@ -0,0 +1,26 @@
name: Drag race
description: >-
A standing-start kilometre on a dead-flat road, for testing that the gears and
the resistance actually do something. Nothing about it is a workout: it is the
shortest route to the two questions that matter. Start in the bottom gear from
a stop and wind it up — every shift should land under the pedals immediately,
and holding the same gear should get harder as the speed rises, because drag
is the only thing resisting you on the flat and it grows with the square of
speed. If shifting feels like nothing, or the load never builds, the control
writes are not reaching the trainer.
Flat is deliberate: on a gradient, gravity swamps everything and a broken gear
ratio still feels like a hill. With no slope, the load you feel is the gearing
and the aerodynamics, and nothing else.
Distance-based, so it ends when you have covered the kilometre rather than
after a fixed time — which makes the elapsed time your score. Loops, so you
can go again in a different gear and compare.
looping: true
blocks:
- type: segments
segments:
# The whole course. One flat kilometre; the gears supply all the variety.
- distance_m: 1000.0
gradient_pct: 0.0
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# TASK-2: does a control write actually change what the pedals feel like?
# Every write comes back acknowledged; nobody has confirmed it does anything.
#
# Four connect/hold/disconnect cycles against one adapter, which is where this
# script's own hazard lives — see `settle` and the EXIT trap below. A run that
# leaves the trainer connected at the BlueZ level poisons the *next* run with
# `le-connection-abort-by-local`, and the failure looks like a trainer fault
# rather than the script's.
set -uo pipefail
P=./target/debug/probe
NAME=VANRYSEL
D="--name $NAME"
# Seconds to let BlueZ tear a link down before asking it to build another. A
# connect issued straight after a disconnect is the reliable way to provoke
# `le-connection-abort-by-local`.
SETTLE=4
if [[ ! -x $P ]]; then
echo "Build the probe first: cargo build -p bikecontrol-probe" >&2
exit 1
fi
# The address of the trainer, if BlueZ currently knows one. Used only for
# cleanup — the probe finds its own device by name.
trainer_addr () {
bluetoothctl devices 2>/dev/null | awk -v n="$NAME" '$3 ~ n {print $2; exit}'
}
# Drop any GATT link BlueZ still holds.
#
# This is the whole point of the trap: the probe disconnects cleanly on its own
# exit path, but it never gets there if the run is interrupted — Ctrl-C reaches
# every process in the foreground group, so bash can die while the probe is
# mid-hold and leave the link open with nothing owning it. The next run then
# fails at connect for reasons that have nothing to do with the trainer.
cleanup () {
local addr
addr=$(trainer_addr)
[[ -z $addr ]] && return 0
if bluetoothctl info "$addr" 2>/dev/null | grep -q "Connected: yes"; then
echo
echo " (releasing GATT link to $addr)"
bluetoothctl disconnect "$addr" >/dev/null 2>&1
sleep 2
fi
}
trap cleanup EXIT INT TERM
step () {
local target=$1 title=$2
echo
echo "════════════════════════════════════════════"
echo " $title"
echo "════════════════════════════════════════════"
echo " connecting (~8s), then holding for 20s — PEDAL THROUGHOUT"
# Output is NOT piped. `| tail -3` swallowed the live telemetry this test
# exists to produce, and hid connect errors until the run was over. It also
# put the probe in a pipeline, so its exit status vanished behind tail's and
# `set -e` never fired on a failed step.
if ! $P set $D "$target" --secs 20; then
echo
echo " !! step failed: $target" >&2
cleanup
return 1
fi
# Let the link fully drop before the next step reconnects.
sleep "$SETTLE"
}
# Start from a clean slate: a link left over from a previous run is the most
# common cause of the first step failing.
cleanup
echo "Wake the trainer first: spin the cranks for a few seconds."
echo "Press Enter when it's awake and you're ready to ride."
read -r
step resistance=15 "TEST 1 of 4 — resistance 15 of 100 (should feel EASY)" || exit 1
step resistance=75 "TEST 2 of 4 — resistance 75 of 100 (should feel MUCH HARDER)" || exit 1
step sim=0.0 "TEST 3 of 4 — simulated gradient 0% (flat)" || exit 1
step sim=8.0 "TEST 4 of 4 — simulated gradient +8% (a climb)" || exit 1
echo
echo "Done — trainer reset to zero load."
echo
echo "Report back:"
echo " a) Did 2 feel harder than 1? (resistance mode works)"
echo " b) Did 4 feel harder than 3? (SIM MODE works — this is the one that matters)"
echo " c) Roughly how much harder, and did it change instantly or drift in?"
+44 -4
View File
@@ -3,9 +3,20 @@
# plain `cargo build` produces a binary wired to localhost:1420 that shows a
# BLACK WINDOW unless a Vite dev server happens to be running. Incremental
# builds take seconds, and this removes a whole class of confusion.
set -e
#
# `pipefail` is load-bearing. This script used to end the build line with
# `| tail -3`, which meant the exit status came from `tail` and was always 0:
# a build that failed to compile printed three lines of error, `set -e` saw
# success, and the script went on to launch whatever binary was already in
# target/ — silently, and possibly hours old. Every symptom of that looks like
# a code change that "did not work", because the code being run is not the code
# on disk. A failed build must stop here.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
LOG="$ROOT/bikecontrol.log"
BIN="$ROOT/target/debug/bikecontrol-app"
BUILD_LOG="$ROOT/target/last-build.log"
# WebKitGTK 2.52 on Wayland + Intel paints black rectangles via its DMABUF
# renderer; disabling it costs nothing here (the UI is 2D canvas and CSS).
@@ -14,9 +25,38 @@ export WEBKIT_DISABLE_COMPOSITING_MODE=1
export RUST_LOG="${RUST_LOG:-debug,btleplug=info,tao=warn,wry=warn}"
echo "building (embeds the frontend — this is what avoids the black window)..."
( cd "$ROOT/src-tauri" && cargo tauri build --debug --no-bundle ) 2>&1 | tail -3
mkdir -p "$(dirname "$BUILD_LOG")"
BIN="$ROOT/target/debug/bikecontrol-app"
# Quiet on success, complete on failure. Piping straight to `tail` would hide
# the first error of a cascade, which is usually the only one that matters.
if ( cd "$ROOT/src-tauri" && cargo tauri build --debug --no-bundle ) >"$BUILD_LOG" 2>&1; then
tail -3 "$BUILD_LOG"
else
status=$?
echo
echo "════════════════════════════════════════════"
echo " BUILD FAILED — not launching."
echo "════════════════════════════════════════════"
echo
cat "$BUILD_LOG"
echo
if [[ -x $BIN ]]; then
echo "There IS an older binary at $BIN (built $(date -r "$BIN" '+%Y-%m-%d %H:%M:%S'))."
echo "It is deliberately NOT being launched: running stale code against new"
echo "expectations is how an afternoon disappears."
fi
exit $status
fi
if [[ ! -x $BIN ]]; then
echo "build reported success but $BIN is missing" >&2
exit 1
fi
# Say what is actually about to run. The whole point of the machinery above is
# that this line can be trusted, so print it where it cannot be missed.
echo "=== BikeControl $(date '+%H:%M:%S') ===" > "$LOG"
echo "launching $BIN — logging to $LOG"
echo "launching $BIN"
echo " built: $(date -r "$BIN" '+%Y-%m-%d %H:%M:%S')"
echo " logging: $LOG"
exec "$BIN" "$@" 2>&1 | tee -a "$LOG"
+7 -11
View File
@@ -18,11 +18,13 @@ tauri-build = { version = "2", features = [] }
[dependencies]
bikecontrol-core = { workspace = true }
bikecontrol-ble = { workspace = true }
bikecontrol-fit = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
uuid = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
@@ -33,14 +35,8 @@ thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[features]
default = ["mock-ride"]
# Compile the synthetic rider in `src/mock.rs` *as an option*. It is no longer
# the default data source — `bikecontrol_core::RideSession` fed by real FTMS
# telemetry is (see `state::Inner::new`). The feature exists so the mock can be
# selected at runtime with `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1`, which
# is what makes the GUI developable with no hardware on the desk.
#
# Build with `--no-default-features` for a binary that can only ever show real
# trainer data.
mock-ride = []
# Desktop-only: the crate is a `compile_error!` on anything that is not
# Windows/Linux/macOS, and the mobile entry points (G-4) have their own
# platform APIs for this. `wakelock.rs` degrades to a no-op there.
[target.'cfg(any(windows, target_os = "linux", target_os = "macos"))'.dependencies]
keepawake = "0.6.0"
+32 -12
View File
@@ -1,14 +1,11 @@
//! The seam between the Tauri shell and whatever is actually riding.
//! The seam between the Tauri shell and what is actually riding:
//! [`crate::session_backend::SessionBackend`] — `bikecontrol_core::RideSession`
//! fed by real FTMS telemetry from `bikecontrol_ble`. Rider intent in, a
//! `RideSnapshot` out, and a `ControlTarget` to push to the trainer.
//!
//! By default that is [`crate::session_backend::SessionBackend`] —
//! `bikecontrol_core::RideSession` fed by real FTMS telemetry from
//! `bikecontrol_ble`. `crate::mock::MockBackend`, a synthetic rider, is the
//! opt-in alternative for working on the GUI with no hardware. Both are the
//! same shape: rider intent in, a `RideSnapshot` out, and a `ControlTarget` to
//! push to the trainer.
//!
//! Nothing above this trait knows which one is running (§4.3 — the control loop
//! lives in Rust; the frontend only ever sees snapshots).
//! There is no synthetic alternative: with no trainer attached the ride reads
//! zero, which is the truth (§4.3 — the control loop lives in Rust; the
//! frontend only ever sees snapshots).
use std::sync::Arc;
@@ -16,6 +13,7 @@ use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits,
};
use bikecontrol_core::VirtualCassette;
use crate::events::RideStatus;
@@ -30,6 +28,11 @@ pub struct RideInputs {
pub gradient_offset_pct: f32,
/// Selected virtual gear, one-based (FR-4.1).
pub gear: usize,
/// The ladder `gear` indexes into. Held here rather than left to the
/// session's own default so that the gear and the cassette it is counted
/// against can never disagree — a "gear 12 of 12" that is really gear 12 of
/// 8 is worse than no readout at all.
pub cassette: VirtualCassette,
pub resistance_level: i16,
pub power_target_w: u16,
pub profile: Option<Arc<Profile>>,
@@ -43,6 +46,7 @@ impl Default for RideInputs {
status: RideStatus::Idle,
mode: ControlMode::ManualGrade,
gear: bikecontrol_core::Gearing::default().gear(),
cassette: VirtualCassette::default(),
manual_gradient_pct: 0.0,
gradient_offset_pct: 0.0,
resistance_level: 20,
@@ -54,6 +58,24 @@ impl Default for RideInputs {
}
}
impl RideInputs {
/// How many gears the selected cassette offers.
pub fn gear_count(&self) -> usize {
self.cassette.len().max(1)
}
/// Select a gear, one-based. Out-of-range values clamp to the ends.
pub fn set_gear(&mut self, gear: usize) {
self.gear = gear.clamp(1, self.gear_count());
}
/// Shift by `delta` gears, clamping at both ends (FR-4.1.3 — never wraps).
pub fn shift_gear(&mut self, delta: i32) {
let next = self.gear as i64 + delta as i64;
self.set_gear(next.clamp(1, self.gear_count() as i64) as usize);
}
}
/// What a tick produced.
pub struct Tick {
pub snapshot: RideSnapshot,
@@ -67,6 +89,4 @@ pub trait RideBackend: Send + 'static {
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick;
/// Return to a fresh ride: zero elapsed, distance and speed.
fn reset(&mut self);
/// Identifier surfaced to the UI so it is obvious when the data is fake.
fn source(&self) -> &'static str;
}
+250 -37
View File
@@ -4,21 +4,30 @@
//! they mutate Rust-side state and the resulting truth comes back on the event
//! channel. The UI never assumes a command took effect (§4.3).
use std::path::PathBuf;
use bikecontrol_core::gpx::{self, SmoothingConfig};
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
use tauri::{AppHandle, State};
use bikecontrol_ble::TrainerSelector;
use bikecontrol_ble::PodId;
use crate::controller::ControllerStatus;
use crate::controller::{ControllerStatus, Pod};
use crate::devices::DeviceInfo;
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
use crate::profile_view::{self, ProfileView};
use crate::recording::{self, Recovered, RideRecordingSetup, RideSummary};
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};
type Cmd<T> = Result<T, String>;
/// Ride time now, for journal entries that need a timestamp. Zero before the
/// first tick, which is the correct answer rather than a missing one.
fn elapsed_ms(state: &AppState) -> u64 {
state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms)
}
// ---------------------------------------------------------------------------
// Ride state
// ---------------------------------------------------------------------------
@@ -30,21 +39,58 @@ pub fn ride_state(state: State<'_, AppState>) -> RideState {
#[tauri::command]
pub fn start_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
{
let mut inner = state.lock();
if inner.inputs.status == RideStatus::Finished || inner.inputs.status == RideStatus::Idle {
inner.reset_ride();
}
inner.inputs.status = RideStatus::Running;
}
begin_ride(&app, &state);
ack(&app, "start", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// Put the ride into `Running`, opening a journal if this is a fresh start.
///
/// Every route into a running ride goes through here — the Start button, the
/// space bar via [`toggle_pause`], and Click face button B. Any new path that
/// set `Running` on its own would ride with no recorder attached, and the rider
/// would not find out until the summary said nothing had been saved.
fn begin_ride(app: &AppHandle, state: &AppState) {
let setup = {
let mut inner = state.lock();
// A fresh ride, as opposed to resuming a paused one. Only a fresh ride
// opens a new journal; resuming must keep writing to the current one.
let fresh = matches!(inner.inputs.status, RideStatus::Finished | RideStatus::Idle);
if fresh {
inner.reset_ride();
}
inner.inputs.status = RideStatus::Running;
fresh.then(|| RideRecordingSetup {
stamp: recording::stamp_now(),
rider_kg: inner.inputs.rider.rider_kg,
has_profile: inner.inputs.profile.is_some(),
})
};
// Started outside the lock: creating the journal touches the disk.
let Some(setup) = setup else {
// Resuming, not starting: the journal is already open and only needs
// its timer restarted.
state.recorder().resume(elapsed_ms(state));
return;
};
let started = recording::rides_dir(app).and_then(|dir| state.recorder().start(&dir, setup));
if let Err(e) = started {
// The ride still starts. Refusing to ride because a file could not be
// opened would be the wrong trade — but the rider has to be told this
// one will not be saved.
tracing::error!(%e, "recording did not start");
notify(
app,
Notice::error(format!("{e} — this ride will not be saved")),
);
}
}
#[tauri::command]
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Paused;
state.recorder().pause(elapsed_ms(&state));
ack(&app, "pause", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
@@ -52,24 +98,28 @@ pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState>
#[tauri::command]
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Running;
// Not a bare status assignment: resuming from `Finished` is a *new* ride and
// must open a journal rather than run on unrecorded.
begin_ride(&app, &state);
ack(&app, "resume", None);
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// Pause or resume, whichever is the opposite of now. This is the one bound to
/// the space bar and to Click face button B.
/// the space bar and to Click face button B, and it is also how a ride is
/// *started* from the launch screen — hence the trip through [`begin_ride`]
/// rather than a status assignment.
#[tauri::command]
pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
let status = {
let mut inner = state.lock();
inner.inputs.status = match inner.inputs.status {
RideStatus::Running => RideStatus::Paused,
_ => RideStatus::Running,
};
inner.inputs.status
};
let running = state.lock().inputs.status == RideStatus::Running;
if running {
state.lock().inputs.status = RideStatus::Paused;
state.recorder().pause(elapsed_ms(&state));
} else {
begin_ride(&app, &state);
}
let status = state.lock().inputs.status;
ack(&app, "toggle-pause", Some(format!("{status:?}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
@@ -77,16 +127,88 @@ pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState
/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
/// before the session closes.
///
/// The activity is written automatically, before anything is shown and before
/// the rider is asked anything (FR-8.2). Saving to a location they choose is a
/// copy made afterwards (FR-9.14, [`save_fit`]) — a rider who cancels that
/// dialog, or closes the window, still has their ride.
#[tauri::command]
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().inputs.status = RideStatus::Finished;
// The trainer comes first. Whatever happens to the file, the rider must not
// be left on a loaded trainer while we talk to the disk.
crate::state::release_trainer(&app);
ack(&app, "stop", None);
emit_ride_state(&app);
notify(&app, Notice::info("Ride ended — trainer released to 0%"));
match state.recorder().finish() {
Ok(Some((summary, fit_path))) => {
let summary = RideSummary::new(&summary, &fit_path);
state.lock().last_summary = Some(summary.clone());
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_SUMMARY, summary);
recording::prune(&app, KEEP_RECORDINGS);
}
// Nothing was recording — a ride that never started, or a recorder that
// failed to open at the start and already said so.
Ok(None) => {}
Err(e) => {
tracing::error!(%e, "could not finalise the activity");
notify(&app, Notice::error(e));
}
}
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// How many finished rides stay in the app's data directory.
///
/// §5.8 puts ride *history* out of scope for v1: the FIT the rider saved is the
/// artifact, and this directory is the safety net behind it. Unbounded it would
/// grow forever somewhere nobody looks.
pub const KEEP_RECORDINGS: usize = 20;
/// Rides rebuilt from an interrupted session at startup (FR-8.4).
///
/// Draining rather than reading: this is reported to the rider once, and a
/// webview reload should not re-announce a recovery they have already seen.
#[tauri::command]
pub fn recovered_rides(state: State<'_, AppState>) -> Vec<Recovered> {
std::mem::take(&mut state.lock().recovered)
}
/// The most recently finished ride, if the summary screen is reloaded.
#[tauri::command]
pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
state.lock().last_summary.clone()
}
/// Save the finished activity where the rider asked (FR-9.14).
///
/// Returns the path actually written, so the UI can confirm it rather than
/// claiming success against a path it merely proposed.
#[tauri::command]
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
let dest = PathBuf::from(&path);
let source = {
let inner = state.lock();
let summary = inner
.last_summary
.as_ref()
.ok_or("There is no finished ride to save")?;
PathBuf::from(&summary.fit_path)
};
recording::save_copy(&source, &dest)?;
let written = dest.display().to_string();
if let Some(summary) = state.lock().last_summary.as_mut() {
summary.saved_path = Some(written.clone());
}
ack(&app, "save-fit", Some(written.clone()));
notify(&app, Notice::info(format!("Ride saved to {written}")));
Ok(written)
}
#[tauri::command]
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
state.lock().reset_ride();
@@ -148,6 +270,41 @@ pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<Rid
Ok(state.lock().ride_state())
}
/// Shift the virtual gear by `delta` (FR-4.1).
///
/// Clamps at both ends rather than wrapping: going from top gear straight to
/// bottom mid-climb would be violent, and a rider holding the paddle down
/// expects to arrive at the end of the cassette and stay there.
///
/// This is the one place a shift happens. The controller loop and the keyboard
/// both route here, so the pod and the keys cannot drift apart, and neither can
/// also nudge the gradient on the way past — a shift changes how hard the
/// pedals are, not what the road is doing.
#[tauri::command]
pub fn shift_gear(app: AppHandle, state: State<'_, AppState>, delta: i32) -> Cmd<RideState> {
let (gear, count) = {
let mut inner = state.lock();
inner.inputs.shift_gear(delta);
(inner.inputs.gear, inner.inputs.gear_count())
};
ack(&app, "gear", Some(format!("{gear}/{count}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// Select a gear directly, one-based (FR-4.1). Out-of-range values clamp.
#[tauri::command]
pub fn set_gear(app: AppHandle, state: State<'_, AppState>, gear: usize) -> Cmd<RideState> {
let (gear, count) = {
let mut inner = state.lock();
inner.inputs.set_gear(gear);
(inner.inputs.gear, inner.inputs.gear_count())
};
ack(&app, "gear", Some(format!("{gear}/{count}")));
emit_ride_state(&app);
Ok(state.lock().ride_state())
}
/// FR-4.2 / SAF-5 — one configured increment per event, never more.
#[tauri::command]
pub fn nudge_gradient(
@@ -231,6 +388,9 @@ pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16)
#[tauri::command]
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
let lap = state.lock().mark_lap();
// The journal takes the ride time the lap closed at, not the lap's own
// duration — the two differ from the second lap onwards.
state.recorder().mark_lap(elapsed_ms(&state), false);
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
ack(&app, "lap", Some(format!("Lap {}", lap.index)));
emit_ride_state(&app);
@@ -311,7 +471,10 @@ pub fn load_profile_from_path(
let (view, geom) = profile_view::build(&profile, path);
state.lock().set_profile(profile, view.clone(), geom);
emit_ride_state(&app);
notify(&app, Notice::info(format!("Loaded profile “{}", view.name)));
notify(
&app,
Notice::info(format!("Loaded profile “{}", view.name)),
);
Ok(view)
}
@@ -329,7 +492,10 @@ pub fn load_profile_from_text(
let (view, geom) = profile_view::build(&profile, name);
state.lock().set_profile(profile, view.clone(), geom);
emit_ride_state(&app);
notify(&app, Notice::info(format!("Loaded profile “{}", view.name)));
notify(
&app,
Notice::info(format!("Loaded profile “{}", view.name)),
);
Ok(view)
}
@@ -371,7 +537,10 @@ pub fn sample_profiles() -> Vec<SampleProfile> {
#[tauri::command]
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
let inner = state.lock();
DeviceList { scanning: inner.devices.scanning, devices: inner.devices.list() }
DeviceList {
scanning: inner.devices.scanning,
devices: inner.devices.list(),
}
}
#[tauri::command]
@@ -418,8 +587,9 @@ pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: Stri
Ok(())
}
/// True once a trainer has FTMS control. The ride screen uses this to warn that
/// it is showing simulated data (FR-9.3).
/// True once a trainer has FTMS control. This is what gates the ride screen:
/// connected is not controllable, and a ride nothing is driving is not a ride
/// (FR-9.3).
#[tauri::command]
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
state.lock().devices.trainer_controllable()
@@ -434,27 +604,70 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
state.controller().status()
}
/// Connect to a Click. `device_id` is an address; omit it to take the first pod
/// that advertises.
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
///
/// `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
/// advertisement — never by name, because both pods of a pair advertise the
/// same one and the app used to get whichever answered first.
///
/// Fire-and-forget: the supervisor owns the radio and the result arrives on
/// `controller://status`. A Click sleeps within seconds and only advertises
/// after a button press (A-4), so this routinely takes a few attempts — which
/// is why it must not block the UI thread waiting for one.
#[tauri::command]
pub fn connect_controller(state: State<'_, AppState>, device_id: Option<String>) -> Cmd<()> {
let selector = match device_id {
Some(id) if !id.trim().is_empty() => TrainerSelector::Address(id),
// Every pod so far advertises as "Zwift Click".
_ => TrainerSelector::NameContains("Zwift Click".into()),
};
state.controller().connect(selector);
pub fn connect_controller(
state: State<'_, AppState>,
pod: Option<Pod>,
device_id: Option<String>,
) -> Cmd<()> {
let controller = state.controller();
let address = device_id.filter(|id| !id.trim().is_empty());
match pod {
Some(pod) => controller.connect(pod.into(), address),
None => {
if address.is_some() {
return Err("An address names one pod, so say which pod it is".into());
}
// Both, each on its own schedule: a pod that is awake connects now
// rather than queueing behind its sleeping twin.
let known = state.lock().devices.click_pod_addresses();
for id in PodId::BOTH {
controller.connect(id, known.get(&id).cloned());
}
}
}
Ok(())
}
/// Disconnect one pod, or both when `pod` is omitted.
#[tauri::command]
pub fn disconnect_controller(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.controller().disconnect();
notify(&app, Notice::info("Controller disconnected"));
pub fn disconnect_controller(
app: AppHandle,
state: State<'_, AppState>,
pod: Option<Pod>,
) -> Cmd<()> {
state.controller().disconnect(pod.map(PodId::from));
notify(
&app,
Notice::info(match pod {
Some(Pod::Plus) => "+ pod disconnected",
Some(Pod::Minus) => " pod disconnected",
None => "Both Click pods disconnected",
}),
);
Ok(())
}
/// Exchange the two pods, for when they answer to the other name.
///
/// §2.3.1 confirms one manufacturer-data type byte per pod but not which byte
/// belongs to which, so the app starts from a documented guess. Pressing a
/// paddle shows the rider whether the guess was right; this is how they fix it
/// if it was not.
#[tauri::command]
pub fn swap_controller_pods(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.controller().swap();
notify(&app, Notice::info("Swapped the + and pods"));
Ok(())
}
+1212 -161
View File
File diff suppressed because it is too large Load Diff
+111 -19
View File
@@ -27,6 +27,15 @@ use crate::profile_view::{self, ProfileGeometry, XUnit};
/// Rolling mean window for the speed that feeds ETA. Long enough to survive a
/// soft-pedal over a rise, short enough to react to a real change of pace.
const SPEED_WINDOW_S: f64 = 45.0;
/// Rolling mean window for the speed the rider *reads*.
///
/// Deliberately far shorter than the ETA's. An ETA wants a pace, and averaging
/// three quarters of a minute is right for that. A speedometer wants to answer
/// "what did that do?" — and with virtual gearing a shift changes the speed
/// immediately, so a 45 s mean takes most of a minute to show a change that
/// already happened. Shifting through the whole cassette inside one window
/// averages the lot and reads as the gears doing nothing at all.
const DISPLAY_SPEED_WINDOW_S: f64 = 3.0;
/// Rolling mean window for the displayed power (FR-9.11).
pub const POWER_WINDOW_S: f64 = 10.0;
/// Window for the normalised-power rolling mean (§12 glossary).
@@ -75,6 +84,10 @@ pub struct Derived {
/// 45-second rolling mean. This is what drives ETA; it is also the honest
/// number to show a rider, because instantaneous speed is noise.
pub smoothed_speed_kph: f32,
/// The speed to put on screen: lightly smoothed, so a shift is visible at
/// once. `smoothed_speed_kph` is the ETA's much longer mean and would take
/// most of a minute to show the same change.
pub display_speed_kph: f32,
// --- effort (secondary) ---------------------------------------------------
/// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
@@ -94,6 +107,8 @@ pub struct Derived {
/// Rolling windows. One instance lives in the app state for the whole ride.
pub struct Deriver {
speed: VecDeque<(f64, f32)>,
/// Short window behind the speed on screen; `speed` is the ETA's.
display_speed: VecDeque<(f64, f32)>,
power: VecDeque<(f64, f32)>,
np: VecDeque<(f64, f32)>,
np_fourth_sum: f64,
@@ -116,6 +131,7 @@ impl Default for Deriver {
fn default() -> Self {
Self {
speed: VecDeque::new(),
display_speed: VecDeque::new(),
power: VecDeque::new(),
np: VecDeque::new(),
np_fourth_sum: 0.0,
@@ -175,9 +191,35 @@ impl Deriver {
let power = snapshot.telemetry.power_w.unwrap_or(0) as f32;
let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0);
push_window(&mut self.speed, t, snapshot.virtual_speed_kph, SPEED_WINDOW_S);
push_window(&mut self.power, t, power, POWER_WINDOW_S);
push_window(&mut self.np, t, power, NP_WINDOW_S);
// Only fold a sample in when the ride clock actually moved.
//
// These windows are trimmed by timestamp, so a sample taken while the
// clock is frozen can never expire: `t - t0 > span` is `0 > span`. The
// tick loop runs at a fixed 4 Hz whether or not the ride is running,
// and `elapsed_ms` only advances while it is — so every second spent
// sitting on the ride screen before pressing start used to push four
// more zero-speed samples at t = 0 that nothing would ever evict.
//
// The rider then set off and watched the speed read a fraction of their
// real pace, because the mean was still mostly those zeros, and it only
// came right 45 seconds in when the frozen samples finally aged out.
// Rolling power, normalised power and the ETA all had it too.
if dt > 0.0 {
push_window(
&mut self.speed,
t,
snapshot.virtual_speed_kph,
SPEED_WINDOW_S,
);
push_window(
&mut self.display_speed,
t,
snapshot.virtual_speed_kph,
DISPLAY_SPEED_WINDOW_S,
);
push_window(&mut self.power, t, power, POWER_WINDOW_S);
push_window(&mut self.np, t, power, NP_WINDOW_S);
}
if running {
self.power_sum += power as f64;
@@ -197,6 +239,7 @@ impl Deriver {
}
let smoothed_speed_kph = mean(&self.speed);
let display_speed_kph = mean(&self.display_speed);
// ---- route position and ETA ----------------------------------------
let mut eta_kind = EtaKind::Unavailable;
@@ -279,6 +322,7 @@ impl Deriver {
axis_total,
loop_index,
smoothed_speed_kph,
display_speed_kph,
rolling_power_w: mean(&self.power),
rolling_power_window_s: POWER_WINDOW_S,
avg_power_w: if self.power_n == 0 {
@@ -295,11 +339,8 @@ impl Deriver {
(self.cadence_sum / self.cadence_n as f64) as f32
},
energy_kj: self.energy_kj,
calories_kcal: energy::kcal(
f64::from(self.energy_kj) * 1000.0,
rider_kg,
self.active_s,
) as f32,
calories_kcal: energy::kcal(f64::from(self.energy_kj) * 1000.0, rider_kg, self.active_s)
as f32,
}
}
}
@@ -327,11 +368,20 @@ mod tests {
fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
RideSnapshot {
elapsed_ms: (elapsed_s * 1000.0) as u64,
telemetry: Telemetry { power_w: Some(200), ..Telemetry::default() },
telemetry: Telemetry {
power_w: Some(200),
..Telemetry::default()
},
virtual_speed_kph: speed_kph,
virtual_distance_m: distance_m,
gradient_pct: 0.0,
elevation_gain_m: 0.0,
gear: 6,
gear_count: 12,
development_m: 5.7,
target_cadence_rpm: 0.0,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
pedal_force_n: 120.0,
mode: bikecontrol_core::types::ControlMode::Profile,
target: None,
profile_progress: None,
@@ -358,8 +408,14 @@ mod tests {
looping,
blocks: vec![Block::Segments {
segments: vec![
Segment { distance_m: 1000.0, gradient_pct: 4.0 },
Segment { distance_m: 1000.0, gradient_pct: -2.0 },
Segment {
distance_m: 1000.0,
gradient_pct: 4.0,
},
Segment {
distance_m: 1000.0,
gradient_pct: -2.0,
},
],
}],
}
@@ -372,7 +428,13 @@ mod tests {
let profile = timed_profile();
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(&snapshot(120.0, 0.0, 0.0), true, RIDER_KG, Some(&profile), Some(&geom));
let out = d.update(
&snapshot(120.0, 0.0, 0.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(out.eta_kind, EtaKind::Exact);
assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6);
}
@@ -387,7 +449,13 @@ mod tests {
let mut t = 0.0;
for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 {
t = i as f64 * 0.25;
d.update(&snapshot(t, t * 10.0, 36.0), true, RIDER_KG, Some(&profile), Some(&geom));
d.update(
&snapshot(t, t * 10.0, 36.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
t += 0.25;
let steady = d.update(
@@ -424,7 +492,13 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
d.update(
&snapshot(t, t * 8.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
let moving = d.update(
&snapshot(50.25, 402.0, 28.8),
@@ -451,9 +525,14 @@ mod tests {
}
// The contract: finite, flagged as held, and no longer changing.
assert_eq!(stopped.eta_kind, EtaKind::Held);
let eta = stopped.time_remaining_s.expect("held ETA must still be a number");
let eta = stopped
.time_remaining_s
.expect("held ETA must still be a number");
assert!(eta.is_finite(), "ETA diverged when the rider stopped");
assert_eq!(prev.time_remaining_s, stopped.time_remaining_s, "held ETA still drifting");
assert_eq!(
prev.time_remaining_s, stopped.time_remaining_s,
"held ETA still drifting"
);
}
/// Pausing freezes the estimate rather than letting it creep.
@@ -464,7 +543,13 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
d.update(
&snapshot(t, t * 8.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
let paused = d.update(
&snapshot(50.25, 402.0, 28.8),
@@ -522,7 +607,10 @@ mod tests {
snap.elapsed_ms = 10_250;
snap.telemetry.power_w = Some(600);
let out = d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
assert!(out.rolling_power_w < 250.0, "rolling power tracked the spike too closely");
assert!(
out.rolling_power_w < 250.0,
"rolling power tracked the spike too closely"
);
}
/// An hour at 200 W: the work term dominates, the resting term is the
@@ -536,7 +624,11 @@ mod tests {
out = Some(d.update(&snapshot(i as f64, 0.0, 30.0), true, RIDER_KG, None, None));
}
let out = out.unwrap();
assert!((out.energy_kj - 720.0).abs() < 1.0, "work {} kJ", out.energy_kj);
assert!(
(out.energy_kj - 720.0).abs() < 1.0,
"work {} kJ",
out.energy_kj
);
// 720 kJ of work plus 75 kcal of being alive for an hour.
assert!(
(out.calories_kcal - 763.0).abs() < 5.0,
+165 -23
View File
@@ -18,16 +18,18 @@
//! Controlling`, and it can sit at `Connected` indefinitely if the control
//! point is refused.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
use bikecontrol_ble::PodId;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use uuid::Uuid;
use crate::controller::{ControllerHandle, PodState};
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
@@ -45,13 +47,25 @@ const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805
pub enum DeviceKind {
/// Advertises FTMS (`0x1826`).
Trainer,
/// Zwift custom service, manufacturer type byte identifying the left pod.
ClickLeft,
ClickRight,
/// A Click pod, named for the shift paddle it carries — the type byte in
/// its manufacturer data says which (§2.3.1, FR-1.4).
ClickMinus,
ClickPlus,
HeartRate,
Unknown,
}
impl DeviceKind {
/// Which Click pod this row is, if it is one at all.
pub fn pod_id(self) -> Option<PodId> {
match self {
DeviceKind::ClickMinus => Some(PodId::Minus),
DeviceKind::ClickPlus => Some(PodId::Plus),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
@@ -70,8 +84,11 @@ pub struct DeviceInfo {
/// Previously paired, so it would auto-connect on launch (FR-1.5).
pub remembered: bool,
pub battery_pct: Option<u8>,
/// Zwift unlock validity for Click pods (FR-3.9). `None` for other kinds.
pub unlock_expires_in_s: Option<u64>,
// No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session
// refreshing daily; TASK-0 disproved it on this hardware — the pods answer
// `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1).
// The field was always `None`, which the UI rendered as "expired" against a
// pod that was working perfectly.
/// Human-readable failure, shown verbatim in the UI (FR-9.2).
pub error: Option<String>,
}
@@ -99,6 +116,9 @@ pub struct ScanSnapshot {
pub struct DeviceRegistry {
trainer: TrainerHandle,
/// Held so a Click row in the device list connects the same way its card on
/// the connection screen does — one path, not two that can disagree.
controller: ControllerHandle,
scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
@@ -115,13 +135,14 @@ pub struct DeviceRegistry {
}
impl DeviceRegistry {
pub fn new(trainer: TrainerHandle) -> Self {
pub fn new(trainer: TrainerHandle, controller: ControllerHandle) -> Self {
let (scan_on, scan_on_rx) = watch::channel(false);
let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default());
tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
Self {
last_trainer: trainer.status(),
trainer,
controller,
scan_rx,
scan_on,
forgotten: HashSet::new(),
@@ -172,7 +193,11 @@ impl DeviceRegistry {
let changed = next != self.published;
self.published = next;
PollResult { changed, transitions, trainer_changed }
PollResult {
changed,
transitions,
trainer_changed,
}
}
/// Merge the scan snapshot with the trainer's live status.
@@ -180,14 +205,26 @@ impl DeviceRegistry {
let snapshot = self.scan_rx.borrow().clone();
self.error = snapshot.error.clone();
let controller = self.controller.status();
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
for d in &snapshot.devices {
let id = d.address.clone();
if self.forgotten.contains(&id) {
continue;
}
let kind = classify(d);
// A Click advertises for a few seconds after a button press and
// then goes back to sleep (A-4). Nobody can reliably press Connect
// inside that window, and there is nothing to decide anyway — this
// is the pod the rider already told us about by pressing a button
// on it. So the scan connects it (FR-1.5), unless they disconnected
// it on purpose, in which case the supervisor ignores this.
if let Some(pod) = kind.pod_id() {
self.controller.pod_seen(pod, &id);
}
out.push(DeviceInfo {
kind: classify(d),
kind,
name: d.label(),
address: d.address.clone(),
rssi: d.rssi.unwrap_or(0),
@@ -200,7 +237,6 @@ impl DeviceRegistry {
services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id),
battery_pct: None,
unlock_expires_in_s: None,
error: None,
id,
});
@@ -224,7 +260,6 @@ impl DeviceRegistry {
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true,
battery_pct: None,
unlock_expires_in_s: None,
error: None,
});
out.len() - 1
@@ -245,6 +280,60 @@ impl DeviceRegistry {
}
}
// The same for each Click pod, and for the same reason: a connected pod
// stops advertising, and a row that disappears the moment the pod works
// reads as a pod that has gone (FR-1.4). This is a *view* of what the
// controller supervisor owns — the panel above the list and this row
// are the same link, never two.
for id in PodId::BOTH {
let pod = controller.get(id);
let Some(address) = pod.address.clone() else {
continue;
};
if self.forgotten.contains(&address) {
continue;
}
let kind = match id {
PodId::Minus => DeviceKind::ClickMinus,
PodId::Plus => DeviceKind::ClickPlus,
};
let idx = match out.iter().position(|d| d.id == address) {
Some(i) => i,
None => {
out.push(DeviceInfo {
id: address.clone(),
name: pod.name.clone().unwrap_or_else(|| "Zwift Click".into()),
address,
rssi: 0,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(ZWIFT_SERVICE)],
remembered: true,
battery_pct: None,
error: None,
});
out.len() - 1
}
};
let device = &mut out[idx];
device.kind = kind;
device.battery_pct = pod.battery_percent;
device.error = pod.error.clone();
device.remembered = true;
device.state = match pod.state {
PodState::Connected => ConnectionState::Connected,
PodState::Searching => ConnectionState::Connecting,
PodState::Reconnecting => ConnectionState::Reconnecting,
PodState::GaveUp => ConnectionState::Lost {
reason: "stopped answering".into(),
},
// Nothing has been asked of this pod, so whatever the scan says
// about it stands.
PodState::Idle => device.state.clone(),
};
}
// Trainers first, then by signal strength: the thing the rider is
// looking for should not be below an unnamed peripheral.
out.sort_by(|a, b| {
@@ -268,9 +357,24 @@ impl DeviceRegistry {
let device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
// A Click row connects the pod it *is*, by address. Routed to the
// controller supervisor rather than handled here, so the device list
// and the connection screen drive the same one link per pod (FR-1.4).
if let Some(pod) = device.kind.pod_id() {
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.controller.connect(pod, Some(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.error = None;
info.remembered = true;
return Ok(info);
}
if device.kind != DeviceKind::Trainer {
return Err(format!(
"{} is not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
"{} is neither a trainer nor a Click pod — there is nothing to connect to.",
device.name
));
}
@@ -323,13 +427,18 @@ impl DeviceRegistry {
// SAF-2 runs inside the supervisor before the link drops.
self.trainer.disconnect();
}
if let Some(pod) = device.kind.pod_id() {
self.controller.disconnect(Some(pod));
}
device.state = ConnectionState::Idle;
device.control_acquired = false;
Ok(device)
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self.get(id).ok_or_else(|| format!("no such device: {id}"))?;
let device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.kind == DeviceKind::Trainer && device.control_acquired {
self.trainer.disconnect();
}
@@ -339,6 +448,23 @@ impl DeviceRegistry {
Ok(())
}
/// 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
/// local name, so anything that goes looking for "a Zwift Click" is picking
/// one of the two at random. The scan has already done the identifying
/// work — this hands it to the controller supervisor rather than making it
/// scan again.
pub fn click_pod_addresses(&self) -> HashMap<PodId, String> {
let mut out = HashMap::new();
for device in &self.published {
if let Some(pod) = device.kind.pod_id() {
out.entry(pod).or_insert_with(|| device.address.clone());
}
}
out
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
@@ -359,12 +485,17 @@ fn classify(d: &DiscoveredDevice) -> DeviceKind {
if d.services.contains(&HEART_RATE_SERVICE) {
return DeviceKind::HeartRate;
}
if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() {
// Splitting left pod from right needs the Zwift manufacturer-data type
// byte, which is Phase 3 and unverified against this hardware. Guessing
// would put a wrong label on the connection screen, so it stays Unknown
// and the service UUID is listed instead.
return DeviceKind::Unknown;
// Which pod comes from the manufacturer-data type byte (§2.3.1) — the one
// thing that distinguishes the pair, since both advertise the same name.
match d.pod_id() {
Some(PodId::Minus) => return DeviceKind::ClickMinus,
Some(PodId::Plus) => return DeviceKind::ClickPlus,
// A Zwift device we cannot place: a v1 Click, or the trainer's own
// Zwift service. Labelled by the service rather than guessed at.
None if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() => {
return DeviceKind::Unknown
}
None => {}
}
DeviceKind::Unknown
}
@@ -433,7 +564,11 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
generation += 1;
let snapshot = match result {
Ok(devices) => ScanSnapshot { devices, error: None, generation },
Ok(devices) => ScanSnapshot {
devices,
error: None,
generation,
},
Err(e) => {
tracing::warn!(error = %e, "scan failed");
ScanSnapshot {
@@ -464,7 +599,6 @@ mod tests {
services: Vec::new(),
remembered: false,
battery_pct: None,
unlock_expires_in_s: None,
error: None,
}
}
@@ -497,7 +631,13 @@ mod tests {
#[test]
fn a_lost_link_is_reported() {
let before = vec![info("t", ConnectionState::Controlling, true)];
let after = vec![info("t", ConnectionState::Lost { reason: "gone".into() }, false)];
let after = vec![info(
"t",
ConnectionState::Lost {
reason: "gone".into(),
},
false,
)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(!t[0].control_acquired);
@@ -517,7 +657,9 @@ mod tests {
// list frozen on a snapshot taken before the attempt.
let idle = TrainerStatus::default();
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
state: ConnectionState::Lost {
reason: "gone".into(),
},
..TrainerStatus::default()
};
assert!(should_resume_scan(true, &idle));
+18 -6
View File
@@ -17,6 +17,9 @@ pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
pub const RIDE_STATE: &str = "ride://state";
/// A lap marker was inserted (FR-3.19 / FR-8.7).
pub const RIDE_LAP: &str = "ride://lap";
/// The ride ended and its activity was written (FR-9.13). Carries a
/// `crate::recording::RideSummary`.
pub const RIDE_SUMMARY: &str = "ride://summary";
/// The full device list changed (FR-9.1).
pub const DEVICES_UPDATED: &str = "devices://updated";
/// One device changed connection or control state (FR-1.7, FR-9.3).
@@ -55,12 +58,12 @@ pub struct RideState {
pub manual_gradient_pct: f32,
pub resistance_level: i16,
pub power_target_w: u16,
/// Selected virtual gear, one-based, and how many there are (FR-4.1).
pub gear: usize,
pub gear_count: usize,
pub lap: u32,
pub laps: Vec<LapSummary>,
pub profile: Option<ProfileView>,
/// Which backend is driving the ride: `"ftms"` (the real trainer) or
/// `"mock"` (the synthetic rider, only reachable via `BIKECONTROL_DEMO`).
pub source: &'static str,
/// Trainer link, so the ride screen can say when the numbers stopped being
/// real rather than quietly showing zeros (FR-1.8, FR-9.3).
pub trainer: TrainerStatus,
@@ -102,13 +105,22 @@ pub struct Notice {
impl Notice {
pub fn info(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Info, message: message.into() }
Self {
level: NoticeLevel::Info,
message: message.into(),
}
}
pub fn warn(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Warn, message: message.into() }
Self {
level: NoticeLevel::Warn,
message: message.into(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self { level: NoticeLevel::Error, message: message.into() }
Self {
level: NoticeLevel::Error,
message: message.into(),
}
}
}
+32 -10
View File
@@ -11,13 +11,13 @@ pub mod controller;
pub mod derive;
pub mod devices;
pub mod events;
#[cfg(feature = "mock-ride")]
pub mod mock;
pub mod profile_view;
pub mod recording;
pub mod samples;
pub mod session_backend;
pub mod state;
pub mod trainer;
pub mod wakelock;
use tauri::{Manager, RunEvent, WindowEvent};
@@ -43,9 +43,15 @@ pub fn run() {
commands::toggle_pause,
commands::stop_ride,
commands::reset_ride,
// recording and export
commands::ride_summary,
commands::save_fit,
commands::recovered_rides,
// control modes and targets
commands::set_control_mode,
commands::cycle_control_mode,
commands::shift_gear,
commands::set_gear,
commands::nudge_gradient,
commands::set_gradient,
commands::reset_gradient,
@@ -75,6 +81,7 @@ pub fn run() {
commands::controller_status,
commands::connect_controller,
commands::disconnect_controller,
commands::swap_controller_pods,
])
.setup(|app| {
let handle = app.handle().clone();
@@ -83,12 +90,17 @@ pub fn run() {
state::spawn_ride_loop(handle.clone());
state::spawn_device_loop(handle.clone());
state::spawn_controller_loop(handle.clone());
// `BIKECONTROL_DEMO=1` opens straight onto a running ride with the
// bundled GPX loaded. Purely a development convenience — it makes
// the ride screen reviewable without clicking through first.
if std::env::var("BIKECONTROL_DEMO").is_ok() {
state::start_demo(&handle);
}
// FR-8.4: a journal with no activity beside it is a ride the app
// died during. Rebuilding it is the same code path a clean stop
// uses, so the rider gets the same file they would have had.
//
// The result is stashed rather than emitted: nothing is listening
// on the event channel yet, and a recovered ride is exactly the
// thing that must not be announced to an empty room. The webview
// collects it via `recovered_rides` when it starts.
let recovered = recording::recover_orphans(&handle);
handle.state::<AppState>().lock().recovered = recovered;
recording::prune(&handle, commands::KEEP_RECORDINGS);
state::emit_devices(&handle);
state::emit_ride_state(&handle);
Ok(())
@@ -103,8 +115,18 @@ pub fn run() {
// rider on a loaded trainer. It is idempotent, which matters because
// one quit delivers several of these events.
match &event {
RunEvent::ExitRequested { .. } | RunEvent::Exit => state::shutdown_devices(app),
RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } => {
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
// NFR-11: hand the screensaver back too. The inhibitor would
// lapse with the process anyway, but not before a slow
// shutdown, and a released lock is one fewer thing to explain.
crate::wakelock::set(false);
state::shutdown_devices(app)
}
RunEvent::WindowEvent {
event: WindowEvent::Destroyed,
..
} => {
crate::wakelock::set(false);
state::shutdown_devices(app)
}
_ => {}
-228
View File
@@ -1,228 +0,0 @@
//! A synthetic rider, so the UI can be built and judged with no trainer on the
//! desk.
//!
//! No longer the default: the app rides `RideSession` on real FTMS telemetry
//! unless `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1` selects this instead.
//! It is compiled only under the `mock-ride` feature, so a build made with
//! `--no-default-features` cannot show fake data at all.
//!
//! It fabricates plausible power and cadence, then runs them through the §5.7
//! physics equations to get virtual speed, distance and elevation gain. The
//! numbers are fake; their *shape* is not — power is deliberately noisy so the
//! rolling average (FR-9.11) has something to smooth, and speed responds to
//! gradient with inertia rather than snapping (FR-7.3).
//!
//! Replaced wholesale by a `RideSession`-backed implementation; see
//! [`crate::backend::RideBackend`].
use bikecontrol_core::profile::Position;
use bikecontrol_core::types::{ControlMode, ControlTarget, RideSnapshot, Telemetry};
use crate::backend::{RideBackend, RideInputs, Tick};
use crate::events::RideStatus;
/// Deterministic, dependency-free noise source.
struct Rng(u64);
impl Rng {
fn next_f32(&mut self) -> f32 {
// xorshift64*
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
/// Symmetric noise in `[-1, 1]`.
fn bipolar(&mut self) -> f32 {
self.next_f32() * 2.0 - 1.0
}
}
pub struct MockBackend {
rng: Rng,
t_s: f64,
elapsed_ms: u64,
speed_ms: f32,
distance_m: f64,
elevation_gain_m: f32,
energy_kj: f32,
power_w: f32,
cadence: f32,
/// Slow effort wander, so the rider drifts rather than jitters.
effort: f32,
last_target: Option<ControlTarget>,
}
impl Default for MockBackend {
fn default() -> Self {
Self {
rng: Rng(0x9E37_79B9_7F4A_7C15),
t_s: 0.0,
elapsed_ms: 0,
speed_ms: 0.0,
distance_m: 0.0,
elevation_gain_m: 0.0,
energy_kj: 0.0,
power_w: 0.0,
cadence: 0.0,
effort: 1.0,
last_target: None,
}
}
}
impl MockBackend {
/// Base gradient before the rider's manual trim.
fn base_gradient(&self, inputs: &RideInputs) -> f32 {
match inputs.mode {
ControlMode::Profile => inputs
.profile
.as_deref()
.and_then(|p| p.sample(self.position()))
.and_then(|t| match t {
ControlTarget::Gradient { percent } => Some(percent),
_ => None,
})
.unwrap_or(0.0),
_ => inputs.manual_gradient_pct,
}
}
/// What the profile wants right now, whatever channel it drives.
fn profile_target(&self, inputs: &RideInputs) -> Option<ControlTarget> {
inputs.profile.as_deref().and_then(|p| p.sample(self.position()))
}
fn position(&self) -> Position {
Position { elapsed_s: self.t_s, distance_m: self.distance_m }
}
}
impl RideBackend for MockBackend {
fn source(&self) -> &'static str {
"mock"
}
fn reset(&mut self) {
let rng = Rng(self.rng.0);
*self = Self { rng, ..Self::default() };
}
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
let running = inputs.status == RideStatus::Running;
if running {
self.t_s += dt_s as f64;
self.elapsed_ms += (dt_s * 1000.0).round() as u64;
}
let gradient_pct = self.base_gradient(inputs) + inputs.gradient_offset_pct;
// ---- what we would send to the trainer -----------------------------
let raw_target = match inputs.mode {
ControlMode::ManualGrade => ControlTarget::Gradient { percent: gradient_pct },
ControlMode::Resistance => ControlTarget::Resistance { level: inputs.resistance_level },
ControlMode::Erg => ControlTarget::Power { watts: inputs.power_target_w },
ControlMode::Profile => match self.profile_target(inputs) {
Some(ControlTarget::Gradient { .. }) | None => {
ControlTarget::Gradient { percent: gradient_pct }
}
Some(other) => other,
},
};
// SAF-3: clamped at the point of transmission, whatever the source.
let target = inputs.limits.clamp(raw_target);
// ---- synthesise a rider --------------------------------------------
if running {
// Slow wander in effort plus a breathing cycle.
self.effort += (self.rng.bipolar() * 0.02 - (self.effort - 1.0) * 0.02) * dt_s;
self.effort = self.effort.clamp(0.75, 1.3);
let breathing = 1.0 + 0.06 * (self.t_s as f32 / 23.0).sin();
let demand = match target {
ControlTarget::Power { watts } => watts as f32,
ControlTarget::Resistance { level } => 90.0 + level as f32 * 3.2,
ControlTarget::Gradient { percent } => 165.0 + percent * 13.0,
};
let wanted = (demand * self.effort * breathing).clamp(0.0, 800.0);
// First-order lag: legs do not step.
let tau = 2.5;
self.power_w += (wanted - self.power_w) * (dt_s / tau).min(1.0);
let noisy = (self.power_w + self.rng.bipolar() * 14.0).max(0.0);
let cadence_wanted = (78.0 + 14.0 * self.effort - gradient_pct * 1.1).clamp(55.0, 105.0);
self.cadence += (cadence_wanted - self.cadence) * (dt_s / 1.8).min(1.0);
self.energy_kj += noisy * dt_s / 1000.0;
// ---- §5.7 physics ----------------------------------------------
let cfg = inputs.rider;
let m = cfg.total_mass_kg();
let g = 9.80665f32;
let theta = (gradient_pct / 100.0).atan();
let v = self.speed_ms.max(0.5);
let f_prop = (noisy * cfg.drivetrain_efficiency) / v;
let f_grav = m * g * theta.sin();
let f_roll = m * g * cfg.crr * theta.cos();
let f_aero = 0.5 * cfg.air_density * cfg.cda * self.speed_ms * self.speed_ms;
let a = (f_prop - f_grav - f_roll - f_aero) / m;
self.speed_ms = (self.speed_ms + a * dt_s).max(0.0);
let step = self.speed_ms as f64 * dt_s as f64;
self.distance_m += step;
if gradient_pct > 0.0 {
self.elevation_gain_m += (step * (gradient_pct as f64 / 100.0)) as f32;
}
} else {
// Coast down when paused so the readouts settle rather than freeze.
self.power_w *= 1.0 - (dt_s * 2.0).min(1.0);
self.cadence *= 1.0 - (dt_s * 2.0).min(1.0);
self.speed_ms *= 1.0 - (dt_s * 0.6).min(1.0);
}
let power_out = if running { (self.power_w + self.rng.bipolar() * 12.0).max(0.0) } else { 0.0 };
let telemetry = Telemetry {
elapsed_ms: self.elapsed_ms,
power_w: Some(power_out.round() as i16),
cadence_rpm: Some(if self.cadence < 2.0 { 0.0 } else { self.cadence }),
// Trainer-reported speed is deliberately a little off the virtual
// speed — it is diagnostic only (FR-7.5).
speed_kph: Some(self.speed_ms * 3.6 * 0.98),
resistance_level: match target {
ControlTarget::Resistance { level } => Some(level),
_ => None,
},
heart_rate_bpm: Some((118.0 + power_out * 0.13).clamp(60.0, 195.0) as u8),
total_distance_m: Some(self.distance_m as u32),
total_energy_kcal: Some((self.energy_kj / 4.184) as u16),
};
let snapshot = RideSnapshot {
elapsed_ms: self.elapsed_ms,
telemetry,
// Not running means not moving, and the readout must agree.
virtual_speed_kph: if running { self.speed_ms * 3.6 } else { 0.0 },
virtual_distance_m: self.distance_m,
gradient_pct,
elevation_gain_m: self.elevation_gain_m,
mode: inputs.mode,
target: Some(target),
profile_progress: inputs
.profile
.as_deref()
.and_then(|p| p.total_extent().progress(self.position())),
};
let changed = self.last_target != Some(target);
self.last_target = Some(target);
Tick {
snapshot,
// SAF-1/SAF-8: only transmit while running, and only on change —
// the real backend rate-limits to ≤4 Hz here too (FR-2.8).
command: (running && changed).then_some(target),
}
}
}
+22 -7
View File
@@ -8,9 +8,7 @@
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.
use bikecontrol_core::profile::{
Block, Channel, Extent, Position, Profile, Waveform,
};
use bikecontrol_core::profile::{Block, Channel, Extent, Position, Profile, Waveform};
use serde::Serialize;
/// Which axis the profile is drawn against.
@@ -114,7 +112,11 @@ fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
let (x0, x1) = (xs[i - 1], xs[i]);
let (y0, y1) = (ys[i - 1], ys[i]);
let span = x1 - x0;
Some(if span.abs() < f64::EPSILON { y1 } else { y0 + (y1 - y0) * ((x - x0) / span) as f32 })
Some(if span.abs() < f64::EPSILON {
y1
} else {
y0 + (y1 - y0) * ((x - x0) / span) as f32
})
}
const PREVIEW_SAMPLES: usize = 1400;
@@ -145,7 +147,11 @@ pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, Prof
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
let channel = profile.blocks.first().map(|b| b.channel()).unwrap_or(Channel::Gradient);
let channel = profile
.blocks
.first()
.map(|b| b.channel())
.unwrap_or(Channel::Gradient);
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
// block that already carries surveyed elevation. Otherwise integrate the
@@ -280,7 +286,10 @@ pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f6
/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
Position { elapsed_s, distance_m }
Position {
elapsed_s,
distance_m,
}
}
fn block_kind(block: &Block) -> &'static str {
@@ -306,7 +315,13 @@ fn block_label(block: &Block) -> String {
match block {
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u}{to:.0}{u}"),
Block::Wave { shape, midpoint, amplitude, repeats, .. } => format!(
Block::Wave {
shape,
midpoint,
amplitude,
repeats,
..
} => format!(
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
match shape {
Waveform::Sine => "sine",
+549
View File
@@ -0,0 +1,549 @@
//! Ride recording: the journal, the FIT file, and where both live.
//!
//! Two files per ride, in the app's data directory:
//!
//! ```text
//! rides/2026-08-05T18-42-10.jsonl the raw journal (FR-8.4)
//! rides/2026-08-05T18-42-10.fit the activity, written at stop
//! ```
//!
//! Neither path is chosen by the rider. The journal is crash-safety scaffolding
//! and the FIT written next to it is the automatic copy that exists so a ride
//! can never be lost to a cancelled dialog — see [`finish`]. Saving *somewhere
//! the rider picked* (FR-9.14) is a separate copy, made afterwards, from a file
//! that is already safely on disk.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use bikecontrol_core::types::RideSnapshot;
use bikecontrol_fit::profile::enums;
use bikecontrol_fit::{FitSummary, Recorder, RecorderOptions, Sample};
use serde::Serialize;
use tauri::{AppHandle, Manager};
/// The recording of one ride, plus where its output landed.
struct Active {
recorder: Recorder,
fit_path: PathBuf,
}
/// The live recorder, behind its own lock.
///
/// Deliberately *not* a field of [`crate::state::Inner`]. `RecorderOptions`
/// defaults to an `fsync` every ten samples, and holding the ride-state mutex
/// across a blocking `sync_data` would stall every command and the device loop
/// behind the disk. The ride loop takes this lock only after it has dropped the
/// other one.
#[derive(Clone, Default)]
pub struct RecorderHandle(Arc<Mutex<Option<Active>>>);
impl RecorderHandle {
fn lock(&self) -> MutexGuard<'_, Option<Active>> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn is_recording(&self) -> bool {
self.lock().is_some()
}
/// Begin recording. Any recorder still open is abandoned first — its
/// journal stays on disk and can be rebuilt, but it is not this ride.
///
/// Returns the journal path, or the reason recording could not start. A
/// failure here is not fatal to the ride: the caller reports it and the
/// rider carries on unrecorded, which is worse than recording but far
/// better than refusing to ride.
/// `dir` is passed in rather than resolved from the `AppHandle` so that the
/// whole start/record/finish path can be exercised without a Tauri app.
pub fn start(&self, dir: &Path, opts: RideRecordingSetup) -> Result<PathBuf, String> {
let stamp = opts.stamp;
let log_path = dir.join(format!("{stamp}.jsonl"));
let fit_path = dir.join(format!("{stamp}.fit"));
let mut slot = self.lock();
if let Some(prev) = slot.take() {
let orphan = prev.recorder.abandon();
tracing::warn!(path = %orphan.display(), "a recording was still open; abandoning it");
}
let recorder = Recorder::create(
&log_path,
RecorderOptions {
// A ride following a loaded route is a Virtual Ride; a plain
// trainer session with no course is indoor cycling. Getting
// this wrong files every ERG workout as a virtual ride.
sub_sport: if opts.has_profile {
enums::SUB_SPORT_VIRTUAL_ACTIVITY
} else {
enums::SUB_SPORT_INDOOR_CYCLING
},
rider_kg: opts.rider_kg,
software_version: software_version(),
..Default::default()
},
)
.map_err(|e| format!("could not start recording: {e}"))?;
tracing::info!(journal = %log_path.display(), "recording started");
*slot = Some(Active { recorder, fit_path });
Ok(log_path)
}
/// Record one tick. Throttled to 1 Hz inside the recorder, so this is safe
/// and cheap to call on every engine tick.
pub fn record(&self, snapshot: &RideSnapshot, altitude_m: Option<f32>) {
let mut slot = self.lock();
let Some(active) = slot.as_mut() else { return };
// `Sample::from_snapshot` leaves `gear` unset even though the snapshot
// carries one, hence `record_sample` rather than `record`.
// Gears are one-based and there are a dozen or so; the cast cannot
// realistically saturate, but clamping beats a panic in the ride loop.
let gear = u8::try_from(snapshot.gear).unwrap_or(u8::MAX);
let mut sample = Sample::from_snapshot(snapshot).with_gear(gear);
if let Some(altitude) = altitude_m {
sample = sample.with_altitude(altitude);
}
if let Err(e) = active.recorder.record_sample(sample) {
tracing::warn!(%e, "dropped a sample");
}
}
/// Run something against the live recorder, if there is one. Errors are
/// logged rather than propagated: a journal write that fails must not turn
/// a lap press or a pause into a failed command.
fn with(
&self,
what: &'static str,
f: impl FnOnce(&mut Recorder) -> Result<(), bikecontrol_fit::FitError>,
) {
let mut slot = self.lock();
let Some(active) = slot.as_mut() else { return };
if let Err(e) = f(&mut active.recorder) {
tracing::warn!(%e, "could not journal {what}");
}
}
pub fn mark_lap(&self, at_ms: u64, from_controller: bool) {
self.with("lap", |r| r.mark_lap(at_ms, from_controller));
}
pub fn pause(&self, at_ms: u64) {
self.with("pause", |r| r.pause(at_ms));
}
pub fn resume(&self, at_ms: u64) {
self.with("resume", |r| r.resume(at_ms));
}
/// Note a telemetry dropout (FR-8.5). Repeated calls inside one dropout are
/// a no-op in the recorder, so the device loop can call this freely.
pub fn mark_gap(&self, at_ms: u64, reason: impl Into<String>) {
let reason = reason.into();
self.with("dropout", move |r| r.mark_gap(at_ms, reason));
}
/// Close the journal and write the FIT beside it.
///
/// `Ok(None)` means there was nothing recording — stopping a ride that
/// never started is not an error.
pub fn finish(&self) -> Result<Option<(FitSummary, PathBuf)>, String> {
let Some(active) = self.lock().take() else {
return Ok(None);
};
let Active { recorder, fit_path } = active;
let journal = recorder.log_path().to_path_buf();
match recorder.finish(&fit_path) {
Ok(summary) => {
tracing::info!(
path = %fit_path.display(),
records = summary.records,
"activity written"
);
Ok(Some((summary, fit_path)))
}
// The journal survives, so say where it is. "Failed to save" with no
// path would leave a recoverable ride looking like a lost one.
Err(e) => Err(format!(
"could not write the FIT file ({e}) — the ride is still recorded at {}",
journal.display()
)),
}
}
}
/// What [`RecorderHandle::start`] needs from the ride state, read under the
/// other lock and handed over so the two are never held at once.
pub struct RideRecordingSetup {
pub stamp: String,
pub rider_kg: f32,
pub has_profile: bool,
}
/// The finished ride, as the summary screen shows it (FR-9.13, FR-9.14).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideSummary {
pub duration_s: f64,
/// Excludes time spent paused.
pub moving_s: f64,
pub distance_m: f64,
pub ascent_m: u16,
pub avg_power_w: Option<u16>,
pub max_power_w: Option<u16>,
pub avg_cadence_rpm: Option<u8>,
pub calories: Option<u16>,
pub records: usize,
pub laps: usize,
/// BLE dropouts spanned (FR-8.5).
pub gaps: usize,
/// True when the activity was rebuilt from a journal with no end marker.
pub recovered_from_crash: bool,
/// Journal lines that could not be parsed. Non-zero means data was lost.
pub skipped_log_lines: usize,
/// The automatic copy, in the app's data directory.
pub fit_path: String,
/// Where the rider chose to save it, once they have (FR-9.14).
pub saved_path: Option<String>,
}
impl RideSummary {
pub fn new(summary: &FitSummary, fit_path: &Path) -> Self {
Self {
duration_s: summary.total_elapsed_s,
moving_s: summary.total_timer_s,
distance_m: summary.total_distance_m,
ascent_m: summary.total_ascent_m,
avg_power_w: summary.avg_power_w,
max_power_w: summary.max_power_w,
avg_cadence_rpm: summary.avg_cadence_rpm,
calories: summary.total_calories,
records: summary.records,
laps: summary.laps,
gaps: summary.gaps,
recovered_from_crash: summary.recovered_from_crash,
skipped_log_lines: summary.skipped_log_lines,
fit_path: fit_path.display().to_string(),
saved_path: None,
}
}
}
/// `rides/` inside the app's data directory, created if absent.
pub fn rides_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no app data directory: {e}"))?
.join("rides");
std::fs::create_dir_all(&dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
Ok(dir)
}
/// A filename stem for a ride starting now: local time, sortable, no colons
/// (Windows will not have them).
pub fn stamp_now() -> String {
chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string()
}
/// The app version as FIT wants it: scaled by 100, so 0.1.0 is `10`.
fn software_version() -> u16 {
let v = env!("CARGO_PKG_VERSION");
let mut parts = v.split('.');
let major: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
major.saturating_mul(100).saturating_add(minor)
}
/// Copy the finished activity to a path the rider chose (FR-9.14).
///
/// A copy, never a move: the automatic file stays where it is so that saving
/// twice, or saving to a disk that then fills up, cannot lose the ride.
pub fn save_copy(from: &Path, to: &Path) -> Result<(), String> {
if !from.exists() {
return Err(format!("{} is gone — nothing to save", from.display()));
}
if let Some(parent) = to.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
}
std::fs::copy(from, to)
.map(|_| ())
.map_err(|e| format!("could not save to {}: {e}", to.display()))
}
// ---------------------------------------------------------------------------
// Crash recovery (FR-8.4)
// ---------------------------------------------------------------------------
/// A journal with no activity beside it: a ride that ended when the process did.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Recovered {
pub fit_path: String,
pub summary: RideSummary,
}
/// Rebuild an activity for every journal that has no FIT next to it.
///
/// The encoder guarantees a file rebuilt from a journal is byte-identical to
/// one written by a clean shutdown of the same ride, so this is not a degraded
/// path — it is the same path, run late.
///
/// Journals that produce nothing useful (a ride that recorded no samples before
/// the crash) are deleted rather than left to be retried on every launch.
pub fn recover_orphans(app: &AppHandle) -> Vec<Recovered> {
match rides_dir(app) {
Ok(dir) => recover_in(&dir),
Err(e) => {
tracing::warn!(%e, "cannot reach the rides directory; skipping recovery");
Vec::new()
}
}
}
/// [`recover_orphans`] against a directory, so it can be tested without a Tauri
/// app.
pub fn recover_in(dir: &Path) -> Vec<Recovered> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut recovered = Vec::new();
for entry in entries.flatten() {
let log_path = entry.path();
if log_path.extension().is_none_or(|e| e != "jsonl") {
continue;
}
let fit_path = log_path.with_extension("fit");
if fit_path.exists() {
continue;
}
match bikecontrol_fit::build_fit_from_log(&log_path, &fit_path) {
Ok(summary) => {
tracing::info!(
journal = %log_path.display(),
records = summary.records,
"recovered an interrupted ride"
);
recovered.push(Recovered {
fit_path: fit_path.display().to_string(),
summary: RideSummary::new(&summary, &fit_path),
});
}
Err(e) => {
tracing::warn!(
journal = %log_path.display(),
%e,
"journal holds no usable ride; removing it"
);
let _ = std::fs::remove_file(&log_path);
}
}
}
recovered
}
/// Delete the oldest rides once there are more than `keep`.
///
/// §5.8 puts in-app ride history out of scope for v1 — the FIT the rider saved
/// is the artifact, and what is left here is a safety net, not a library. Left
/// unbounded it would grow forever in a directory nobody opens. A ride is only
/// ever removed once its FIT exists, so nothing is deleted before it has been
/// through the encoder.
pub fn prune(app: &AppHandle, keep: usize) {
let Ok(dir) = rides_dir(app) else { return };
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// Stems are timestamps, so lexical order is chronological.
let mut stems: Vec<String> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "fit"))
.filter_map(|e| e.path().file_stem()?.to_str().map(str::to_owned))
.collect();
if stems.len() <= keep {
return;
}
stems.sort();
let doomed = stems.len() - keep;
for stem in stems.into_iter().take(doomed) {
let _ = std::fs::remove_file(dir.join(format!("{stem}.fit")));
let _ = std::fs::remove_file(dir.join(format!("{stem}.jsonl")));
tracing::info!(ride = %stem, "pruned an old recording");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_core::types::{ControlMode, RideSnapshot, Telemetry};
fn tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bc-rec-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn setup() -> RideRecordingSetup {
RideRecordingSetup {
stamp: "2026-08-05T18-42-10".into(),
rider_kg: 78.0,
has_profile: true,
}
}
fn snapshot(elapsed_ms: u64) -> RideSnapshot {
RideSnapshot {
elapsed_ms,
telemetry: Telemetry {
elapsed_ms,
power_w: Some(230),
cadence_rpm: Some(91.0),
..Default::default()
},
virtual_speed_kph: 30.0,
virtual_distance_m: elapsed_ms as f64 * 0.008_333,
gradient_pct: 2.0,
elevation_gain_m: elapsed_ms as f32 * 0.000_15,
gear: 7,
gear_count: 12,
development_m: 6.3,
target_cadence_rpm: 90.0,
pedal_force_n: 120.0,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
mode: ControlMode::Profile,
target: None,
profile_progress: None,
}
}
/// The whole app-side recording path, without a Tauri app: start, record a
/// ride's worth of ticks at the real 4 Hz rate, mark a lap and a pause, and
/// finish. The FIT that comes out must be one an uploader would accept.
#[test]
fn a_ride_records_end_to_end_and_produces_a_verifiable_fit() {
let dir = tmpdir("e2e");
let rec = RecorderHandle::default();
assert!(!rec.is_recording());
let journal = rec.start(&dir, setup()).unwrap();
assert!(rec.is_recording());
assert_eq!(journal, dir.join("2026-08-05T18-42-10.jsonl"));
// 60 s of ride at the ride loop's 4 Hz. The recorder throttles to 1 Hz
// internally, so this also proves the throttle survives the wiring.
for tick in 0..240u64 {
rec.record(&snapshot(tick * 250), Some(120.0 + tick as f32 * 0.01));
}
rec.mark_lap(30_000, false);
rec.pause(40_000);
rec.resume(50_000);
rec.mark_gap(55_000, "trainer went quiet");
let (summary, fit_path) = rec.finish().unwrap().expect("a ride was recording");
assert!(!rec.is_recording(), "finish must release the recorder");
assert_eq!(fit_path, dir.join("2026-08-05T18-42-10.fit"));
assert_eq!(summary.records, 60, "60 s at 1 Hz after throttling");
assert_eq!(summary.laps, 2, "one marker splits the ride in two");
assert!(!summary.recovered_from_crash);
assert_eq!(summary.skipped_log_lines, 0);
assert_eq!(summary.avg_power_w, Some(230));
assert_eq!(summary.avg_cadence_rpm, Some(91), "FR-9.13 needs this");
// The file itself, not just what the encoder claimed about it.
let bytes = std::fs::read(&fit_path).unwrap();
assert_eq!(bytes.len(), summary.bytes);
bikecontrol_fit::verify(&bytes).expect("a FIT an uploader would reject");
// And the summary the screen renders agrees with it.
let view = RideSummary::new(&summary, &fit_path);
assert_eq!(view.avg_cadence_rpm, Some(91));
assert_eq!(view.saved_path, None, "not saved anywhere yet");
// The ten seconds of pause are elapsed time but not moving time, which
// is the distinction the summary screen shows as "N paused".
assert!(
view.moving_s < view.duration_s,
"moving {} vs duration {}",
view.moving_s,
view.duration_s
);
let _ = std::fs::remove_dir_all(dir);
}
/// Stopping a ride that never started is not an error.
#[test]
fn finishing_without_recording_is_not_a_failure() {
assert!(RecorderHandle::default().finish().unwrap().is_none());
}
/// FR-8.4: a journal left behind by a crash becomes an activity on the next
/// launch, and one that already has its FIT is left alone.
#[test]
fn an_orphaned_journal_is_rebuilt_and_a_finished_one_is_not() {
let dir = tmpdir("orphan");
let rec = RecorderHandle::default();
rec.start(&dir, setup()).unwrap();
for tick in 0..40u64 {
rec.record(&snapshot(tick * 250), None);
}
// Drop the handle's contents without finishing — the crash case.
let orphan = dir.join("2026-08-05T18-42-10.jsonl");
drop(rec);
assert!(orphan.exists());
assert!(!dir.join("2026-08-05T18-42-10.fit").exists());
let rebuilt = recover_in(&dir);
assert_eq!(rebuilt.len(), 1);
assert!(rebuilt[0].summary.recovered_from_crash);
bikecontrol_fit::verify(&std::fs::read(&rebuilt[0].fit_path).unwrap()).unwrap();
// Second pass: the FIT now exists, so there is nothing left to recover.
assert!(
recover_in(&dir).is_empty(),
"recovery must not repeat itself"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_software_version_is_scaled_the_way_fit_wants() {
// Whatever the crate version is, the encoding must not panic or
// overflow; the shape is what matters.
let v = software_version();
assert!(v < 10_000);
}
#[test]
fn a_stamp_is_a_sortable_filename() {
let s = stamp_now();
assert_eq!(s.len(), 19, "YYYY-MM-DDTHH-MM-SS");
assert!(!s.contains(':'), "colons are illegal in Windows filenames");
}
#[test]
fn saving_a_copy_leaves_the_original() {
let dir = std::env::temp_dir().join(format!("bc-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let from = dir.join("ride.fit");
std::fs::write(&from, b"activity").unwrap();
let to = dir.join("nested").join("saved.fit");
save_copy(&from, &to).unwrap();
assert_eq!(std::fs::read(&to).unwrap(), b"activity");
assert!(from.exists(), "the automatic copy must survive the save");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn saving_from_a_missing_file_says_so() {
let missing = std::env::temp_dir().join("bc-definitely-not-here.fit");
let err = save_copy(&missing, &std::env::temp_dir().join("out.fit")).unwrap_err();
assert!(err.contains("nothing to save"), "{err}");
}
}
+92 -12
View File
@@ -73,22 +73,51 @@ blocks:
extent: { seconds: 600 }
"#;
/// The gearing bench test, shipped rather than kept in a scratch file because
/// it is the fastest way to answer "do the gears and the resistance work?" on
/// real hardware. Flat on purpose: on a slope, gravity swamps everything and a
/// broken gear ratio still feels like a hill.
const DRAG_RACE: &str = r#"name: Drag race
description: >-
A standing-start kilometre on a dead-flat road, for testing that the gears and
the resistance actually do something. Start in bottom gear from a stop and
wind it up: every shift should land under the pedals at once, and holding one
gear should get harder as you speed up, because on the flat drag is the only
thing resisting you and it grows with the square of speed. If shifting feels
like nothing, the control writes are not reaching the trainer. Loops, so you
can go again in a different gear and compare the time.
looping: true
blocks:
- type: segments
segments:
- distance_m: 1000.0
gradient_pct: 0.0
"#;
/// A real GPX, bundled so the route view has something to draw on first run.
const SAMPLE_CLIMB_GPX: &str = include_str!("../../testdata/sample-climb.gpx");
pub fn all() -> Vec<SampleProfile> {
let mut out: Vec<SampleProfile> = [OVER_UNDERS, HILL_REPEATS, SAWTOOTH_GRADE, STEADY_ENDURANCE]
.iter()
.map(|yaml| {
let (name, summary) = header(yaml);
SampleProfile {
name,
summary,
text: (*yaml).to_string(),
is_gpx: false,
}
})
.collect();
// Drag race first among the written profiles: it is the bench test, and the
// thing most likely to be wanted in a hurry when the gearing feels wrong.
let mut out: Vec<SampleProfile> = [
DRAG_RACE,
OVER_UNDERS,
HILL_REPEATS,
SAWTOOTH_GRADE,
STEADY_ENDURANCE,
]
.iter()
.map(|yaml| {
let (name, summary) = header(yaml);
SampleProfile {
name,
summary,
text: (*yaml).to_string(),
is_gpx: false,
}
})
.collect();
out.insert(
0,
SampleProfile {
@@ -113,3 +142,54 @@ fn header(yaml: &str) -> (String, String) {
}
(name, summary)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_profile_parses() {
// These are only ever exercised when a rider clicks one, so a typo in a
// heredoc ships and stays shipped. Parsing them here is the difference
// between finding that at compile time and finding it mid-warm-up.
for sample in all() {
if sample.is_gpx {
continue;
}
bikecontrol_core::profile::Profile::from_yaml(&sample.text)
.unwrap_or_else(|e| panic!("sample profile {:?} does not parse: {e}", sample.name));
}
}
#[test]
fn the_drag_race_is_offered_and_is_the_flat_kilometre_it_claims() {
// Flat is the whole point: on a slope gravity swamps the gearing and a
// broken ratio still feels like a hill.
let sample = all()
.into_iter()
.find(|s| s.name == "Drag race")
.expect("the drag race must reach the picker — it was defined but unlisted once");
let profile = bikecontrol_core::profile::Profile::from_yaml(&sample.text).unwrap();
assert!(
profile.looping,
"you must be able to go again without reloading"
);
let extent = profile.total_extent();
let metres = extent
.metres
.expect("a drag race is measured in distance, not time");
assert!(
(metres - 1000.0).abs() < 1.0,
"expected a kilometre, got {metres} m"
);
}
#[test]
fn a_name_and_summary_are_extracted_for_every_sample() {
for sample in all() {
assert!(!sample.name.is_empty(), "a nameless entry in the picker");
assert_ne!(sample.name, "Profile", "fell back to the placeholder name");
}
}
}
+177 -19
View File
@@ -1,7 +1,7 @@
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
//! telemetry.
//!
//! This is the app's default data source. It holds the latest decoded Indoor
//! This is the app's only data source. It holds the latest decoded Indoor
//! Bike Data sample — published by [`crate::trainer`] from `bikecontrol_ble`'s
//! telemetry stream — and feeds it to the ride engine once per tick.
//!
@@ -17,6 +17,7 @@
use bikecontrol_core::session::{RideSession, SessionEvent};
use bikecontrol_core::types::{RideSnapshot, Telemetry};
use bikecontrol_core::{Gearing, VirtualCassette};
use tokio::sync::watch;
use crate::backend::{RideBackend, RideInputs, Tick};
@@ -30,26 +31,28 @@ pub struct SessionBackend {
/// Set once a profile has been handed to the session, so a profile swap is
/// noticed but the same profile is not reloaded every tick.
loaded_profile: Option<usize>,
/// The cassette currently installed in the session, for the same reason.
loaded_cassette: VirtualCassette,
}
impl SessionBackend {
pub fn new(inputs: &RideInputs, telemetry: watch::Receiver<Telemetry>) -> Self {
let mut session = RideSession::new(inputs.rider, inputs.limits);
session.gearing = Gearing::new(inputs.cassette.clone());
Self {
session: RideSession::new(inputs.rider, inputs.limits),
session,
telemetry,
last_snapshot: None,
loaded_profile: None,
loaded_cassette: inputs.cassette.clone(),
}
}
}
impl RideBackend for SessionBackend {
fn source(&self) -> &'static str {
"ftms"
}
fn reset(&mut self) {
self.session = RideSession::new(self.session.config, self.session.limits);
self.session.gearing = Gearing::new(self.loaded_cassette.clone());
self.last_snapshot = None;
self.loaded_profile = None;
}
@@ -69,7 +72,10 @@ impl RideBackend for SessionBackend {
Some(profile) => {
// `Arc` identity, not contents: reloading resets the session's
// position, which must not happen every tick.
let id = inputs.profile.as_ref().map(|p| std::sync::Arc::as_ptr(p) as usize);
let id = inputs
.profile
.as_ref()
.map(|p| std::sync::Arc::as_ptr(p) as usize);
if self.loaded_profile != id {
self.session.load_profile(profile.clone());
self.session.mode = inputs.mode;
@@ -90,6 +96,13 @@ impl RideBackend for SessionBackend {
self.session.reset_gradient_offset();
self.session.nudge_gradient(offset);
// The cassette is the rider's, not the session's default. Rebuilding
// the gearing is only correct when it actually changed — doing it every
// tick would reset the cadence readout to zero forever.
if self.loaded_cassette != inputs.cassette {
self.session.gearing = Gearing::new(inputs.cassette.clone());
self.loaded_cassette = inputs.cassette.clone();
}
self.session.gearing.set_gear(inputs.gear);
match inputs.status {
@@ -133,7 +146,11 @@ mod tests {
use bikecontrol_core::types::{ControlMode, ControlTarget};
fn running(mode: ControlMode) -> RideInputs {
RideInputs { status: RideStatus::Running, mode, ..RideInputs::default() }
RideInputs {
status: RideStatus::Running,
mode,
..RideInputs::default()
}
}
#[test]
@@ -141,7 +158,6 @@ mod tests {
let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
assert_eq!(backend.source(), "ftms");
// No power: nothing moves.
for _ in 0..8 {
@@ -149,8 +165,14 @@ mod tests {
}
assert_eq!(backend.tick(0.25, &inputs).snapshot.virtual_distance_m, 0.0);
// 200 W from the trainer: the engine accelerates.
let _ = tx.send(Telemetry { power_w: Some(200), ..Telemetry::default() });
// 200 W and turning the cranks: the engine moves. Cadence is not
// garnish — speed is cadence × the selected gear, so a sample with
// power and no cadence is a bike going nowhere.
let _ = tx.send(Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Telemetry::default()
});
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
@@ -162,7 +184,11 @@ mod tests {
#[test]
fn losing_the_trainer_coasts_to_a_stop_rather_than_freezing() {
let (tx, rx) = watch::channel(Telemetry { power_w: Some(250), ..Telemetry::default() });
let (tx, rx) = watch::channel(Telemetry {
power_w: Some(250),
cadence_rpm: Some(85.0),
..Telemetry::default()
});
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
for _ in 0..60 {
@@ -177,7 +203,10 @@ mod tests {
backend.tick(0.25, &inputs);
}
let stopped = backend.tick(0.25, &inputs).snapshot;
assert!(stopped.virtual_speed_kph < moving, "speed must decay, not hold");
assert!(
stopped.virtual_speed_kph < moving,
"speed must decay, not hold"
);
assert_eq!(stopped.telemetry.power_w, None);
}
@@ -185,11 +214,24 @@ mod tests {
fn the_manual_gradient_reaches_the_trainer() {
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
// The gradient channel specifically; the power channel expresses the
// same load in watts and is covered in the engine's own tests.
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
inputs.manual_gradient_pct = 5.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(tick.command, Some(ControlTarget::Gradient { percent: 5.0 }));
// The road is the rider's setting exactly; what the trainer is asked
// for is the load that road implies through the selected gear, which is
// a different number (see `bikecontrol_core::gearing`).
assert_eq!(tick.snapshot.gradient_pct, 5.0);
let commanded = match tick.command {
Some(ControlTarget::Gradient { percent }) => percent,
other => panic!("expected a gradient command, got {other:?}"),
};
assert!(
commanded > 0.0,
"a 5% road must put load on the pedals: {commanded}"
);
}
#[test]
@@ -230,19 +272,25 @@ mod tests {
// SAF-3 is enforced by the engine; assert the backend does not bypass it.
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
inputs.manual_gradient_pct = 400.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(
tick.command,
Some(ControlTarget::Gradient { percent: inputs.limits.max_gradient_pct })
Some(ControlTarget::Gradient {
percent: inputs.limits.max_gradient_pct
})
);
}
#[test]
fn a_paused_ride_commands_nothing() {
// SAF-1: the last target stands; a pause must not push a new load.
let (_tx, rx) = watch::channel(Telemetry { power_w: Some(200), ..Telemetry::default() });
let (_tx, rx) = watch::channel(Telemetry {
power_w: Some(200),
..Telemetry::default()
});
let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
backend.tick(0.25, &inputs);
@@ -258,7 +306,11 @@ mod tests {
let (tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(Telemetry { power_w: Some(250), ..Telemetry::default() });
let _ = tx.send(Telemetry {
power_w: Some(250),
cadence_rpm: Some(85.0),
..Telemetry::default()
});
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
@@ -268,7 +320,10 @@ mod tests {
for status in [RideStatus::Paused, RideStatus::Finished, RideStatus::Idle] {
inputs.status = status;
let stopped = backend.tick(0.25, &inputs).snapshot;
assert_eq!(stopped.virtual_speed_kph, 0.0, "{status:?} still showed speed");
assert_eq!(
stopped.virtual_speed_kph, 0.0,
"{status:?} still showed speed"
);
// Distance must not be thrown away — the ride resumes where it was.
assert!(stopped.virtual_distance_m >= moving.virtual_distance_m);
}
@@ -284,7 +339,11 @@ mod tests {
let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(Telemetry { power_w: Some(300), ..Telemetry::default() });
let _ = tx.send(Telemetry {
power_w: Some(300),
cadence_rpm: Some(90.0),
..Telemetry::default()
});
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
@@ -296,3 +355,102 @@ mod tests {
assert_eq!(snapshot.elapsed_ms, 250);
}
}
#[cfg(test)]
mod drag_race_tests {
use super::*;
use bikecontrol_core::types::ControlMode;
/// Telemetry shaped exactly like the D100's: power and wheel speed, and
/// **no cadence** — the firmware does not send it (qdomyos-zwift#3282).
fn d100(power_w: i16, speed_kph: f32) -> Telemetry {
Telemetry {
power_w: Some(power_w),
cadence_rpm: None,
speed_kph: Some(speed_kph),
..Telemetry::default()
}
}
#[test]
fn a_drag_race_on_d100_telemetry_actually_moves() {
// End to end on the real shipped profile: if this passes and the app
// still shows zero, the fault is above the engine — in what is being
// fed to it, or in the ride never having been started.
let yaml = crate::samples::all()
.into_iter()
.find(|s| s.name == "Drag race")
.expect("drag race must be shipped")
.text;
let profile = bikecontrol_core::profile::Profile::from_yaml(&yaml).unwrap();
let (tx, rx) = watch::channel(Telemetry::default());
let mut inputs = RideInputs {
status: RideStatus::Running,
mode: ControlMode::Profile,
profile: Some(std::sync::Arc::new(profile)),
..RideInputs::default()
};
inputs.set_gear(3);
let mut backend = SessionBackend::new(&inputs, rx);
// Rolling: 180 W at 22 km/h on the flywheel.
let _ = tx.send(d100(180, 22.0));
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(
snap.speed_source,
bikecontrol_core::types::SpeedSource::Drivetrain,
"cadence must be inferred from wheel speed: {snap:?}"
);
assert!(snap.virtual_speed_kph > 5.0, "the bike must move: {snap:?}");
assert!(snap.virtual_distance_m > 0.0, "the race must progress: {snap:?}");
}
#[test]
fn a_drag_race_that_was_never_started_reports_zero() {
// The other explanation for a stationary drag race, and it is not a
// bug: loading a profile does not start the ride. Pinned so the two
// causes stay distinguishable.
let (tx, rx) = watch::channel(Telemetry::default());
let inputs = RideInputs {
status: RideStatus::Idle,
mode: ControlMode::Profile,
..RideInputs::default()
};
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(d100(180, 22.0));
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(snap.virtual_speed_kph, 0.0);
assert_eq!(snap.elapsed_ms, 0, "an unstarted ride has no clock");
}
#[test]
fn shifting_up_mid_race_speeds_the_rider_up_for_the_same_flywheel() {
// The gear doing its job on inferred cadence: same trainer speed, more
// ground covered.
let ride = |gear: usize| {
let (tx, rx) = watch::channel(Telemetry::default());
let mut inputs = RideInputs {
status: RideStatus::Running,
mode: ControlMode::ManualGrade,
..RideInputs::default()
};
inputs.set_gear(gear);
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(d100(180, 22.0));
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
backend.tick(0.25, &inputs).snapshot.virtual_speed_kph
};
let low = ride(2);
let high = ride(11);
assert!(high > low * 2.0, "a longer gear must cover more ground: {high} vs {low}");
}
}
+163 -68
View File
@@ -12,13 +12,15 @@ use bikecontrol_core::types::{ConnectionState, ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};
use crate::backend::{RideBackend, RideInputs};
use crate::controller::{ControllerStatus, PodState};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::devices::DeviceRegistry;
use crate::events;
use crate::events::{
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::profile_view::{ProfileGeometry, ProfileView};
use crate::recording::{RecorderHandle, Recovered, RideSummary};
use crate::session_backend::SessionBackend;
use crate::trainer::{TrainerHandle, TrainerStatus};
@@ -44,43 +46,28 @@ pub struct Inner {
pub last_derived: Option<Derived>,
pub lap_index: u32,
pub laps: Vec<LapSummary>,
/// The most recently finished ride, kept so the summary screen survives a
/// webview reload (FR-9.13). Cleared when the next ride starts.
pub last_summary: Option<RideSummary>,
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
/// the webview asks for them.
pub recovered: Vec<Recovered>,
lap_start_ms: u64,
lap_start_m: f64,
lap_power_sum: f64,
lap_power_n: u64,
}
/// Choose the ride's data source.
/// Build the ride's data source.
///
/// **There is no fallback.** A missing, sleeping or uncontrollable trainer
/// yields zeros, not invented numbers: the ride engine reads
/// `Telemetry::default()` and the screen shows a rider who is not pedalling,
/// which is the truth. Substituting a synthetic rider when the hardware is
/// absent would mean a rider could complete a session and only discover
/// afterwards that none of it happened.
///
/// The synthetic rider is therefore opt-in, deliberately, and only from
/// outside the app: `BIKECONTROL_DEMO=1` (which also loads a route and starts
/// riding) or `BIKECONTROL_MOCK=1`. It exists only under the `mock-ride`
/// feature, so a build made with `--no-default-features` is incapable of
/// showing fake data at all. Whenever it is on, `RideState::source` reports
/// `"mock"` and the ride screen carries a banner that cannot be missed.
/// **There is one, and it is the trainer.** A missing, sleeping or
/// uncontrollable trainer yields zeros, not invented numbers: the ride engine
/// reads `Telemetry::default()` and the screen shows a rider who is not
/// pedalling, which is the truth. There is deliberately no synthetic rider to
/// fall back to — a session a rider could finish and only then discover none of
/// it happened is worse than no session at all.
fn build_backend(inputs: &RideInputs, trainer: &TrainerHandle) -> Box<dyn RideBackend> {
#[cfg(feature = "mock-ride")]
if std::env::var_os("BIKECONTROL_DEMO").is_some()
|| std::env::var_os("BIKECONTROL_MOCK").is_some()
{
tracing::warn!(
source = "mock",
"BIKECONTROL_DEMO/MOCK is set — this ride is a SIMULATION. Power, speed and \
distance are fabricated and nothing is being read from a trainer."
);
return Box::new(crate::mock::MockBackend::default());
}
tracing::info!(
source = "ftms",
"ride data source is the trainer; with no trainer attached the ride reads zero"
);
tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero");
Box::new(SessionBackend::new(inputs, trainer.telemetry()))
}
@@ -89,7 +76,7 @@ impl Inner {
let inputs = RideInputs::default();
Self {
backend: build_backend(&inputs, &trainer),
devices: DeviceRegistry::new(trainer.clone()),
devices: DeviceRegistry::new(trainer.clone(), controller.clone()),
inputs,
trainer,
controller,
@@ -100,6 +87,8 @@ impl Inner {
last_derived: None,
lap_index: 1,
laps: Vec::new(),
last_summary: None,
recovered: Vec::new(),
lap_start_ms: 0,
lap_start_m: 0.0,
lap_power_sum: 0.0,
@@ -116,10 +105,11 @@ impl Inner {
manual_gradient_pct: self.inputs.manual_gradient_pct,
resistance_level: self.inputs.resistance_level,
power_target_w: self.inputs.power_target_w,
gear: self.inputs.gear,
gear_count: self.inputs.gear_count(),
lap: self.lap_index,
laps: self.laps.clone(),
profile: self.profile_view.clone(),
source: self.backend.source(),
trainer: self.trainer.status(),
}
}
@@ -171,6 +161,7 @@ impl Inner {
self.inputs.status = RideStatus::Idle;
self.inputs.gradient_offset_pct = 0.0;
self.last_snapshot = None;
self.last_summary = None;
self.lap_index = 1;
self.laps.clear();
self.lap_start_ms = 0;
@@ -202,7 +193,12 @@ impl Inner {
}
#[derive(Clone)]
pub struct AppState(Arc<Mutex<Inner>>);
pub struct AppState {
inner: Arc<Mutex<Inner>>,
/// The live recording, behind its own lock so that journal `fsync`s never
/// block a command waiting on `inner` — see [`crate::recording`].
recorder: RecorderHandle,
}
impl Default for AppState {
fn default() -> Self {
@@ -215,7 +211,15 @@ impl AppState {
let limits = RideInputs::default().limits;
let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
let controller = crate::controller::ControllerHandle::spawn();
Self(Arc::new(Mutex::new(Inner::new(trainer, controller))))
Self {
inner: Arc::new(Mutex::new(Inner::new(trainer, controller))),
recorder: RecorderHandle::default(),
}
}
/// The ride recorder. Never call this while holding [`AppState::lock`].
pub fn recorder(&self) -> RecorderHandle {
self.recorder.clone()
}
/// The trainer supervisor handle, for callers outside the lock (SAF-2 at
@@ -232,7 +236,7 @@ impl AppState {
/// Panics are impossible to recover from here, and a poisoned lock means
/// the ride loop already died — surface it rather than hide it.
pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
}
@@ -259,7 +263,13 @@ pub fn notify(app: &AppHandle, notice: Notice) {
/// Confirm an input registered so the UI can flash the control (FR-9.9).
pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
let _ = app.emit(
events::INPUT_ACK,
InputAck {
action: action.into(),
detail,
},
);
}
/// Forward controller button edges and link status to the webview.
@@ -272,6 +282,7 @@ pub fn spawn_controller_loop(app: AppHandle) {
let controller = app.state::<AppState>().controller();
let mut inputs = controller.inputs();
let mut status = controller.status_watch();
let mut previous = controller.status();
loop {
tokio::select! {
@@ -280,6 +291,13 @@ pub fn spawn_controller_loop(app: AppHandle) {
// The paddles shift the virtual gear (FR-4.1, OQ-1).
// Done here rather than in the webview so gearing keeps
// working with the window unfocused or minimised.
//
// The webview must therefore NOT also act on these two
// buttons. It used to, and the result was that one
// paddle press both shifted a gear and tilted the road
// — the gear silently, the gradient visibly, so the
// paddles looked like a gradient trim and virtual
// shifting looked broken.
if input.pressed {
let delta = match input.button {
"plus" => 1i32,
@@ -288,10 +306,7 @@ pub fn spawn_controller_loop(app: AppHandle) {
};
if delta != 0 {
let state = app.state::<AppState>();
let mut inner = state.lock();
let next = (inner.inputs.gear as i32 + delta).max(1);
inner.inputs.gear = next as usize;
drop(inner);
state.lock().inputs.shift_gear(delta);
emit_ride_state(&app);
}
}
@@ -309,6 +324,13 @@ pub fn spawn_controller_loop(app: AppHandle) {
return;
}
let payload = status.borrow_and_update().clone();
// Each pod speaks for itself (FR-1.4, FR-9.4): one pod
// arriving must not read as "the controller is connected"
// when the other is still missing.
for notice in pod_notices(&previous, &payload) {
notify(&app, notice);
}
previous = payload.clone();
let _ = app.emit(events::CONTROLLER_STATUS, payload);
}
}
@@ -316,6 +338,39 @@ pub fn spawn_controller_loop(app: AppHandle) {
});
}
/// Say what changed about each pod, in words (FR-1.8, FR-9.4).
///
/// Only *state* changes speak. A battery reading or a button count arriving
/// would otherwise raise a toast every few seconds, and the one message that
/// matters — a pod that has gone — would be buried in them.
fn pod_notices(before: &ControllerStatus, after: &ControllerStatus) -> Vec<Notice> {
let mut out = Vec::new();
for (was, now) in [(&before.minus, &after.minus), (&before.plus, &after.plus)] {
if was.state == now.state {
continue;
}
let pod = now.symbol;
out.push(match now.state {
PodState::Connected => Notice::info(format!("{pod} pod connected")),
PodState::Reconnecting => Notice::warn(format!(
"Lost the {pod} pod — reconnecting. Press a button on it to wake it."
)),
PodState::GaveUp => Notice::error(
now.error
.clone()
.unwrap_or_else(|| format!("Stopped looking for the {pod} pod")),
),
// Searching is visible on the card, and Idle after a deliberate
// disconnect is already acknowledged by the command that did it.
PodState::Searching | PodState::Idle => match &now.error {
Some(error) => Notice::warn(error.clone()),
None => continue,
},
});
}
out
}
/// The ride loop. One tick: advance the backend, publish the snapshot, and
/// transmit the (already clamped) target to the trainer.
pub fn spawn_ride_loop(app: AppHandle) {
@@ -326,15 +381,48 @@ pub fn spawn_ride_loop(app: AppHandle) {
let mut n: u64 = 0;
loop {
interval.tick().await;
let (frame, command) = {
let (frame, command, altitude_m, status) = {
let state = app.state::<AppState>();
let mut inner = state.lock();
let inputs = inner.inputs.clone();
let tick = inner.backend.tick(dt_s, &inputs);
inner.last_snapshot = Some(tick.snapshot);
let derived = inner.absorb(&tick.snapshot);
(RideFrame { snapshot: tick.snapshot, derived }, tick.command)
// A loaded route knows the rider's true altitude; without one
// the recorder integrates elevation gain instead.
let altitude_m = inner.geometry.as_ref().and_then(|g| {
let x = crate::profile_view::position_x(
g,
tick.snapshot.elapsed_ms as f64 / 1000.0,
tick.snapshot.virtual_distance_m,
);
g.elevation_at(x)
});
let status = inner.inputs.status;
(
RideFrame {
snapshot: tick.snapshot,
derived,
},
tick.command,
altitude_m,
status,
)
};
// NFR-11 — the screensaver follows the ride, not the window. Done
// here rather than in the commands because a ride can also end on
// its own (a profile running out), and this is the one place that
// sees every status however it changed. Outside the lock: on a
// transition it makes a D-Bus round trip.
crate::wakelock::sync(status);
// Journalling happens outside the ride-state lock, deliberately: the
// recorder fsyncs periodically and holding `inner` across the disk
// would stall every command behind it.
if status == RideStatus::Running {
app.state::<AppState>()
.recorder()
.record(&frame.snapshot, altitude_m);
}
n += 1;
if n % TICK_HZ == 0 {
let s = &frame.snapshot;
@@ -350,6 +438,14 @@ pub fn spawn_ride_loop(app: AppHandle) {
// problem from a telemetry problem.
trainer_kph = ?s.telemetry.speed_kph,
cadence = ?s.telemetry.cadence_rpm,
// Which rule set the speed, the gear it used, and what that
// gear is worth. `NoCadence` here means the trainer's Zwift
// channel is not delivering and the ride is pinned at a
// stop — a fault, and the first thing to check when the
// speed looks wrong.
speed_source = ?s.speed_source,
gear = s.gear,
development_m = s.development_m,
?command,
"ride tick"
);
@@ -374,30 +470,6 @@ fn transmit(app: &AppHandle, target: ControlTarget) {
trainer.set_target(target);
}
/// Load the bundled GPX and start riding it. Development only — see the
/// `BIKECONTROL_DEMO` check in `lib.rs`.
pub fn start_demo(app: &AppHandle) {
let Some(sample) = crate::samples::all().into_iter().find(|s| s.is_gpx) else {
return;
};
let profile = match bikecontrol_core::gpx::import(
&sample.text,
&sample.name,
&bikecontrol_core::gpx::SmoothingConfig::default(),
) {
Ok(p) => p,
Err(e) => {
tracing::warn!(%e, "demo profile failed to import");
return;
}
};
let (view, geom) = crate::profile_view::build(&profile, "demo");
let state = app.state::<AppState>();
let mut inner = state.lock();
inner.set_profile(profile, view, geom);
inner.inputs.status = RideStatus::Running;
}
/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept so
/// the next ride does not have to reconnect.
pub fn release_trainer(app: &AppHandle) {
@@ -424,7 +496,9 @@ pub fn release_trainer(app: &AppHandle) {
/// the radio (FR-1.10, NFR-9). The lock is released before waiting so the ride
/// loop can finish.
pub fn shutdown_devices(app: &AppHandle) {
let Some(state) = app.try_state::<AppState>() else { return };
let Some(state) = app.try_state::<AppState>() else {
return;
};
let (trainer, controller) = (state.trainer(), state.controller());
trainer.shutdown_blocking();
controller.shutdown_blocking();
@@ -456,6 +530,14 @@ pub fn spawn_device_loop(app: AppHandle) {
);
}
if let Some(status) = result.trainer_changed {
// FR-8.5: a dropout the app *knows* about is journalled with its
// reason and its exact moment, rather than left to the
// recorder's five-second silence detector to infer.
if let Some(reason) = dropout_reason(&status.state) {
let state = app.state::<AppState>();
let at_ms = state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms);
state.recorder().mark_gap(at_ms, reason);
}
if let Some(notice) = trainer_notice(&status) {
notify(&app, notice);
}
@@ -468,6 +550,16 @@ pub fn spawn_device_loop(app: AppHandle) {
});
}
/// Whether a trainer state change means telemetry has stopped arriving, and if
/// so what to write in the journal. `None` for states that still deliver data.
fn dropout_reason(state: &ConnectionState) -> Option<String> {
match state {
ConnectionState::Reconnecting => Some("trainer link lost — reconnecting".into()),
ConnectionState::Lost { reason } => Some(format!("trainer unavailable: {reason}")),
_ => None,
}
}
/// Say what is wrong, in words, whenever the trainer link changes (FR-1.8,
/// FR-9.4). Silence is the one thing that is not allowed: a rider staring at
/// zeros must be told whether the trainer is missing, asleep or refusing
@@ -490,7 +582,10 @@ fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
"Lost {name} — reconnecting. The ride continues; pedal to wake the trainer."
))),
ConnectionState::Lost { reason } => Some(Notice::error(
status.error.clone().unwrap_or_else(|| format!("{name} unavailable: {reason}")),
status
.error
.clone()
.unwrap_or_else(|| format!("{name} unavailable: {reason}")),
)),
ConnectionState::Idle | ConnectionState::Scanning => None,
}
+39 -15
View File
@@ -54,7 +54,7 @@ const SAFETY_SEQUENCE_TIMEOUT: Duration = Duration::from_secs(7);
/// link sits in `Reconnecting` indefinitely, the screen keeps implying the
/// trainer is on its way back, and the rider is never told to go and look at
/// it. Twenty attempts against the default backoff is about seven minutes.
const RECONNECT_ATTEMPTS: u32 = 20;
pub(crate) const RECONNECT_ATTEMPTS: u32 = 20;
/// What the UI needs to know about the trainer link (FR-1.7, FR-9.3).
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -93,7 +93,10 @@ impl TrainerStatus {
}
pub fn is_attached(&self) -> bool {
!matches!(self.state, ConnectionState::Idle | ConnectionState::Lost { .. })
!matches!(
self.state,
ConnectionState::Idle | ConnectionState::Lost { .. }
)
}
}
@@ -103,9 +106,13 @@ enum Cmd {
Target(ControlTarget),
/// SAF-2 without dropping the link: used at the end of a ride, so the next
/// ride does not have to reconnect.
Release { limits: SafetyLimits },
Release {
limits: SafetyLimits,
},
/// Full SAF-2 sequence plus disconnect. Used on app exit.
Shutdown { reply: SyncSender<()> },
Shutdown {
reply: SyncSender<()>,
},
/// A spawned control write finished. Only reported when it failed.
WriteFailed(String),
}
@@ -701,7 +708,9 @@ mod tests {
#[test]
fn a_lost_link_is_not_attached() {
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
state: ConnectionState::Lost {
reason: "gone".into(),
},
..TrainerStatus::default()
};
assert!(!lost.is_attached());
@@ -714,14 +723,23 @@ mod tests {
// The D100 rejects 0x03 and accepts 0x11 — measured, see README.
let cfg = app_config(SafetyLimits::default());
assert!(cfg.use_simulation_mode);
assert!(!cfg.ignore_advertised_features, "FR-2.6 is not for the app to bypass");
assert!(
!cfg.ignore_advertised_features,
"FR-2.6 is not for the app to bypass"
);
assert!(cfg.start_on_connect);
assert!(cfg.min_write_interval >= Duration::from_millis(250), "FR-2.8");
assert!(
cfg.min_write_interval >= Duration::from_millis(250),
"FR-2.8"
);
}
#[test]
fn safety_limits_reach_the_ble_layer() {
let limits = SafetyLimits { max_gradient_pct: 8.0, ..SafetyLimits::default() };
let limits = SafetyLimits {
max_gradient_pct: 8.0,
..SafetyLimits::default()
};
assert_eq!(app_config(limits).limits.max_gradient_pct, 8.0);
}
@@ -776,9 +794,11 @@ mod tests {
tx.send(Cmd::Target(ControlTarget::Gradient { percent: 3.0 }))
.await
.unwrap();
tx.send(Cmd::Release { limits: SafetyLimits::default() })
.await
.unwrap();
tx.send(Cmd::Release {
limits: SafetyLimits::default(),
})
.await
.unwrap();
tx.send(Cmd::Shutdown { reply }).await.unwrap();
match abort_signal(&mut rx, &TrainerSelector::Any).await {
@@ -814,9 +834,11 @@ mod tests {
// The same trainer clicked again is impatience, not a new intent — it
// must not restart the attempt already running.
tx.send(Cmd::Connect(connecting_to.clone())).await.unwrap();
tx.send(Cmd::Connect(TrainerSelector::Address("aa:bb:cc:dd:ee:ff".into())))
.await
.unwrap();
tx.send(Cmd::Connect(TrainerSelector::Address(
"aa:bb:cc:dd:ee:ff".into(),
)))
.await
.unwrap();
match abort_signal(&mut rx, &connecting_to).await {
Abort::Connect(TrainerSelector::Address(a)) => assert_eq!(a, "aa:bb:cc:dd:ee:ff"),
@@ -857,7 +879,9 @@ mod tests {
fn a_trainer_held_by_another_app_says_so() {
// A-3: one BLE host. BlueZ reports the second one's torn-down link as a
// bare "Not connected", which explains nothing on its own.
let hint = connect_hint(&FtmsError::MissingCharacteristic("Indoor Bike Data (0x2AD2)"));
let hint = connect_hint(&FtmsError::MissingCharacteristic(
"Indoor Bike Data (0x2AD2)",
));
assert!(hint.contains("one connection at a time"), "{hint}");
assert!(hint.to_lowercase().contains("busy"), "{hint}");
}
+122
View File
@@ -0,0 +1,122 @@
//! Keep the display awake while a ride is live (NFR-11).
//!
//! A ride is an hour with both hands on the bars and no keyboard or mouse
//! activity at all, so every desktop's idle timer eventually blanks the screen
//! and locks the session — mid-interval, with the numbers the rider is pacing
//! against behind a lock screen.
//!
//! The inhibitor is therefore held for exactly as long as a session is live
//! (`Running` **or** `Paused` — pausing for a drink is still a ride) and
//! dropped the moment it is not. An app that suppressed the lock screen for
//! the whole time it happened to be open would be a worse citizen than the
//! screensaver it is fighting.
//!
//! Failure here is never fatal: no D-Bus, no screensaver service, a sandbox
//! that refuses the inhibit — all of it costs the rider a blanked screen, not
//! a ride.
use std::sync::Mutex;
use crate::events::RideStatus;
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
mod imp {
/// Mobile (G-4) has its own keep-screen-on API and no `keepawake`.
pub struct Guard;
pub fn acquire() -> Result<Guard, String> {
Err("no keep-awake implementation for this platform".into())
}
pub fn release(_guard: Guard) {}
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
mod imp {
pub type Guard = keepawake::KeepAwake;
pub fn acquire() -> Result<Guard, keepawake::Error> {
// Two separate inhibits on Linux: `display` is the freedesktop
// ScreenSaver one (blank and lock), `idle` the systemd login1 one
// (suspend-on-idle). The second needs a system-bus call that some
// configurations refuse, and a refusal fails the whole builder — so
// fall back to display-only rather than lose both.
//
// We never ask for `sleep`. A closed lid or a pressed suspend key is
// an instruction, not an accident, and blocking it would strand the
// machine awake in a bag.
let build = |idle: bool| {
keepawake::Builder::default()
.display(true)
.idle(idle)
.reason("Ride in progress")
.app_name("BikeControl")
.app_reverse_domain("paris.tourolle.bikecontrol")
.create()
};
build(true).or_else(|e| {
tracing::debug!(error = %e, "idle inhibitor refused; falling back to display only");
build(false)
})
}
pub fn release(guard: Guard) {
// `keepawake`'s `Drop` unwraps its D-Bus un-inhibit call, which fails
// if the session bus went away under us. This runs on the ride loop's
// task, and that task must not die of a screensaver.
let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
if dropped.is_err() {
tracing::warn!("releasing the display inhibitor failed; it lapses when the app exits");
}
}
}
struct State {
held: Option<imp::Guard>,
/// Set after a failed acquire so a machine without a working screensaver
/// service does not mean a fresh attempt — and a fresh warning — four
/// times a second for a whole ride. Cleared when the ride ends, so the
/// next one tries again.
unavailable: bool,
}
static STATE: Mutex<State> = Mutex::new(State {
held: None,
unavailable: false,
});
/// Hold the inhibitor iff a ride is live. Called every ride tick; cheap and
/// idempotent, and only ever talks to the bus on a transition.
pub fn sync(status: RideStatus) {
set(matches!(status, RideStatus::Running | RideStatus::Paused));
}
/// Acquire or release the inhibitor. Safe to call from any thread, repeatedly.
pub fn set(active: bool) {
// A poisoned lock here means a previous caller panicked mid-transition;
// the guard it left behind is still valid, so carry on rather than take
// the whole ride loop down with it.
let mut state = STATE.lock().unwrap_or_else(|p| p.into_inner());
if active {
if state.held.is_some() || state.unavailable {
return;
}
match imp::acquire() {
Ok(guard) => {
state.held = Some(guard);
tracing::info!("display sleep inhibited for the duration of the ride");
}
Err(e) => {
state.unavailable = true;
tracing::warn!(
error = %e,
"could not inhibit the screensaver — the display may blank mid-ride"
);
}
}
} else {
state.unavailable = false;
if let Some(guard) = state.held.take() {
imp::release(guard);
tracing::info!("display sleep inhibitor released");
}
}
}
+73 -20
View File
@@ -6,6 +6,7 @@
import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte';
import RideScreen from './components/RideScreen.svelte';
import SummaryScreen from './components/SummaryScreen.svelte';
import Toasts from './components/Toasts.svelte';
let ready = $state(false);
@@ -38,6 +39,36 @@
app.run(fn);
};
// The summary screen has its own two keys, and swallows the ride controls:
// nudging the gradient of a ride that has ended is meaningless, and space
// would silently start a new one out from under the summary.
if (app.screen === 'summary') {
switch (e.key) {
case 's':
case 'S':
e.preventDefault();
app.saveFit();
return;
case 'n':
case 'N':
e.preventDefault();
app.newRide();
return;
case 'd':
case 'D':
app.screen = 'connect';
return;
case '?':
app.showHelp = !app.showHelp;
return;
case 'Escape':
app.showHelp = false;
return;
default:
return;
}
}
switch (e.key) {
case 'ArrowUp':
return run(() => api.nudgeGradient(step));
@@ -53,6 +84,15 @@
case 'l':
case 'L':
return run(() => api.markLap());
// Shifting is the primary control, so it gets the obvious keys and the
// paddles. `[` / `]` keep the mode's own target for the modes that have
// one — a gear is not a substitute for an ERG wattage.
case '+':
case '=':
return run(() => api.shiftGear(1));
case '-':
case '_':
return run(() => api.shiftGear(-1));
case ']':
return run(() => bumpTarget(1));
case '[':
@@ -68,7 +108,7 @@
return;
case 'r':
case 'R':
app.screen = 'ride';
app.goToRide();
return;
case '?':
app.showHelp = !app.showHelp;
@@ -90,12 +130,20 @@
}
/**
* Zwift Click input. The paddles shift "gears" and the D-pad drives the UI.
* Zwift Click input. The paddles shift the virtual gear and the D-pad drives
* the UI.
*
* Every button routes to an intent the keyboard already has, rather than to a
* second implementation — that is the whole point of doing this here instead
* of in Rust. If a shortcut changes, the controller follows it for free.
*
* The one exception is the paddles, and it is deliberate: shifting is applied
* in Rust (`spawn_controller_loop`) so it keeps working with the window
* unfocused or minimised. They are therefore absent from the switch below.
* Handling them here as well would shift twice per press — and the version of
* this file that also nudged the gradient from them is exactly why virtual
* shifting appeared not to work.
*
* Only press edges act. The Rust side already filters the pod's ~10 Hz repeat
* while a button is held, so acting on releases too would double every shift.
*/
@@ -103,12 +151,27 @@
if (!input.pressed) return;
const run = (fn: () => Promise<unknown>) => app.run(fn);
// As with the keyboard: on the summary the ride controls are inert, and the
// face buttons carry that screen's own two actions instead. Leaving `a` on
// toggle-pause here would restart the ride the rider just finished.
if (app.screen === 'summary') {
switch (input.button) {
case 'a':
app.saveFit();
return;
case 'b':
app.newRide();
return;
case 'left':
app.screen = 'connect';
return;
default:
return;
}
}
switch (input.button) {
// Paddles: a gear is ±10 W of load (or the closest thing the mode has).
case 'plus':
return run(() => shiftGear(1));
case 'minus':
return run(() => shiftGear(-1));
// 'plus' / 'minus' are handled in Rust — see above.
// D-pad: gradient on the vertical axis, screens on the horizontal.
case 'up':
@@ -119,7 +182,7 @@
app.screen = 'connect';
return;
case 'right':
app.screen = 'ride';
app.goToRide();
return;
// Face buttons mirror the existing single-key shortcuts.
@@ -135,18 +198,6 @@
}
}
/**
* One "gear" of load. Power modes move in 10 W steps; resistance mode has no
* watt unit, so it moves one level, and gradient modes fall back to the
* existing nudge so the paddles are never dead.
*/
async function shiftGear(dir: number): Promise<unknown> {
const ride = app.ride;
if (!ride) return;
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir);
return api.nudgeGradient(dir * 0.5);
}
</script>
<svelte:window on:keydown={onKey} />
@@ -159,6 +210,8 @@
</div>
{:else if !ready}
<div class="boot"><span class="label">Starting…</span></div>
{:else if app.screen === 'summary'}
<SummaryScreen />
{:else if app.screen === 'ride'}
<RideScreen />
{:else}
+459
View File
@@ -0,0 +1,459 @@
<script lang="ts">
/**
* The Zwift Click, as two pods (FR-1.4, FR-9.19.2).
*
* A Click v2 is **two peripherals**, and until now the app showed one line
* for both: connect, and you got whichever pod answered first, with no way to
* tell which one that was or that the other was missing entirely. Each pod
* now has a card of its own — its own state, battery, address and proof that
* its buttons arrive.
*
* They are named for the shift paddle each carries, not for the side of the
* bar. Nothing a pod advertises says which end of the handlebar it is
* clamped to, so left and right would be a guess; the paddle is printed on
* the pod, and pressing it settles the question on screen (`confirmed`).
*
* The second job of this panel is to say what to *do* when a pod is missing.
* A Click sleeps within seconds and only advertises while awake (A-4), which
* no rider can guess from the words "not connected" — and which is also why
* connecting is not a button they have to win a race with: the running scan
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
* here are for overriding that, not for driving it.
*/
import { app } from '../lib/app.svelte';
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
const controller = $derived(app.controller);
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
/** Auto-connect rides on the device scan, so a stopped scan is a reason a
* pod stays missing — and the fix belongs next to the symptom. */
const scanning = $derived(app.devices.scanning);
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
const bothConnected = $derived(pods.length === 2 && pods.every((p) => p.state === 'connected'));
const busy = $derived(pods.some((p) => p.state === 'searching'));
/** A pod reporting the other's paddle: the pair may be filed the wrong way
* round, and the rider is the only one who can say. */
const mixedUp = $derived(pods.some((p) => p.contradicted));
const STATE_TEXT: Record<PodState, string> = {
idle: 'Not connected',
searching: 'Searching…',
connected: 'Connected',
reconnecting: 'Reconnecting…',
gaveUp: 'Gave up',
};
const STATE_TONE: Record<PodState, string> = {
idle: 'tone-idle',
searching: 'tone-warn',
connected: 'tone-ok',
reconnecting: 'tone-warn',
gaveUp: 'tone-bad',
};
/** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = {
minus: 'Shift down · D-pad',
plus: 'Shift up · A B Y Z',
};
function connect(pod: Pod) {
app.run(() => api.connectController(pod));
}
function disconnect(pod: Pod) {
app.run(() => api.disconnectController(pod));
}
</script>
<section class="click">
<header>
<h2>Zwift Click</h2>
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
{#if bothConnected}
Both pods connected
{:else if anyConnected}
One pod of two
{:else}
No pods connected
{/if}
</span>
<div class="actions">
{#if !scanning}
<!-- Nothing can be picked up automatically while the scan is off, so
the way to fix that sits here rather than only in the header. -->
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !bothConnected}
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Connect now'}
</button>
{/if}
{#if anyConnected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect both
</button>
{/if}
</div>
</header>
<p class="lede">
{#if !scanning}
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
on each pod.
{:else if bothConnected}
Both pods are connected and will reconnect on their own if one drops.
{:else}
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
running scan connects it as soon as it does — no need to press anything here.
{/if}
</p>
<div class="pods">
{#each pods as pod (pod.pod)}
<article class="pod" class:live={pod.state === 'connected'}>
<div class="title">
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
<span class="what">
<span class="label">{pod.symbol} pod</span>
<span class="purpose">{PURPOSE[pod.pod]}</span>
</span>
<span class="state {STATE_TONE[pod.state]}">
<span class="dot"></span>{STATE_TEXT[pod.state]}
</span>
</div>
<dl class="facts">
<div>
<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
something, so the count is the honest test of the link. -->
<dd>
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`}
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
</dd>
</div>
<div>
<dt>Address</dt>
<dd class="addr">{pod.address ?? '—'}</dd>
</div>
</dl>
{#if pod.confirmed}
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
{:else if pod.state === 'connected'}
<p class="note">
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
</p>
{/if}
{#if pod.contradicted}
<p class="note tone-warn">
This pod sent the other paddle. If the pair is the wrong way round, swap them.
</p>
{/if}
{#if pod.error}
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
<p class="note tone-bad">{pod.error}</p>
{/if}
<div class="controls">
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
{:else if pod.state === 'searching'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
{:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}>
Look for it now
</button>
{/if}
</div>
</article>
{/each}
{#if pods.length === 0}
<p class="note">Waiting for the controller supervisor…</p>
{/if}
</div>
{#if mixedUp || controller?.swapped}
<div class="swap">
<span>
{#if mixedUp}
A pod is sending the other pod's paddle — the pair may be filed the wrong way round.
{:else}
The pods are swapped from what their advertisement claims.
{/if}
</span>
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
Swap + /
</button>
</div>
{/if}
{#if !bothConnected}
<!--
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
real causes — a sleeping pod and a lapsed unlock — are things only the
rider can fix, so they are spelled out here rather than left to be
guessed at.
-->
<details class="help" open={!anyConnected}>
<summary>A pod will not connect — what to try</summary>
<ol>
<li>
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
A Click sleeps within seconds and only advertises while awake, so a pod that is not
broadcasting is the normal case, not a fault. The scan below is running: press a
button and the pod connects itself, usually within a second or two.
</li>
<li>
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a
stopped scan means nothing gets picked up. Restart it above.
</li>
<li>
<strong>Close anything else holding the pod.</strong> One app at a time — Zwift left
running in the background keeps the link, and this app will never see the pod.
</li>
<li>
<strong>Bring it closer, or charge it.</strong> A flat pod stops advertising
altogether, and after about thirty failed attempts the app stops chasing it and says
so on the card.
</li>
</ol>
<p class="fallback">
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
<span class="kbd"></span> shift, <span class="kbd"></span>
<span class="kbd"></span> trim the gradient. Press
<span class="kbd">?</span> for the full list. A ride never depends on a pod.
</p>
</details>
{/if}
</section>
<style>
.click {
margin: 0 var(--edge) 0.8rem;
padding: 0.9rem 1rem 1rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
header {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.summary {
font-size: 0.85rem;
font-weight: 600;
}
.lede {
margin: 0.55rem 0 0;
font-size: 0.86rem;
line-height: 1.5;
color: var(--ink-soft);
}
.lede strong {
color: var(--ink);
}
.actions {
display: flex;
gap: 0.4rem;
margin-left: auto;
}
.pods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 0.6rem;
margin-top: 0.8rem;
}
.pod {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.8rem 0.9rem;
border-radius: 0.55rem;
border: 1px solid var(--hairline);
}
.pod.live {
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
}
.title {
display: flex;
align-items: center;
gap: 0.65rem;
}
/* The paddle glyph is the pod's identity — big enough to match against the
one printed on the hardware at arm's length. */
.paddle {
display: grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.45rem;
background: var(--hairline);
color: var(--ink-dim);
font-size: 1.3rem;
font-weight: 300;
line-height: 1;
flex: none;
}
.paddle.on {
background: color-mix(in srgb, var(--ok) 18%, transparent);
color: var(--ok);
}
.what {
display: flex;
flex-direction: column;
min-width: 0;
}
.label {
font-size: 1rem;
font-weight: 600;
}
.purpose {
font-size: 0.78rem;
color: var(--ink-dim);
}
.state {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-left: auto;
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
}
.facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
margin: 0;
}
.facts div {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
dt {
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--ink-dim);
}
dd {
margin: 0;
font-size: 0.9rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last {
color: var(--ink-dim);
}
.addr {
font-size: 0.78rem;
color: var(--ink-dim);
}
.note {
margin: 0;
font-size: 0.84rem;
color: var(--ink-soft);
line-height: 1.45;
}
.note strong {
color: var(--ink);
}
.controls {
display: flex;
gap: 0.4rem;
margin-top: auto;
padding-top: 0.15rem;
}
.swap {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.6rem;
padding: 0.55rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 207, 74, 0.07);
color: var(--ink-soft);
font-size: 0.85rem;
}
.swap button {
margin-left: auto;
}
.help {
margin-top: 0.7rem;
font-size: 0.86rem;
color: var(--ink-soft);
}
summary {
cursor: pointer;
color: var(--ink-soft);
font-weight: 600;
}
.help ol {
margin: 0.5rem 0 0;
padding-left: 1.2rem;
line-height: 1.6;
}
.help li {
margin-bottom: 0.4rem;
}
.help strong {
color: var(--ink);
}
.fallback {
margin: 0.5rem 0 0;
color: var(--ink-dim);
line-height: 1.6;
}
</style>
+35 -49
View File
@@ -12,17 +12,18 @@
import { api } from '../lib/bridge';
import { connectionText, rssiBars } from '../lib/format';
import type { DeviceInfo, DeviceKind } from '../lib/types';
import ClickPanel from './ClickPanel.svelte';
const devices = $derived(app.devices.devices);
const scanning = $derived(app.devices.scanning);
const trainerReady = $derived(
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
);
const trainerReady = $derived(app.trainerReady);
const KIND_LABEL: Record<DeviceKind, string> = {
trainer: 'Smart trainer · FTMS',
clickLeft: 'Zwift Click · left pod',
clickRight: 'Zwift Click · right pod',
// Named for the shift paddle, which is printed on the pod — unlike left and
// right, which nothing in the advertisement actually tells us (§2.3.1).
clickMinus: 'Zwift Click · pod',
clickPlus: 'Zwift Click · + pod',
heartRate: 'Heart rate monitor',
unknown: 'Unidentified',
};
@@ -54,48 +55,33 @@
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
{/if}
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
<!-- Disabled until control is real: there is no ride without a trainer,
and offering one would be offering a session that records nothing. -->
<button
class="btn primary"
disabled={!trainerReady}
title={trainerReady ? '' : 'Connect a trainer and acquire FTMS control first'}
onclick={() => app.goToRide()}
>
Go to ride
</button>
</div>
</header>
<!--
The Zwift Click is deliberately not in the device list below: that list is
FTMS trainers, and a controller is a different kind of thing with a
different failure mode (it sleeps in seconds and must be woken by hand).
The Click gets a panel of its own above the device list rather than two more
rows in it. It is a different kind of thing with a different failure mode —
two pods that sleep in seconds and must be woken by hand — and the one thing
the old single line could not do was say *which* pod was missing (FR-1.4).
-->
<div class="gate">
<span class="dot {app.controller?.connected ? 'tone-ok' : 'tone-warn'}"></span>
<span>
{#if app.controller?.connected}
Zwift Click connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}. Paddles shift; the D-pad drives the UI.
{:else if app.controller?.error}
Controller: {app.controller.error}
{:else}
No controller. <strong>Press a button on the Click first</strong> — it only advertises
while awake.
{/if}
</span>
{#if app.controller?.connected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect
</button>
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.connectController())}>
Connect Click
</button>
{/if}
</div>
<ClickPanel />
{#if !trainerReady}
<div class="gate">
<span class="dot tone-warn"></span>
<span
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
telemetry until an FTMS trainer accepts the control point.</span
>No trainer under control yet. The ride screen stays <strong>locked</strong> until an FTMS
trainer accepts the control point.</span
>
</div>
{/if}
@@ -138,20 +124,20 @@
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
</span>
</span>
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
{:else if device.kind === 'clickMinus' || device.kind === 'clickPlus'}
<!--
Which pod, and what the panel above says about its link. The
unlock countdown that used to sit here was fiction: nothing
reports how much of the ~24 h unlock is left, so it rendered
"expired" against a pod that was working perfectly.
-->
{@const pod = device.kind === 'clickPlus' ? app.controller?.plus : app.controller?.minus}
<span class="state">
<span class="label">Zwift unlock</span>
<span
class="value"
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
>
<span class="dot"></span>
{#if (device.unlockExpiresInS ?? 0) > 0}
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
{:else}
Expired — re-unlock in Zwift
{/if}
<span class="label">Click pod</span>
<span class="value" class:tone-ok={pod?.state === 'connected'} class:tone-idle={pod?.state !== 'connected'}>
<span class="dot"></span>{pod?.symbol ?? '?'} pod{pod?.state === 'connected'
? ' · linked'
: ''}
</span>
</span>
{:else if device.batteryPct != null}
+46 -12
View File
@@ -9,16 +9,17 @@
/** Kept beside the keyboard list so the two cannot drift apart on screen —
* they already share one implementation in `App.svelte`. */
const CONTROLLER: [string, string][] = [
['+ / ', 'Shift a gear: ±10 W, or one resistance level'],
['+ / ', 'Shift up / down a virtual gear'],
['D-pad ↑ / ↓', 'Gradient +0.5% / 0.5%'],
['D-pad ← / →', 'Device screen / ride screen'],
['A', 'Pause / resume'],
['B', 'Insert lap marker'],
['A', 'Pause / resume — save the FIT on the summary'],
['B', 'Insert lap marker — start a new ride on the summary'],
['Y', 'Cycle control mode'],
['Z', 'Profiles and routes'],
];
const BINDINGS: [string, string][] = [
['+ / ', 'Shift up / down a virtual gear'],
['↑ / ↓', 'Gradient +0.5% / 0.5%'],
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
['0', 'Reset gradient trim to zero'],
@@ -29,6 +30,8 @@
['D', 'Device / connection screen'],
['R', 'Ride screen'],
['[ / ]', 'Target down / up (resistance or ERG power)'],
['S', 'Save the FIT file (summary screen)'],
['N', 'Start a new ride (summary screen)'],
['?', 'This list'],
];
</script>
@@ -60,16 +63,31 @@
</div>
{/each}
</dl>
{#if app.controller?.connected}
<!-- Per pod, because a Click v2 is two of them and losing one loses half the
buttons — the D-pad and shift-down live on the pod. -->
<ul class="pods">
{#each [app.controller?.minus, app.controller?.plus] as pod}
{#if pod}
<li class:tone-ok={pod.state === 'connected'} class:tone-idle={pod.state !== 'connected'}>
<span class="dot"></span>
{pod.symbol} pod —
{#if pod.state === 'connected'}
connected{pod.batteryPercent != null ? `, battery ${pod.batteryPercent}%` : ''}
{:else if pod.state === 'searching'}
searching…
{:else if pod.state === 'reconnecting'}
reconnecting…
{:else}
not connected
{/if}
</li>
{/if}
{/each}
</ul>
{#if !app.controller || app.controller.minus.state !== 'connected' || app.controller.plus.state !== 'connected'}
<p class="note">
Controller connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}.
</p>
{:else}
<p class="note">
No controller connected. A Click only advertises after a button press, so wake it and
connect from the device screen.
A Click only advertises just after a button press, so wake the missing pod and connect it
from the device screen. Every action below works from the keyboard meanwhile.
</p>
{/if}
<p class="note">Every one of these has an on-screen equivalent in the control bar.</p>
@@ -127,6 +145,22 @@
font-size: 0.92rem;
}
.pods {
display: flex;
gap: 1.2rem;
margin: 0.9rem 0 0;
padding: 0;
list-style: none;
font-size: 0.86rem;
font-weight: 600;
}
.pods li {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.note {
margin: 1.2rem 0 0;
padding-top: 0.9rem;
+96 -50
View File
@@ -35,9 +35,6 @@
* zeros without being told why, so every non-controlling state gets words.
*/
const trainerChip = $derived.by(() => {
if (ride?.source === 'mock') {
return { tone: 'tone-warn', label: 'Simulated — no trainer' };
}
const t = ride?.trainer;
if (!t) return null;
const state = t.state;
@@ -64,6 +61,71 @@
}
});
/**
* The Click pods, mid-ride (FR-1.4).
*
* Silent when both are connected — the chip row is for things that need
* attention, and two working pods do not. A pod that has dropped or was
* never connected is named individually, because they do different jobs:
* lose the `` pod and the D-pad and shift-down go with it.
*/
const podChip = $derived.by(() => {
const c = app.controller;
if (!c) return null;
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
if (missing.length === 0) return null;
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
return {
tone: searching ? 'tone-warn' : 'tone-idle',
label: searching ? `Looking for the ${names}…` : `No ${names}`,
// The keyboard is always the fallback, which is what keeps a missing pod
// an annoyance rather than the end of the ride.
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
};
});
/**
* The selected gear (FR-4.1).
*
* Read from the snapshot rather than from `ride`, so what is shown is the
* gear the engine actually rode this tick, not the intent the shell recorded.
* Development is the subtitle because "8 of 12" alone says nothing about how
* hard the pedals will be, whereas 6.5 m per crank turn does.
*/
const gear = $derived.by(() => {
const count = snap?.gear_count ?? 0;
if (!snap || count === 0) return { value: '—', sub: null, dim: true };
return {
value: `${snap.gear}`,
// Development says how far the gear carries you; pedal force says what it
// costs to turn it. The second is the one that tells you, without
// pedalling, whether the gear you just selected is rideable.
sub: `of ${count} · ${num(snap.development_m, 1)} m · ${num(snap.pedal_force_n, 0)} N`,
dim: false,
};
});
/**
* A speed the engine could not compute is not a slow ride, it is a broken
* one, and saying "0.0 km/h" without saying why would be the readout lying by
* omission. Cadence arrives on the trainer's Zwift channel, not over FTMS.
*/
const speedFault = $derived(
snap?.speed_source === 'NoCadence' ? 'No cadence from the trainer — speed unavailable' : null,
);
/**
* Cadence, with what the selected gear is asking for. The difference is the
* whole feedback the rider gets on whether they are in the right gear:
* turning well above it is spinning out, well below it is grinding.
*/
const cadenceSub = $derived.by(() => {
const target = snap?.target_cadence_rpm ?? 0;
if (!target || target < 20) return null;
return `gear wants ${num(target, 0)}`;
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
@@ -118,9 +180,11 @@
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
if (speedFault) return speedFault;
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const trainer = snap?.telemetry.speed_kph;
return trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now;
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
});
const statusChip = $derived.by(() => {
@@ -143,15 +207,6 @@
*/
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
/**
* The rider is looking at invented data. This is never inferred from a
* missing trainer — the app shows zeros for that — it is only ever true
* because someone asked for it with `BIKECONTROL_DEMO`/`BIKECONTROL_MOCK`.
* It still gets a banner, because a session you cannot tell from a real one
* is worse than no session at all.
*/
const simulated = $derived(ride?.source === 'mock');
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
const routeSummary = $derived.by(() => {
if (!profile) return null;
@@ -166,16 +221,7 @@
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride" class:simulated>
{#if simulated}
<!-- Not a chip, not a toast: a rider must not be able to finish a session
and only then find out none of it was real. -->
<div class="sim-banner">
<strong>Simulated ride</strong>
<span>Power, speed and distance are fabricated. No trainer is being read.</span>
</div>
{/if}
<div class="ride">
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
@@ -192,6 +238,16 @@
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<!--
Both pods, mid-ride, in one chip. A Click that has quietly dropped is
indistinguishable from a Click nobody has touched, and the first press
that does nothing is a bad moment to find out (FR-1.4, FR-9.2).
-->
{#if podChip}
<span class="chip {podChip.tone}" title={podChip.title}>
<span class="dot"></span>{podChip.label}
</span>
{/if}
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
@@ -251,7 +307,7 @@
/>
<Readout
label="Speed"
value={num(d?.smoothedSpeedKph ?? 0, 1)}
value={num(d?.displaySpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={speedSub}
@@ -264,6 +320,14 @@
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<Readout
label="Gear"
value={gear.value}
size="big"
colour="var(--power)"
sub={gear.sub}
dim={gear.dim}
/>
</section>
<!-- Route detail. -->
@@ -294,7 +358,13 @@
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout label="Cadence" value={num(snap?.telemetry.cadence_rpm ?? 0, 0)} unit="rpm" size="mid" />
<Readout
label="Cadence"
value={num(snap?.telemetry.cadence_rpm ?? 0, 0)}
unit="rpm"
size="mid"
sub={cadenceSub}
/>
<Readout
label="Heart rate"
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
@@ -368,30 +438,6 @@
flex: none;
}
.sim-banner {
display: flex;
align-items: baseline;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.55rem var(--edge);
background: var(--warn);
color: #1a1400;
font-size: 0.92rem;
}
.sim-banner strong {
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.8rem;
}
/* A hairline of the same warning colour all the way round the ride, so the
state is legible from the corner of the eye at any scroll position. */
.ride.simulated {
box-shadow: inset 0 0 0 2px var(--warn);
}
header {
display: flex;
align-items: flex-start;
@@ -518,7 +564,7 @@
.primary {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr 1fr;
grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr;
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
/**
* The ride, after the ride (FR-9.13, FR-9.14).
*
* The activity is already written by the time this appears — `stop_ride`
* saves it before emitting the summary. So the primary action here is "save a
* copy where I want it", not "save or lose it", and nothing on this screen is
* urgent. It says where the file already is, whatever the rider does next.
*/
import { app } from '../lib/app.svelte';
import { clock, km, num } from '../lib/format';
import Readout from './Readout.svelte';
const s = $derived(app.summary);
/** Time not spent riding. Only worth showing when it is not zero. */
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
/**
* Anything the rider should know about the *file* rather than the ride.
* Silence here means the recording was clean, so these only ever appear when
* there is genuinely something to say.
*/
const caveats = $derived.by(() => {
if (!s) return [];
const out: string[] = [];
if (s.recoveredFromCrash) {
out.push('This ride was rebuilt from its journal after an interruption.');
}
if (s.gaps > 0) {
out.push(
`${s.gaps} telemetry dropout${s.gaps === 1 ? '' : 's'} — the trainer stopped reporting and those stretches are gaps in the file.`,
);
}
if (s.skippedLogLines > 0) {
out.push(
`${s.skippedLogLines} journal line${s.skippedLogLines === 1 ? '' : 's'} could not be read and are missing from the activity.`,
);
}
return out;
});
</script>
<div class="summary">
{#if !s}
<section class="empty">
<h1>No finished ride</h1>
<p>End a ride and its summary appears here.</p>
<button class="btn primary" onclick={() => (app.screen = 'ride')}>Back to the ride</button>
</section>
{:else}
<header>
<div class="who">
<h1>Ride complete</h1>
<span class="sub">
{s.records} sample{s.records === 1 ? '' : 's'} · {s.laps} lap{s.laps === 1 ? '' : 's'}
</span>
</div>
<span class="chip tone-ok"><span class="dot"></span>Saved</span>
</header>
<!-- FR-9.13: duration, distance, elevation, avg/max power, avg cadence. -->
<section class="primary">
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
<Readout
label="Distance"
value={km(s.distanceM, 2)}
unit="km"
size="big"
colour="var(--route)"
/>
<Readout
label="Climbing"
value={num(s.ascentM, 0)}
unit="m"
size="big"
colour="var(--climb)"
/>
</section>
<section class="detail">
<Readout
label="Avg power"
value={s.avgPowerW != null ? num(s.avgPowerW, 0) : '—'}
unit={s.avgPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.avgPowerW == null}
/>
<Readout
label="Max power"
value={s.maxPowerW != null ? num(s.maxPowerW, 0) : '—'}
unit={s.maxPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.maxPowerW == null}
/>
<Readout
label="Avg cadence"
value={s.avgCadenceRpm != null ? num(s.avgCadenceRpm, 0) : '—'}
unit={s.avgCadenceRpm != null ? 'rpm' : ''}
dim={s.avgCadenceRpm == null}
/>
<Readout
label="Moving"
value={clock(s.movingS)}
sub={pausedS >= 1 ? `${clock(pausedS)} paused` : null}
/>
<Readout
label="Calories"
value={s.calories != null ? num(s.calories, 0) : '—'}
unit={s.calories != null ? 'kcal' : ''}
dim={s.calories == null}
/>
</section>
{#if caveats.length > 0}
<section class="caveats">
{#each caveats as caveat (caveat)}
<p>{caveat}</p>
{/each}
</section>
{/if}
<!--
The path is stated whether or not the rider saves a copy. A summary that
only said "saved" would leave someone who closes the window with no idea
where their ride went.
-->
<section class="where">
<span class="label">Activity file</span>
<code class="path">{s.savedPath ?? s.fitPath}</code>
{#if s.savedPath}
<span class="also">Automatic copy kept at {s.fitPath}</span>
{/if}
</section>
<footer>
<button class="btn primary" disabled={app.saving} onclick={() => app.saveFit()}>
{app.saving ? 'Saving…' : s.savedPath ? 'Save another copy' : 'Save FIT…'}
<span class="kbd">S</span>
</button>
<button class="btn" onclick={() => app.newRide()}>
New ride
<span class="kbd">N</span>
</button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>
Devices
<span class="kbd">D</span>
</button>
</footer>
{/if}
</div>
<style>
.summary {
display: flex;
flex-direction: column;
gap: var(--gap);
height: 100%;
min-height: 0;
overflow-y: auto;
padding: 1.2rem var(--edge) 1.6rem;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--gap);
}
.who {
min-width: 0;
}
h1 {
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.015em;
}
.sub {
font-size: 0.85rem;
color: var(--ink-dim);
}
.primary {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 2);
align-items: flex-end;
}
.detail {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 1.6);
padding-top: var(--gap);
border-top: 1px solid var(--hairline);
}
/* Warnings about the file, not the ride. Deliberately not a toast: these
outlive the four seconds a toast gets, and they are the reason someone
would go looking at the journal. */
.caveats {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.9rem;
border-radius: 0.5rem;
background: var(--bg-lift);
border-left: 3px solid var(--warn);
}
.caveats p {
margin: 0;
font-size: 0.88rem;
color: var(--ink-soft);
}
.where {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
color: var(--ink-soft);
overflow-wrap: anywhere;
/* Selectable: the whole point of showing it is that it can be copied. */
user-select: text;
-webkit-user-select: text;
}
.also {
font-size: 0.78rem;
color: var(--ink-dim);
overflow-wrap: anywhere;
}
footer {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: auto;
padding-top: var(--gap);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
height: 100%;
text-align: center;
}
.empty p {
margin: 0;
color: var(--ink-dim);
}
</style>
+100 -3
View File
@@ -2,6 +2,7 @@
* Client-side view state. Everything here is either received from Rust or is
* purely presentational (which screen is showing, which toast is up).
*/
import { save } from '@tauri-apps/plugin-dialog';
import { api, subscribe, type ControllerInput, type ControllerStatus } from './bridge';
import { History } from './history';
import type {
@@ -11,10 +12,11 @@ import type {
Notice,
RideFrame,
RideState,
RideSummary,
SampleProfile,
} from './types';
export type Screen = 'connect' | 'ride';
export type Screen = 'connect' | 'ride' | 'summary';
let toastSeq = 0;
@@ -27,9 +29,14 @@ class AppStore {
toasts = $state<(Notice & { id: number })[]>([]);
lastAck = $state<(InputAck & { at: number }) | null>(null);
lastLap = $state<LapSummary | null>(null);
/** The finished ride behind the summary screen (FR-9.13). */
summary = $state<RideSummary | null>(null);
/** True while a save dialog is open, so the button cannot be double-fired. */
saving = $state(false);
showHelp = $state(false);
showProfiles = $state(false);
/** Zwift Click link, so the UI can show battery and say when it dropped. */
/** Both Click pods, tracked separately: each has its own link, its own
* battery and its own way of going missing (FR-1.4). */
controller = $state<ControllerStatus | null>(null);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0);
@@ -41,26 +48,80 @@ class AppStore {
private lastElapsed = -1;
/** An FTMS trainer that is connected *and* has accepted the control point. */
get trainerReady(): boolean {
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
}
/**
* Enter the ride screen.
*
* There is no trainer-less ride: without a trainer under control the screen
* would show zeros and record a session that never happened. The gate lives
* here rather than on the button that opens the screen, so the keyboard and
* the Click cannot walk around it.
*
* A ride already under way is always let back in a trainer that drops
* mid-effort must not lock the rider out of their own ride.
*/
goToRide(): boolean {
const riding = this.ride?.status === 'running' || this.ride?.status === 'paused';
if (!this.trainerReady && !riding) {
this.toast({
level: 'warn',
message: 'Connect a trainer and acquire FTMS control before riding.',
});
return false;
}
this.screen = 'ride';
return true;
}
/**
* Controller input is routed by the caller, not here: `App.svelte` owns the
* keyboard map, and the Click must land on the *same* intents rather than a
* parallel set that can drift.
*/
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
const [ride, devices, samples] = await Promise.all([
const [ride, devices, samples, summary, recovered, controller] = await Promise.all([
api.rideState(),
api.deviceList(),
api.sampleProfiles(),
api.rideSummary(),
api.recoveredRides(),
// Asked for rather than waited for: the status event only fires on a
// *change*, so a webview reload with both pods already connected would
// otherwise show two empty slots.
api.controllerStatus(),
]);
this.ride = ride;
this.devices = devices;
this.samples = samples;
this.summary = summary;
this.controller = controller;
// A ride already in progress (a reload, or an autostart) belongs on screen
// immediately — nobody wants to click past a device list mid-effort.
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
// A reload during the summary lands back on it rather than on the device
// list, where an unsaved ride would look like no ride at all.
else if (ride.status === 'finished' && summary) this.screen = 'summary';
// FR-8.4. Reported here rather than from Rust because the setup hook runs
// before this webview is listening, and a recovered ride announced to
// nobody is the same as one silently discarded.
for (const r of recovered) {
this.toast({
level: 'warn',
message: `Recovered an interrupted ride — saved to ${r.fitPath}`,
});
}
await subscribe({
onFrame: (f) => this.onFrame(f),
onSummary: (s) => {
this.summary = s;
this.screen = 'summary';
},
onRideState: (s) => {
this.ride = s;
},
@@ -116,6 +177,42 @@ class AppStore {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
/**
* Ask where to put the finished activity, and put it there (FR-9.14).
*
* The ride is already on disk before this runs, so cancelling the dialog
* costs nothing which is why this can be a plain "Save a copy" rather than
* a save the rider must not get wrong.
*/
async saveFit(): Promise<void> {
const summary = this.summary;
if (!summary || this.saving) return;
this.saving = true;
try {
const suggested = summary.fitPath.split(/[/\\]/).pop() ?? 'ride.fit';
const dest = await save({
defaultPath: suggested,
filters: [{ name: 'FIT activity', extensions: ['fit'] }],
});
if (dest === null) return; // cancelled — the automatic copy still stands
const written = await api.saveFit(dest);
// Rust confirms the path it actually wrote; trust that over `dest`.
this.summary = { ...summary, savedPath: written };
} catch (e) {
this.toast({ level: 'error', message: String(e) });
} finally {
this.saving = false;
}
}
/** Leave the summary and set up for another ride. */
async newRide(): Promise<void> {
await this.run(() => api.reset());
this.summary = null;
this.clearHistory();
this.screen = 'ride';
}
/** Run a command and surface any rejection as a toast rather than silently. */
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
try {
+57 -4
View File
@@ -14,8 +14,10 @@ import type {
LapSummary,
Notice,
ProfileView,
Recovered,
RideFrame,
RideState,
RideSummary,
RiderConfig,
SafetyLimits,
SampleProfile,
@@ -25,6 +27,7 @@ export const EVENTS = {
snapshot: 'ride://snapshot',
rideState: 'ride://state',
lap: 'ride://lap',
summary: 'ride://summary',
devices: 'devices://updated',
connection: 'devices://connection',
notice: 'app://notice',
@@ -46,19 +49,53 @@ export type ControllerButton =
| 'minus'
| 'plus';
/**
* Which Click pod, named for the shift paddle it carries.
*
* Not left and right: nothing the pod advertises says which end of the bar it
* is clamped to, so those labels would be a guess that a rider who mounts them
* the other way round makes wrong. The paddle is printed on the pod.
*/
export type Pod = 'minus' | 'plus';
/** A press or release edge from a Zwift Click. Repeats while held are already
* filtered out in Rust, so every event here is a real edge. */
export type ControllerInput = {
button: ControllerButton;
pressed: boolean;
/** Which pod sent it. Not used for routing a `+` press means the same
* thing whichever pod it came from but it proves a pod is alive. */
pod: Pod;
};
export type ControllerStatus = {
connected: boolean;
/** Where one pod's link has got to. `idle` and `gaveUp` are both "not
* connected", but they call for different things from the rider. */
export type PodState = 'idle' | 'searching' | 'connected' | 'reconnecting' | 'gaveUp';
export type PodStatus = {
pod: Pod;
/** `+` or ``, ready to render. */
symbol: string;
state: PodState;
address: string | null;
name: string | null;
batteryPercent: number | null;
error: string | null;
lastButton: ControllerButton | null;
buttonsSeen: number;
/** This pod has sent its own paddle, so the label is proven, not assumed. */
confirmed: boolean;
/** It sent the *other* pod's paddle: the pair may be filed the wrong way
* round, or this pod reports for both. */
contradicted: boolean;
};
/** A Click v2 is two peripherals, and each has its own link (FR-1.4). */
export type ControllerStatus = {
minus: PodStatus;
plus: PodStatus;
/** The +/ assignment has been flipped by the rider. */
swapped: boolean;
};
/** True when running inside the Tauri shell rather than a bare browser. */
@@ -78,9 +115,18 @@ export const api = {
stop: () => call<RideState>('stop_ride'),
reset: () => call<RideState>('reset_ride'),
// recording and export
rideSummary: () => call<RideSummary | null>('ride_summary'),
/** Copy the finished activity to `path`; resolves with the path written. */
saveFit: (path: string) => call<string>('save_fit', { path }),
/** Rides rebuilt from an interrupted session. Drains — call once at start. */
recoveredRides: () => call<Recovered[]>('recovered_rides'),
// control modes and targets
setMode: (mode: ControlMode) => call<RideState>('set_control_mode', { mode }),
cycleMode: () => call<RideState>('cycle_control_mode'),
shiftGear: (delta: number) => call<RideState>('shift_gear', { delta }),
setGear: (gear: number) => call<RideState>('set_gear', { gear }),
nudgeGradient: (deltaPct: number) => call<RideState>('nudge_gradient', { deltaPct }),
setGradient: (percent: number) => call<RideState>('set_gradient', { percent }),
resetGradient: () => call<RideState>('reset_gradient'),
@@ -113,14 +159,20 @@ export const api = {
// controller (Zwift Click)
controllerStatus: () => call<ControllerStatus>('controller_status'),
connectController: (deviceId?: string) => call<void>('connect_controller', { deviceId }),
disconnectController: () => call<void>('disconnect_controller'),
/** One pod, or both when `pod` is omitted. `deviceId` names a specific pod
* the scanner has already listed, and needs `pod` alongside it. */
connectController: (pod?: Pod, deviceId?: string) =>
call<void>('connect_controller', { pod, deviceId }),
disconnectController: (pod?: Pod) => call<void>('disconnect_controller', { pod }),
/** Exchange + and , for when the pods answer to the other name. */
swapControllerPods: () => call<void>('swap_controller_pods'),
};
type Handlers = {
onFrame?: (f: RideFrame) => void;
onRideState?: (s: RideState) => void;
onLap?: (l: LapSummary) => void;
onSummary?: (s: RideSummary) => void;
onDevices?: (d: DeviceList) => void;
onNotice?: (n: Notice) => void;
onInputAck?: (a: InputAck) => void;
@@ -138,6 +190,7 @@ export async function subscribe(h: Handlers): Promise<UnlistenFn> {
await add(EVENTS.snapshot, h.onFrame);
await add(EVENTS.rideState, h.onRideState);
await add(EVENTS.lap, h.onLap);
await add(EVENTS.summary, h.onSummary);
await add(EVENTS.devices, h.onDevices);
await add(EVENTS.notice, h.onNotice);
await add(EVENTS.inputAck, h.onInputAck);
+59 -4
View File
@@ -51,8 +51,20 @@ export interface RideSnapshot {
};
virtual_speed_kph: number;
virtual_distance_m: number;
/** The gradient of the *road*. What the trainer was asked for is `target`. */
gradient_pct: number;
elevation_gain_m: number;
/** Selected virtual gear, one-based (FR-4.1). */
gear: number;
gear_count: number;
/** Metres travelled per crank revolution in the selected gear. */
development_m: number;
/** Cadence the selected gear implies at the current speed. */
target_cadence_rpm: number;
/** Which rule set the speed. `NoCadence` is a fault, not a mode. */
speed_source: 'Drivetrain' | 'Coasting' | 'NoCadence' | 'Stopped';
/** Force at the pedal, newtons — the commanded load as the legs feel it. */
pedal_force_n: number;
mode: ControlMode;
target: ControlTarget | null;
profile_progress: number | null;
@@ -66,6 +78,11 @@ export interface RiderConfig {
drivetrain_efficiency: number;
air_density: number;
wheel_circumference_m: number;
/** Crank length (pedal circle radius), metres. Road bikes are 0.1700.175. */
crank_length_m: number;
descent_load_floor_pct: number;
/** Development of the real gear through the Zwift Cog, m per crank rev. */
physical_development_m: number;
}
export interface SafetyLimits {
@@ -93,7 +110,10 @@ export interface Derived {
axisUnit: XUnit;
axisTotal: number;
loopIndex: number | null;
/** 45 s mean, for the ETA. Too slow to show a gear change — see below. */
smoothedSpeedKph: number;
/** 3 s mean: what the speedometer shows, so a shift is visible at once. */
displaySpeedKph: number;
rollingPowerW: number;
rollingPowerWindowS: number;
avgPowerW: number;
@@ -155,6 +175,39 @@ export interface LapSummary {
avgPowerW: number;
}
// --- src-tauri/src/recording.rs ----------------------------------------------
/** A finished ride, as the summary screen shows it (FR-9.13, FR-9.14). */
export interface RideSummary {
durationS: number;
/** Excludes time spent paused. */
movingS: number;
distanceM: number;
ascentM: number;
avgPowerW: number | null;
maxPowerW: number | null;
avgCadenceRpm: number | null;
calories: number | null;
records: number;
laps: number;
/** BLE dropouts spanned (FR-8.5). */
gaps: number;
/** True when the activity was rebuilt from a journal with no end marker. */
recoveredFromCrash: boolean;
/** Journal lines that could not be parsed. Non-zero means data was lost. */
skippedLogLines: number;
/** The automatic copy, in the app's data directory. */
fitPath: string;
/** Where the rider chose to save it, once they have (FR-9.14). */
savedPath: string | null;
}
/** A ride rebuilt from an interrupted session at startup (FR-8.4). */
export interface Recovered {
fitPath: string;
summary: RideSummary;
}
/** Trainer link state, mirrored from `src-tauri/src/trainer.rs`. */
export interface TrainerStatus {
state: ConnectionState;
@@ -175,15 +228,18 @@ export interface RideState {
manualGradientPct: number;
resistanceLevel: number;
powerTargetW: number;
/** Selected virtual gear, one-based, and how many there are (FR-4.1). */
gear: number;
gearCount: number;
lap: number;
laps: LapSummary[];
profile: ProfileView | null;
/** `'ftms'` for the real trainer, `'mock'` for the synthetic rider. */
source: string;
trainer: TrainerStatus;
}
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
/** Click pods are named for the shift paddle each carries see `Pod` in
* `bridge.ts` for why that is not left and right. */
export type DeviceKind = 'trainer' | 'clickMinus' | 'clickPlus' | 'heartRate' | 'unknown';
export interface DeviceInfo {
id: string;
@@ -196,7 +252,6 @@ export interface DeviceInfo {
services: string[];
remembered: boolean;
batteryPct: number | null;
unlockExpiresInS: number | null;
error: string | null;
}