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; }