From 7b511db3dca2cdc6171ffb4557e62082ba370000 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 5 Aug 2026 18:21:08 +0200 Subject: [PATCH] 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 --- Cargo.lock | 578 ++++++++- README.md | 18 +- REQUIREMENTS.md | 17 +- crates/ble/src/click.rs | 196 ++- crates/ble/src/client.rs | 151 ++- crates/ble/src/lib.rs | 4 +- crates/ble/src/scan.rs | 44 +- crates/ble/src/zwift.rs | 142 ++- crates/core/src/gearing.rs | 180 ++- crates/core/src/physics.rs | 116 +- crates/core/src/session.rs | 852 ++++++++++++- crates/core/src/types.rs | 148 ++- crates/fit/src/builder.rs | 3 + crates/fit/src/rawlog.rs | 6 + crates/fit/src/recorder.rs | 6 + crates/fit/tests/crash_recovery.rs | 6 + profiles/drag-race.yaml | 26 + resistance-test.sh | 93 ++ run.sh | 48 +- src-tauri/Cargo.toml | 18 +- src-tauri/src/backend.rs | 44 +- src-tauri/src/commands.rs | 287 ++++- src-tauri/src/controller.rs | 1373 ++++++++++++++++++--- src-tauri/src/derive.rs | 130 +- src-tauri/src/devices.rs | 188 ++- src-tauri/src/events.rs | 24 +- src-tauri/src/lib.rs | 42 +- src-tauri/src/mock.rs | 228 ---- src-tauri/src/profile_view.rs | 29 +- src-tauri/src/recording.rs | 549 ++++++++ src-tauri/src/samples.rs | 104 +- src-tauri/src/session_backend.rs | 196 ++- src-tauri/src/state.rs | 231 +++- src-tauri/src/trainer.rs | 54 +- src-tauri/src/wakelock.rs | 122 ++ ui/src/App.svelte | 93 +- ui/src/components/ClickPanel.svelte | 459 +++++++ ui/src/components/ConnectionScreen.svelte | 84 +- ui/src/components/HelpOverlay.svelte | 58 +- ui/src/components/RideScreen.svelte | 146 ++- ui/src/components/SummaryScreen.svelte | 266 ++++ ui/src/lib/app.svelte.ts | 103 +- ui/src/lib/bridge.ts | 61 +- ui/src/lib/types.ts | 63 +- 44 files changed, 6636 insertions(+), 950 deletions(-) create mode 100644 profiles/drag-race.yaml create mode 100755 resistance-test.sh delete mode 100644 src-tauri/src/mock.rs create mode 100644 src-tauri/src/recording.rs create mode 100644 src-tauri/src/wakelock.rs create mode 100644 ui/src/components/ClickPanel.svelte create mode 100644 ui/src/components/SummaryScreen.svelte diff --git a/Cargo.lock b/Cargo.lock index 46ef893..4ff9898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/README.md b/README.md index 5208604..9fcb0ed 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a49131c..5b3f4bc 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 | --- diff --git a/crates/ble/src/click.rs b/crates/ble/src/click.rs index 2b1b440..9516f4e 100644 --- a/crates/ble/src/click.rs +++ b/crates/ble/src/click.rs @@ -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) -> 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 { + 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 { + // 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, + /// 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, }, /// 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, events_tx: broadcast::Sender, + address: String, + name: Option, + pod: Option, } 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 { + 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 { + pub async fn connect(selector: PodSelector, config: ClickConfig) -> Result { 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, ) -> Result, 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::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 { @@ -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, + /// Which pod this is, as it advertised itself. + pod: Option, subscribed: Vec, } /// 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, +) -> 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, 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!( diff --git a/crates/ble/src/client.rs b/crates/ble/src/client.rs index c1a6881..cadb6e1 100644 --- a/crates/ble/src/client.rs +++ b/crates/ble/src/client.rs @@ -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 { + 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, +) { + 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, diff --git a/crates/ble/src/lib.rs b/crates/ble/src/lib.rs index 17ddee6..5f8a256 100644 --- a/crates/ble/src/lib.rs +++ b/crates/ble/src/lib.rs @@ -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, }; diff --git a/crates/ble/src/scan.rs b/crates/ble/src/scan.rs index c7b89e5..d8cf294 100644 --- a/crates/ble/src/scan.rs +++ b/crates/ble/src/scan.rs @@ -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 { + 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 { - 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 { 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)] diff --git a/crates/ble/src/zwift.rs b/crates/ble/src/zwift.rs index 1e4ede6..2d30601 100644 --- a/crates/ble/src/zwift.rs +++ b/crates/ble/src/zwift.rs @@ -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 { + 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!( diff --git a/crates/core/src/gearing.rs b/crates/core/src/gearing.rs index 423e67a..3ede167 100644 --- a/crates/core/src/gearing.rs +++ b/crates/core/src/gearing.rs @@ -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::*; diff --git a/crates/core/src/physics.rs b/crates/core/src/physics.rs index 9192872..89f9edf 100644 --- a/crates/core/src/physics.rs +++ b/crates/core/src/physics.rs @@ -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); } diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index ddb7ba7..4035183 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -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, } @@ -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: + /// — "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 { + 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 ); } - } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index f72b05a..5fce639 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -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 170–175 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 0–6% 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 50–600 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, diff --git a/crates/fit/src/builder.rs b/crates/fit/src/builder.rs index e34a86b..b900e62 100644 --- a/crates/fit/src/builder.rs +++ b/crates/fit/src/builder.rs @@ -52,6 +52,8 @@ pub struct FitSummary { pub avg_power_w: Option, /// Peak power. `None` if no sample reported power. pub max_power_w: Option, + /// Mean cadence over samples that reported one. `None` if none did. + pub avg_cadence_rpm: Option, /// Estimated rider energy expenditure in kilocalories, derived from /// measured mechanical work — see [`Aggregates::calories`]. pub total_calories: Option, @@ -216,6 +218,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec, 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, diff --git a/crates/fit/src/rawlog.rs b/crates/fit/src/rawlog.rs index 81b6c48..c409353 100644 --- a/crates/fit/src/rawlog.rs +++ b/crates/fit/src/rawlog.rs @@ -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, diff --git a/crates/fit/src/recorder.rs b/crates/fit/src/recorder.rs index c7ac5d3..bfa743c 100644 --- a/crates/fit/src/recorder.rs +++ b/crates/fit/src/recorder.rs @@ -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, diff --git a/crates/fit/tests/crash_recovery.rs b/crates/fit/tests/crash_recovery.rs index b42aa0a..ac80375 100644 --- a/crates/fit/tests/crash_recovery.rs +++ b/crates/fit/tests/crash_recovery.rs @@ -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), diff --git a/profiles/drag-race.yaml b/profiles/drag-race.yaml new file mode 100644 index 0000000..e60630a --- /dev/null +++ b/profiles/drag-race.yaml @@ -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 diff --git a/resistance-test.sh b/resistance-test.sh new file mode 100755 index 0000000..790c375 --- /dev/null +++ b/resistance-test.sh @@ -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?" diff --git a/run.sh b/run.sh index dd3f964..18fc044 100755 --- a/run.sh +++ b/run.sh @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41b9c96..9e8e3f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index a138329..c14077b 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -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>, @@ -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; } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ac65a6d..69ae8de 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 = Result; +/// 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 { - { - 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 { 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 #[tauri::command] pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { - 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 { - 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) -> Cmd { 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 { + 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 { + 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 { + 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 { state.lock().reset_ride(); @@ -148,6 +270,41 @@ pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd, delta: i32) -> Cmd { + 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 { + 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 { 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 { #[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) -> 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, + device_id: Option, +) -> 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, +) -> 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(()) } diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 96c6352..c60610e 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -1,14 +1,28 @@ -//! The controller supervisor: the app's single owner of a [`ClickClient`]. +//! The controller supervisor: the app's single owner of the Click pods. //! //! The same shape as [`crate::trainer`], and for the same reason — the Tauri //! commands hold a `Mutex` and may never `.await` a radio, so all BLE work //! happens in one background task that they reach over a channel. //! //! ```text -//! commands ──connect/disconnect──► [supervisor task] ──► ClickClient -//! webview ◄──controller://input──── button edges ◄─────────┘ +//! commands ──connect/disconnect──► [supervisor task] ──► ClickClient (+ pod) +//! └────────► ClickClient (− pod) +//! webview ◄──controller://input──── button edges ◄──────────────┘ //! ``` //! +//! **A Click v2 is two peripherals, not one** (§2.3.1, FR-1.4). They advertise +//! the same local name, so the old single-slot supervisor asked for "a Zwift +//! Click" and got whichever pod answered first — a coin toss, with the loser +//! invisible and no way to tell which one you had. Each pod now has its own +//! slot, its own link and its own line on screen, keyed by the shift paddle it +//! carries rather than by which end of the bar it is clamped to (see +//! [`PodId`]). +//! +//! Connect attempts run in child tasks rather than inline, so a twenty-second +//! search for a sleeping pod cannot hold up the other pod's buttons (A-4). +//! Each attempt carries a generation, and a result from a superseded attempt is +//! discarded — with its link closed, never merely dropped (SAF-9). +//! //! The difference from the trainer is that a controller has **no safety //! story**: it never commands load, so there is no SAF-2 sequence to run and //! nothing to reset on exit. It still has to be *disconnected* on the way out @@ -26,29 +40,285 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use bikecontrol_ble::click::{ClickClient, ClickConfig, ClickEvent}; -use bikecontrol_ble::zwift::Button; -use bikecontrol_ble::TrainerSelector; -use serde::Serialize; -use tokio::sync::{mpsc, watch}; +use bikecontrol_ble::zwift::{Button, PodId}; +use bikecontrol_ble::{Backoff, FtmsError, PodSelector}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, oneshot, watch}; /// How long a controller may stay silent before we call it stale. The pod sends /// a battery heartbeat every ~5 s even when idle, so 20 s is four missed beats. const STALE_AFTER: Duration = Duration::from_secs(20); -/// Upper bound on closing the controller link at exit. Shorter than the +/// Upper bound on closing the controller links at exit. Shorter than the /// trainer's: there is no reset sequence here, only an unsubscribe and a /// disconnect, and this budget is spent on the same window close (NFR-9). const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +/// How long auto-reconnect keeps chasing a pod before it gives up and says so. +/// +/// FR-1.11. More generous than the trainer's, because a Click genuinely does +/// sleep between efforts and only advertises after a button press (A-4), so a +/// quiet pod is normal where a quiet trainer is not. It is still bounded: +/// a flat pod will not come back, and scanning for it every thirty seconds +/// forever competes with the trainer — and with the other pod — for the one +/// adapter. +const RECONNECT_ATTEMPTS: u32 = 30; -/// What the UI needs to know about the controller link. -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +const _: () = assert!( + RECONNECT_ATTEMPTS > crate::trainer::RECONNECT_ATTEMPTS, + "a pod that is merely asleep must not be given up on sooner than a trainer" +); + +/// The controller configuration this app rides with. +pub fn controller_config() -> ClickConfig { + ClickConfig { + backoff: Backoff { + max_attempts: Some(RECONNECT_ATTEMPTS), + ..Backoff::default() + }, + ..ClickConfig::default() + } +} + +/// Which pod, as the webview names it. Mirrors [`PodId`], which lives in the +/// BLE crate and carries no serde derives for the app's benefit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ControllerStatus { - pub connected: bool, +pub enum Pod { + Minus, + Plus, +} + +impl From for Pod { + fn from(id: PodId) -> Self { + match id { + PodId::Minus => Pod::Minus, + PodId::Plus => Pod::Plus, + } + } +} + +impl From for PodId { + fn from(pod: Pod) -> Self { + match pod { + Pod::Minus => PodId::Minus, + Pod::Plus => PodId::Plus, + } + } +} + +/// Where one pod's link has got to. +/// +/// Deliberately more than a bool. "Not connected" covers a pod that is asleep, +/// one that is being searched for right now, and one the app has stopped +/// chasing — three situations with three different things for the rider to do, +/// and a single flag could not tell them apart (FR-9.2). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PodState { + /// Never asked for, or disconnected on purpose. + #[default] + Idle, + /// A connect is in flight. A pod only advertises after a button press, so + /// this can legitimately sit here for the whole scan timeout. + Searching, + /// Connected and talking. + Connected, + /// The link dropped and the BLE layer is chasing it. The ride carries on. + Reconnecting, + /// The app has stopped chasing (FR-1.11). Terminal until the rider acts. + GaveUp, +} + +/// What the UI needs to know about one pod. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PodStatus { + pub pod: Pod, + /// `+` or `−`, so the webview never has to map an enum to a glyph. + pub symbol: &'static str, + pub state: PodState, pub address: Option, pub name: Option, pub battery_percent: Option, /// Rendered verbatim (FR-9.2). pub error: Option, + /// The last button this pod sent, as [`button_name`] spells it. The point + /// is diagnostic: a pod that is connected but has never sent anything looks + /// exactly like a working one until you press something. + pub last_button: Option<&'static str>, + pub buttons_seen: u32, + /// This pod has sent its own paddle, so the label above is no longer a + /// reading of the type byte — it is what the rider pressed. + pub confirmed: bool, + /// This pod has sent the *other* pod's paddle. Either the pair is filed the + /// wrong way round (see [`ControllerHandle::swap`]), or this pod reports + /// for both. + pub contradicted: bool, +} + +impl PodStatus { + fn new(pod: PodId) -> Self { + Self { + pod: pod.into(), + symbol: pod.symbol(), + state: PodState::Idle, + address: None, + name: None, + battery_percent: None, + error: None, + last_button: None, + buttons_seen: 0, + confirmed: false, + contradicted: false, + } + } + + pub fn connected(&self) -> bool { + self.state == PodState::Connected + } + + /// Forget everything about the link, keeping what we know about the pod + /// itself. The address survives on purpose: it is how the next connect + /// finds this pod rather than its identically-named twin. + fn reset_link(&mut self, state: PodState) { + self.state = state; + self.battery_percent = None; + self.error = None; + } +} + +/// What the UI needs to know about the controller as a whole. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ControllerStatus { + pub minus: PodStatus, + pub plus: PodStatus, + /// The type-byte reading has been flipped by the rider. §2.3.1 leaves which + /// byte is which pod unproven, so the default is a documented guess with an + /// escape hatch rather than a fact. + pub swapped: bool, +} + +impl Default for ControllerStatus { + fn default() -> Self { + Self { + minus: PodStatus::new(PodId::Minus), + plus: PodStatus::new(PodId::Plus), + swapped: false, + } + } +} + +impl ControllerStatus { + pub fn get(&self, pod: PodId) -> &PodStatus { + match pod { + PodId::Minus => &self.minus, + PodId::Plus => &self.plus, + } + } + + fn get_mut(&mut self, pod: PodId) -> &mut PodStatus { + match pod { + PodId::Minus => &mut self.minus, + PodId::Plus => &mut self.plus, + } + } + + /// Any pod talking at all. Shifting keeps working with one pod, which is + /// why this is not `both`. + pub fn any_connected(&self) -> bool { + self.minus.connected() || self.plus.connected() + } + + pub fn both_connected(&self) -> bool { + self.minus.connected() && self.plus.connected() + } +} + +/// One physical press, however many pods report it. +/// +/// The pair does **not** divide the buttons between them. §2.3.1 records every +/// one of the ten arriving over the `0x0B` pod alone and leaves open what the +/// other contributes; the answer is that the `+` paddle arrives over *both* — +/// once from the pod it is printed on and once relayed by its twin. Forwarding +/// each pod's edges as they came therefore shifted twice for one press of `+`, +/// while `−`, which only its own pod reports, shifted once. That is the `+2` +/// upshift against a `−1` downshift, and it is visible in a ride log as a gear +/// going 7 → 9 → 11 on three presses. +/// +/// So the two pods are merged into one controller: a button is held when +/// *either* pod says it is, and only the transitions of that aggregate reach +/// the app. Releases carry as much weight as presses here — the aggregate +/// falling back to "nobody holds it" is what re-arms the next press, which is +/// why every path that can lose a release ([`forget`]) has to say so. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct Buttons { + /// One mask per pod, bit per [`Button::bit`]. The highest is 12, so a `u16` + /// holds the lot. + minus: u16, + plus: u16, +} + +impl Buttons { + fn mask_mut(&mut self, pod: PodId) -> &mut u16 { + match pod { + PodId::Minus => &mut self.minus, + PodId::Plus => &mut self.plus, + } + } + + /// Does any pod believe this button is held? + fn held(&self, button: Button) -> bool { + (self.minus | self.plus) & (1u16 << button.bit()) != 0 + } + + /// Fold in one pod's edge. `Some(pressed)` when the controller as a whole + /// changed state — that is, when the edge is worth forwarding. `None` when + /// the other pod had already said the same thing. + fn edge(&mut self, pod: PodId, button: Button, pressed: bool) -> Option { + let before = self.held(button); + let bit = 1u16 << button.bit(); + let mask = self.mask_mut(pod); + if pressed { + *mask |= bit; + } else { + *mask &= !bit; + } + let after = self.held(button); + (after != before).then_some(after) + } + + /// Drop everything this pod claimed to be holding, and report which buttons + /// nobody is holding any more. + /// + /// Called wherever a release edge could have been missed — a dropped link, a + /// lagged channel. A release that never arrives would leave the aggregate + /// latched, and a latched paddle does not shift at all, which is a worse + /// failure than the one this type exists to fix. + fn forget(&mut self, pod: PodId) -> Vec + {:else if !bothConnected} + + {/if} + {#if anyConnected} + + {/if} + + + +

+ {#if !scanning} + The scan is off, 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} + Press any button on a missing pod. It only advertises while awake, and the + running scan connects it as soon as it does — no need to press anything here. + {/if} +

+ +
+ {#each pods as pod (pod.pod)} +
+
+ {pod.symbol} + + {pod.symbol} pod + {PURPOSE[pod.pod]} + + + {STATE_TEXT[pod.state]} + +
+ +
+
+
Battery
+
{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}
+
+
+
Buttons seen
+ +
+ {pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`} + {#if pod.lastButton}· {pod.lastButton}{/if} +
+
+
+
Address
+
{pod.address ?? '—'}
+
+
+ + {#if pod.confirmed} +

Confirmed — this pod sent its own {pod.symbol} paddle.

+ {:else if pod.state === 'connected'} +

+ Press the {pod.symbol} paddle on this pod to confirm it is the one. +

+ {/if} + + {#if pod.contradicted} +

+ This pod sent the other paddle. If the pair is the wrong way round, swap them. +

+ {/if} + + {#if pod.error} + +

{pod.error}

+ {/if} + +
+ {#if pod.state === 'connected' || pod.state === 'reconnecting'} + + {:else if pod.state === 'searching'} + + {:else} + + {/if} +
+
+ {/each} + + {#if pods.length === 0} +

Waiting for the controller supervisor…

+ {/if} +
+ + {#if mixedUp || controller?.swapped} +
+ + {#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} + + +
+ {/if} + + {#if !bothConnected} + +
+ A pod will not connect — what to try +
    +
  1. + Press any button on the pod. 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. +
  2. +
  3. + Keep the scan on. Auto-connect works off the device scan, so a + stopped scan means nothing gets picked up. Restart it above. +
  4. +
  5. + Close anything else holding the pod. One app at a time — Zwift left + running in the background keeps the link, and this app will never see the pod. +
  6. +
  7. + Bring it closer, or charge it. A flat pod stops advertising + altogether, and after about thirty failed attempts the app stops chasing it and says + so on the card. +
  8. +
+

+ Meanwhile the keyboard mirrors every Click action — + + shift, + trim the gradient. Press + ? for the full list. A ride never depends on a pod. +

+
+ {/if} + + + diff --git a/ui/src/components/ConnectionScreen.svelte b/ui/src/components/ConnectionScreen.svelte index 41f6dc2..6b17263 100644 --- a/ui/src/components/ConnectionScreen.svelte +++ b/ui/src/components/ConnectionScreen.svelte @@ -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 = { 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} {/if} - -
- - - {#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. Press a button on the Click first — it only advertises - while awake. - {/if} - - {#if app.controller?.connected} - - {:else} - - {/if} -
+ {#if !trainerReady}
No trainer under control yet. The ride screen will show simulated - telemetry until an FTMS trainer accepts the control point.No trainer under control yet. The ride screen stays locked until an FTMS + trainer accepts the control point.
{/if} @@ -138,20 +124,20 @@ {device.controlAcquired ? 'Acquired' : 'Not acquired'} - {:else if device.kind === 'clickLeft' || device.kind === 'clickRight'} + {:else if device.kind === 'clickMinus' || device.kind === 'clickPlus'} + + {@const pod = device.kind === 'clickPlus' ? app.controller?.plus : app.controller?.minus} - Zwift unlock - 0} - class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0} - > - - {#if (device.unlockExpiresInS ?? 0) > 0} - {Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left - {:else} - Expired — re-unlock in Zwift - {/if} + Click pod + + {pod?.symbol ?? '?'} pod{pod?.state === 'connected' + ? ' · linked' + : ''} {:else if device.batteryPct != null} diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte index 25057e5..83abb6e 100644 --- a/ui/src/components/HelpOverlay.svelte +++ b/ui/src/components/HelpOverlay.svelte @@ -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'], ]; @@ -60,16 +63,31 @@ {/each} - {#if app.controller?.connected} + +
    + {#each [app.controller?.minus, app.controller?.plus] as pod} + {#if pod} +
  • + + {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} +
  • + {/if} + {/each} +
+ {#if !app.controller || app.controller.minus.state !== 'connected' || app.controller.plus.state !== 'connected'}

- Controller connected{app.controller.batteryPercent != null - ? ` — battery ${app.controller.batteryPercent}%` - : ''}. -

- {:else} -

- 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.

{/if}

Every one of these has an on-screen equivalent in the control bar.

@@ -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; diff --git a/ui/src/components/RideScreen.svelte b/ui/src/components/RideScreen.svelte index b6977cc..70d6405 100644 --- a/ui/src/components/RideScreen.svelte +++ b/ui/src/components/RideScreen.svelte @@ -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); -
- {#if simulated} - -
- Simulated ride - Power, speed and distance are fabricated. No trainer is being read. -
- {/if} - +
@@ -192,6 +238,16 @@ {trainerChip.label} {/if} {statusChip.label} + + {#if podChip} + + {podChip.label} + + {/if} {MODE_LABEL[ride?.mode ?? 'ManualGrade']} Target {targetText(ride?.target ?? null)} @@ -251,7 +307,7 @@ /> + @@ -294,7 +358,13 @@ colour="var(--power)" sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`} /> - + + /** + * 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; + }); + + +
+ {#if !s} +
+

No finished ride

+

End a ride and its summary appears here.

+ +
+ {:else} +
+
+

Ride complete

+ + {s.records} sample{s.records === 1 ? '' : 's'} · {s.laps} lap{s.laps === 1 ? '' : 's'} + +
+ Saved +
+ + +
+ + + +
+ +
+ + + + = 1 ? `${clock(pausedS)} paused` : null} + /> + +
+ + {#if caveats.length > 0} +
+ {#each caveats as caveat (caveat)} +

{caveat}

+ {/each} +
+ {/if} + + +
+ Activity file + {s.savedPath ?? s.fitPath} + {#if s.savedPath} + Automatic copy kept at {s.fitPath} + {/if} +
+ +
+ + + +
+ {/if} +
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index bec1d28..bea9fc6 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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(null); + /** The finished ride behind the summary screen (FR-9.13). */ + summary = $state(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(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 { - 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 { + 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 { + 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(fn: () => Promise): Promise { try { diff --git a/ui/src/lib/bridge.ts b/ui/src/lib/bridge.ts index 54bf749..d83f689 100644 --- a/ui/src/lib/bridge.ts +++ b/ui/src/lib/bridge.ts @@ -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('stop_ride'), reset: () => call('reset_ride'), + // recording and export + rideSummary: () => call('ride_summary'), + /** Copy the finished activity to `path`; resolves with the path written. */ + saveFit: (path: string) => call('save_fit', { path }), + /** Rides rebuilt from an interrupted session. Drains — call once at start. */ + recoveredRides: () => call('recovered_rides'), + // control modes and targets setMode: (mode: ControlMode) => call('set_control_mode', { mode }), cycleMode: () => call('cycle_control_mode'), + shiftGear: (delta: number) => call('shift_gear', { delta }), + setGear: (gear: number) => call('set_gear', { gear }), nudgeGradient: (deltaPct: number) => call('nudge_gradient', { deltaPct }), setGradient: (percent: number) => call('set_gradient', { percent }), resetGradient: () => call('reset_gradient'), @@ -113,14 +159,20 @@ export const api = { // controller (Zwift Click) controllerStatus: () => call('controller_status'), - connectController: (deviceId?: string) => call('connect_controller', { deviceId }), - disconnectController: () => call('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('connect_controller', { pod, deviceId }), + disconnectController: (pod?: Pod) => call('disconnect_controller', { pod }), + /** Exchange + and −, for when the pods answer to the other name. */ + swapControllerPods: () => call('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 { 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); diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index b7a1965..345ab45 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -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.170–0.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; }