diff --git a/Cargo.lock b/Cargo.lock index 6862968..3ef6558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -46,12 +67,72 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bikecontrol-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "bikecontrol-core", + "roxmltree", + "serde", + "serde_json", + "serde_yaml_ng", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "thiserror 2.0.19", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "bikecontrol-ble" version = "0.1.0" @@ -82,6 +163,9 @@ version = "0.1.0" dependencies = [ "bikecontrol-core", "chrono", + "fitparser", + "serde", + "serde_json", "thiserror 2.0.19", ] @@ -100,11 +184,44 @@ dependencies = [ "uuid", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "block2" @@ -112,7 +229,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", ] [[package]] @@ -121,7 +247,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bluez-generated", "dbus", "dbus-tokio", @@ -144,6 +270,36 @@ dependencies = [ "dbus", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "btleplug" version = "0.11.8" @@ -151,17 +307,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9a11621cb2c8c024e444734292482b1ad86fb50ded066cf46252e46643c8748" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.13.1", "bluez-async", "dashmap 6.2.1", "dbus", "futures", - "jni", + "jni 0.19.0", "jni-utils", "log", - "objc2", + "objc2 0.5.2", "objc2-core-bluetooth", - "objc2-foundation", + "objc2-foundation 0.2.2", "once_cell", "static_assertions", "thiserror 2.0.19", @@ -178,11 +334,93 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] [[package]] name = "cc" @@ -200,6 +438,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -215,6 +474,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.1", ] @@ -229,18 +489,172 @@ dependencies = [ "memchr", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "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", + "quote", + "syn 2.0.119", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -278,7 +692,7 @@ dependencies = [ "futures-util", "libc", "libdbus-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -292,18 +706,222 @@ dependencies = [ "tokio", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -311,7 +929,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", ] [[package]] @@ -320,6 +963,75 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fitparser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56a834aed7c01a500afb06ebcfde59a4dcfd1de7ce15ae501462934d59655376" +dependencies = [ + "chrono", + "nom", + "serde", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.33" @@ -408,6 +1120,303 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -420,6 +1429,116 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -444,6 +1563,136 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -452,8 +1701,25 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itertools" version = "0.14.0" @@ -469,6 +1735,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "jni" version = "0.19.0" @@ -483,6 +1772,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -519,7 +1824,7 @@ checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" dependencies = [ "dashmap 5.5.3", "futures", - "jni", + "jni 0.19.0", "log", "once_cell", "static_assertions", @@ -537,12 +1842,69 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.189" @@ -558,6 +1920,31 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -573,6 +1960,17 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -588,6 +1986,31 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -596,7 +2019,67 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", ] [[package]] @@ -605,9 +2088,15 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -617,6 +2106,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -633,15 +2144,115 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-bluetooth" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", + "bitflags 2.13.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", ] [[package]] @@ -650,16 +2261,106 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "libc", - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -668,6 +2369,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -691,6 +2423,65 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -703,6 +2494,119 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -712,6 +2616,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -721,13 +2634,74 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -747,12 +2721,85 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "roxmltree" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -774,12 +2821,92 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.229" @@ -790,6 +2917,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde-xml-rs" version = "0.8.2" @@ -822,19 +2961,146 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_yaml_ng" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", "unsafe-libyaml", ] +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -860,6 +3126,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -879,15 +3157,120 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -910,6 +3293,376 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.2", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2 0.6.4", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -959,6 +3712,61 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -973,7 +3781,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1012,6 +3820,171 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1073,25 +4046,145 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1101,6 +4194,38 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1111,12 +4236,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1130,6 +4273,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -1162,13 +4315,165 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", ] [[package]] @@ -1310,6 +4615,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1319,6 +4651,54 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -1328,8 +4708,349 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "xml" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f69ff02..c775d0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/core", "crates/ble", "crates/fit", "crates/probe"] +members = ["crates/core", "crates/ble", "crates/fit", "crates/probe", "src-tauri"] [workspace.package] version = "0.1.0" diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 25233da..cb275ec 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -491,9 +491,10 @@ tests, and removes dependence on the trainer's internal mass assumptions. | ID | Requirement | Priority | |----|-------------|----------| -| FR-9.5 | Large, legible readouts (power, cadence, speed, gradient, gear, elapsed) readable at ~1 m | Must | +| FR-9.5 | Large, legible readouts readable at ~1 m. **The screen is route-led, not power-led** — ETA, distance remaining, speed, gradient and elevation take visual priority; power, cadence and heart rate are present but subordinate | Must | | FR-9.6 | Live streaming charts of power and gradient/target | Must | -| FR-9.7 | Route elevation profile or waveform preview with current position marked | Must | +| FR-9.7 | **The route elevation profile with current position marked is the primary visual element** of the ride screen, not a supporting chart. For waveform profiles, the profile preview serves the same role | Must | +| FR-9.15 | **Estimated time to finish (ETA)**, alongside distance covered and remaining | Must | | FR-9.8 | Prominent display of active mode, current gear, and current target | Must | | FR-9.9 | Visible feedback on every button press, so the rider knows input registered | Must | | FR-9.10 | On-screen and keyboard equivalents for all controller actions | Must | diff --git a/crates/ble/src/capabilities.rs b/crates/ble/src/capabilities.rs new file mode 100644 index 0000000..e21d400 --- /dev/null +++ b/crates/ble/src/capabilities.rs @@ -0,0 +1,804 @@ +//! What the trainer says it can do, and the gate that stops us asking it for +//! anything else. +//! +//! Two sources: +//! +//! * **Fitness Machine Feature** (`0x2ACC`) — two little-endian uint32 +//! bitfields: machine features, then target-setting features. The second is +//! what tells us whether `SetTargetInclination`, `SetTargetResistanceLevel`, +//! `SetTargetPower` and `SetIndoorBikeSimulationParameters` are supported. +//! * **Supported * Range** characteristics — `0x2AD5` inclination, `0x2AD6` +//! resistance level, `0x2AD8` power. Each is `min, max, increment`. +//! +//! Together these satisfy FR-2.6: never send an unsupported or out-of-range +//! command. [`gate_target`] is the single choke point; it is a pure function so +//! the whole of SAF-3 is unit-testable without hardware. + +use bikecontrol_core::types::{ControlTarget, SafetyLimits}; + +use crate::control_point::OpCode; + +/// Why a [`ControlTarget`] cannot be sent to this trainer. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum UnsupportedTarget { + #[error("trainer does not advertise support for {0}")] + OpCodeUnsupported(OpCode), + #[error( + "trainer's supported range for {what} is {min}..={max}, which excludes every value \ + permitted by the configured safety limits" + )] + EmptyRange { + what: &'static str, + min: i32, + max: i32, + }, +} + +/// Decoded Fitness Machine Feature characteristic (`0x2ACC`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FitnessMachineFeature { + /// Fitness Machine Features bitfield (what the machine *measures*). + pub machine: u32, + /// Target Setting Features bitfield (what the machine can be *told*). + pub target: u32, +} + +/// Bit positions in the Fitness Machine Features field. +pub mod machine_feature { + pub const AVERAGE_SPEED: u32 = 1 << 0; + pub const CADENCE: u32 = 1 << 1; + pub const TOTAL_DISTANCE: u32 = 1 << 2; + pub const INCLINATION: u32 = 1 << 3; + pub const ELEVATION_GAIN: u32 = 1 << 4; + pub const PACE: u32 = 1 << 5; + pub const STEP_COUNT: u32 = 1 << 6; + pub const RESISTANCE_LEVEL: u32 = 1 << 7; + pub const STRIDE_COUNT: u32 = 1 << 8; + pub const EXPENDED_ENERGY: u32 = 1 << 9; + pub const HEART_RATE_MEASUREMENT: u32 = 1 << 10; + pub const METABOLIC_EQUIVALENT: u32 = 1 << 11; + pub const ELAPSED_TIME: u32 = 1 << 12; + pub const REMAINING_TIME: u32 = 1 << 13; + pub const POWER_MEASUREMENT: u32 = 1 << 14; + pub const FORCE_ON_BELT_AND_POWER_OUTPUT: u32 = 1 << 15; + pub const USER_DATA_RETENTION: u32 = 1 << 16; +} + +/// Bit positions in the Target Setting Features field. +pub mod target_feature { + pub const SPEED: u32 = 1 << 0; + pub const INCLINATION: u32 = 1 << 1; + pub const RESISTANCE: u32 = 1 << 2; + pub const POWER: u32 = 1 << 3; + pub const HEART_RATE: u32 = 1 << 4; + pub const EXPENDED_ENERGY: u32 = 1 << 5; + pub const STEP_NUMBER: u32 = 1 << 6; + pub const STRIDE_NUMBER: u32 = 1 << 7; + pub const DISTANCE: u32 = 1 << 8; + pub const TRAINING_TIME: u32 = 1 << 9; + pub const TIME_IN_TWO_HR_ZONES: u32 = 1 << 10; + pub const TIME_IN_THREE_HR_ZONES: u32 = 1 << 11; + pub const TIME_IN_FIVE_HR_ZONES: u32 = 1 << 12; + /// Bit 13 — `SetIndoorBikeSimulationParameters` (`0x11`). Open question A-1. + pub const INDOOR_BIKE_SIMULATION: u32 = 1 << 13; + pub const WHEEL_CIRCUMFERENCE: u32 = 1 << 14; + pub const SPIN_DOWN: u32 = 1 << 15; + pub const CADENCE: u32 = 1 << 16; +} + +/// A characteristic (`0x2ACC` etc.) was shorter than its definition requires. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("{what} characteristic is {len} bytes; {need} are required")] +pub struct FieldTooShort { + pub what: &'static str, + pub len: usize, + pub need: usize, +} + +impl FitnessMachineFeature { + /// Decode the 8-byte Fitness Machine Feature characteristic. + pub fn decode(data: &[u8]) -> Result { + if data.len() < 8 { + return Err(FieldTooShort { + what: "Fitness Machine Feature", + len: data.len(), + need: 8, + }); + } + Ok(Self { + machine: u32::from_le_bytes([data[0], data[1], data[2], data[3]]), + target: u32::from_le_bytes([data[4], data[5], data[6], data[7]]), + }) + } + + pub fn has_machine(self, bit: u32) -> bool { + self.machine & bit != 0 + } + + pub fn has_target(self, bit: u32) -> bool { + self.target & bit != 0 + } + + pub fn supports_inclination_target(self) -> bool { + self.has_target(target_feature::INCLINATION) + } + + pub fn supports_resistance_target(self) -> bool { + self.has_target(target_feature::RESISTANCE) + } + + pub fn supports_power_target(self) -> bool { + self.has_target(target_feature::POWER) + } + + /// A-1: whether `0x11` is advertised. Advertised support and *actual* + /// support are not the same thing — `probe set` writes the op code to find + /// out for certain. + pub fn supports_simulation(self) -> bool { + self.has_target(target_feature::INDOOR_BIKE_SIMULATION) + } + + /// Human-readable list of set machine-feature bits, for the probe CLI. + pub fn machine_feature_names(self) -> Vec<&'static str> { + use machine_feature as m; + let table: [(u32, &'static str); 17] = [ + (m::AVERAGE_SPEED, "Average Speed"), + (m::CADENCE, "Cadence"), + (m::TOTAL_DISTANCE, "Total Distance"), + (m::INCLINATION, "Inclination"), + (m::ELEVATION_GAIN, "Elevation Gain"), + (m::PACE, "Pace"), + (m::STEP_COUNT, "Step Count"), + (m::RESISTANCE_LEVEL, "Resistance Level"), + (m::STRIDE_COUNT, "Stride Count"), + (m::EXPENDED_ENERGY, "Expended Energy"), + (m::HEART_RATE_MEASUREMENT, "Heart Rate Measurement"), + (m::METABOLIC_EQUIVALENT, "Metabolic Equivalent"), + (m::ELAPSED_TIME, "Elapsed Time"), + (m::REMAINING_TIME, "Remaining Time"), + (m::POWER_MEASUREMENT, "Power Measurement"), + (m::FORCE_ON_BELT_AND_POWER_OUTPUT, "Force on Belt and Power Output"), + (m::USER_DATA_RETENTION, "User Data Retention"), + ]; + table + .iter() + .filter(|(bit, _)| self.machine & bit != 0) + .map(|(_, name)| *name) + .collect() + } + + /// Human-readable list of set target-setting bits, for the probe CLI. + pub fn target_feature_names(self) -> Vec<&'static str> { + use target_feature as t; + let table: [(u32, &'static str); 17] = [ + (t::SPEED, "Speed Target Setting"), + (t::INCLINATION, "Inclination Target Setting (0x03)"), + (t::RESISTANCE, "Resistance Target Setting (0x04)"), + (t::POWER, "Power Target Setting (0x05)"), + (t::HEART_RATE, "Heart Rate Target Setting"), + (t::EXPENDED_ENERGY, "Targeted Expended Energy Configuration"), + (t::STEP_NUMBER, "Targeted Step Number Configuration"), + (t::STRIDE_NUMBER, "Targeted Stride Number Configuration"), + (t::DISTANCE, "Targeted Distance Configuration"), + (t::TRAINING_TIME, "Targeted Training Time Configuration"), + (t::TIME_IN_TWO_HR_ZONES, "Targeted Time in Two HR Zones"), + (t::TIME_IN_THREE_HR_ZONES, "Targeted Time in Three HR Zones"), + (t::TIME_IN_FIVE_HR_ZONES, "Targeted Time in Five HR Zones"), + (t::INDOOR_BIKE_SIMULATION, "Indoor Bike Simulation Parameters (0x11)"), + (t::WHEEL_CIRCUMFERENCE, "Wheel Circumference Configuration"), + (t::SPIN_DOWN, "Spin Down Control"), + (t::CADENCE, "Targeted Cadence Configuration"), + ]; + table + .iter() + .filter(|(bit, _)| self.target & bit != 0) + .map(|(_, name)| *name) + .collect() + } +} + +/// Supported Resistance Level Range (`0x2AD6`): sint16 min, sint16 max, +/// uint16 increment. +/// +/// The spec assigns these a resolution of 0.1, but resistance level is a +/// trainer-specific unit and the D100 reference works in raw integers capped at +/// 100. We therefore keep the **raw** values as authoritative for clamping — +/// they are in the same units as [`ControlTarget::Resistance`] and +/// [`crate::control_point::set_target_resistance`] — and expose the 0.1-scaled +/// interpretation separately for display. **Needs hardware verification** +/// (TASK-1) to know which the D100 actually means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResistanceLevelRange { + pub min: i16, + pub max: i16, + pub increment: u16, +} + +impl ResistanceLevelRange { + pub fn decode(data: &[u8]) -> Result { + let (min, max, increment) = decode_range("Supported Resistance Level Range", data)?; + Ok(Self { + min, + max, + increment, + }) + } + + /// The spec-scaled interpretation (0.1 units), for display only. + pub fn scaled(&self) -> (f32, f32, f32) { + ( + self.min as f32 * 0.1, + self.max as f32 * 0.1, + self.increment as f32 * 0.1, + ) + } +} + +/// Supported Power Range (`0x2AD8`): sint16 min W, sint16 max W, uint16 +/// increment W. Resolution 1 W. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PowerRange { + pub min_w: i16, + pub max_w: i16, + pub increment_w: u16, +} + +impl PowerRange { + pub fn decode(data: &[u8]) -> Result { + let (min_w, max_w, increment_w) = decode_range("Supported Power Range", data)?; + Ok(Self { + min_w, + max_w, + increment_w, + }) + } +} + +/// Supported Inclination Range (`0x2AD5`): sint16 min, sint16 max, uint16 +/// increment, all with 0.1% resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InclinationRange { + raw_min: i16, + raw_max: i16, + raw_increment: u16, +} + +impl InclinationRange { + pub fn decode(data: &[u8]) -> Result { + let (raw_min, raw_max, raw_increment) = + decode_range("Supported Inclination Range", data)?; + Ok(Self { + raw_min, + raw_max, + raw_increment, + }) + } + + pub fn min_percent(&self) -> f32 { + self.raw_min as f32 * 0.1 + } + + pub fn max_percent(&self) -> f32 { + self.raw_max as f32 * 0.1 + } + + pub fn increment_percent(&self) -> f32 { + self.raw_increment as f32 * 0.1 + } +} + +fn decode_range(what: &'static str, data: &[u8]) -> Result<(i16, i16, u16), FieldTooShort> { + if data.len() < 6 { + return Err(FieldTooShort { + what, + len: data.len(), + need: 6, + }); + } + Ok(( + i16::from_le_bytes([data[0], data[1]]), + i16::from_le_bytes([data[2], data[3]]), + u16::from_le_bytes([data[4], data[5]]), + )) +} + +/// Everything the trainer told us about itself. Any field may be `None` if the +/// corresponding characteristic is absent or unreadable — a trainer is not +/// required to expose the optional range characteristics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TrainerCapabilities { + pub feature: Option, + pub resistance_range: Option, + pub power_range: Option, + pub inclination_range: Option, +} + +impl TrainerCapabilities { + /// Whether the op code used for a given target is advertised. + /// + /// If the Fitness Machine Feature characteristic could not be read we + /// return `true` — refusing to control a trainer that simply did not expose + /// `0x2ACC` would be worse than trying and reading the error response, and + /// the response indication (FR-2.7) is the real backstop. + pub fn supports(&self, target: &ControlTarget, use_simulation: bool) -> bool { + let Some(f) = self.feature else { + return true; + }; + match target { + ControlTarget::Gradient { .. } => { + if use_simulation { + f.supports_simulation() + } else { + f.supports_inclination_target() + } + } + ControlTarget::Resistance { .. } => f.supports_resistance_target(), + ControlTarget::Power { .. } => f.supports_power_target(), + } + } + + /// The op code that would carry this target. + pub fn op_code_for(target: &ControlTarget, use_simulation: bool) -> OpCode { + match target { + ControlTarget::Gradient { .. } if use_simulation => { + OpCode::SetIndoorBikeSimulationParameters + } + ControlTarget::Gradient { .. } => OpCode::SetTargetInclination, + ControlTarget::Resistance { .. } => OpCode::SetTargetResistanceLevel, + ControlTarget::Power { .. } => OpCode::SetTargetPower, + } + } +} + +/// Clamp `target` first to the configured [`SafetyLimits`] (SAF-3) and then to +/// the range the trainer itself reported (FR-2.6, SAF-3), and reject it +/// outright if the op code is not advertised. +/// +/// This is the **only** function the client uses to prepare a target for +/// transmission. Everything else — profiles, waveforms, the D-pad, the UI — +/// funnels through here, which is what makes "clamp at the point of +/// transmission" true rather than aspirational. +pub fn gate_target( + limits: &SafetyLimits, + caps: &TrainerCapabilities, + target: ControlTarget, + use_simulation: bool, +) -> Result { + if !caps.supports(&target, use_simulation) { + return Err(UnsupportedTarget::OpCodeUnsupported( + TrainerCapabilities::op_code_for(&target, use_simulation), + )); + } + + // 1. Configured safety limits. + let target = limits.clamp(target); + + // 2. The trainer's own reported range, where it gave us one. + let target = match target { + ControlTarget::Gradient { percent } => { + if let Some(r) = caps.inclination_range { + let (lo, hi) = (r.min_percent(), r.max_percent()); + if lo > hi { + return Err(UnsupportedTarget::EmptyRange { + what: "inclination", + min: r.raw_min as i32, + max: r.raw_max as i32, + }); + } + ControlTarget::Gradient { + percent: percent.clamp(lo, hi), + } + } else { + ControlTarget::Gradient { percent } + } + } + ControlTarget::Resistance { level } => { + if let Some(r) = caps.resistance_range { + if r.min > r.max { + return Err(UnsupportedTarget::EmptyRange { + what: "resistance level", + min: r.min as i32, + max: r.max as i32, + }); + } + ControlTarget::Resistance { + level: level.clamp(r.min, r.max), + } + } else { + ControlTarget::Resistance { level } + } + } + ControlTarget::Power { watts } => { + if let Some(r) = caps.power_range { + if r.min_w > r.max_w { + return Err(UnsupportedTarget::EmptyRange { + what: "power", + min: r.min_w as i32, + max: r.max_w as i32, + }); + } + // ControlTarget::Power is u16; the range is sint16. Negative + // minima are meaningless for a trainer, so floor at zero. + let lo = r.min_w.max(0) as u16; + let hi = r.max_w.max(0) as u16; + ControlTarget::Power { + watts: watts.clamp(lo, hi), + } + } else { + ControlTarget::Power { watts } + } + } + }; + + Ok(target) +} + +/// Encode a gated target into control point bytes. +/// +/// `use_simulation` selects `0x11` over `0x03` for gradient; `sim_template` +/// supplies the rolling/aero coefficients that accompany the grade. +pub fn encode_target( + target: ControlTarget, + use_simulation: bool, + sim_template: crate::control_point::SimulationParameters, +) -> (OpCode, Vec) { + use crate::control_point as cp; + match target { + ControlTarget::Gradient { percent } if use_simulation => ( + OpCode::SetIndoorBikeSimulationParameters, + cp::set_simulation_parameters(cp::SimulationParameters { + grade_percent: percent, + ..sim_template + }), + ), + ControlTarget::Gradient { percent } => ( + OpCode::SetTargetInclination, + cp::set_target_inclination(percent), + ), + ControlTarget::Resistance { level } => ( + OpCode::SetTargetResistanceLevel, + cp::set_target_resistance(level), + ), + ControlTarget::Power { watts } => ( + OpCode::SetTargetPower, + // watts is u16 but the wire format is sint16; the gate has already + // clamped it to the trainer's range, and `min(i16::MAX)` keeps a + // pathological value from wrapping negative. + cp::set_target_power(watts.min(i16::MAX as u16) as i16), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feature(machine: u32, target: u32) -> FitnessMachineFeature { + FitnessMachineFeature { machine, target } + } + + #[test] + fn feature_decodes_two_little_endian_u32s() { + // machine = 0x00000086, target = 0x0000200C + let bytes = [0x86, 0x00, 0x00, 0x00, 0x0c, 0x20, 0x00, 0x00]; + let f = FitnessMachineFeature::decode(&bytes).unwrap(); + assert_eq!(f.machine, 0x0000_0086); + assert_eq!(f.target, 0x0000_200C); + assert!(f.has_machine(machine_feature::CADENCE)); + assert!(f.has_machine(machine_feature::RESISTANCE_LEVEL)); + assert!(!f.has_machine(machine_feature::AVERAGE_SPEED)); + assert!(f.supports_resistance_target()); + assert!(f.supports_power_target()); + assert!(f.supports_simulation()); + assert!(!f.supports_inclination_target()); + } + + #[test] + fn feature_decode_rejects_short_data() { + assert_eq!( + FitnessMachineFeature::decode(&[0u8; 7]).unwrap_err(), + FieldTooShort { + what: "Fitness Machine Feature", + len: 7, + need: 8 + } + ); + } + + #[test] + fn feature_extra_trailing_bytes_are_tolerated() { + let f = FitnessMachineFeature::decode(&[0x02, 0, 0, 0, 0x04, 0, 0, 0, 0xff]).unwrap(); + assert_eq!(f.machine, 2); + assert_eq!(f.target, 4); + } + + #[test] + fn feature_name_lists() { + let f = feature( + machine_feature::CADENCE | machine_feature::POWER_MEASUREMENT, + target_feature::RESISTANCE | target_feature::INDOOR_BIKE_SIMULATION, + ); + assert_eq!( + f.machine_feature_names(), + vec!["Cadence", "Power Measurement"] + ); + assert_eq!( + f.target_feature_names(), + vec![ + "Resistance Target Setting (0x04)", + "Indoor Bike Simulation Parameters (0x11)" + ] + ); + } + + #[test] + fn resistance_range_decodes() { + // min 0, max 100, increment 1 + let r = ResistanceLevelRange::decode(&[0x00, 0x00, 0x64, 0x00, 0x01, 0x00]).unwrap(); + assert_eq!( + r, + ResistanceLevelRange { + min: 0, + max: 100, + increment: 1 + } + ); + let (lo, hi, inc) = r.scaled(); + assert_eq!((lo, hi, inc), (0.0, 10.0, 0.1)); + } + + #[test] + fn resistance_range_handles_negative_minimum() { + let r = ResistanceLevelRange::decode(&[0xf6, 0xff, 0x64, 0x00, 0x02, 0x00]).unwrap(); + assert_eq!(r.min, -10); + assert_eq!(r.max, 100); + assert_eq!(r.increment, 2); + } + + #[test] + fn power_and_inclination_ranges_decode() { + let p = PowerRange::decode(&[0x32, 0x00, 0x58, 0x02, 0x01, 0x00]).unwrap(); + assert_eq!(p.min_w, 50); + assert_eq!(p.max_w, 600); + assert_eq!(p.increment_w, 1); + + // -10.0% .. +20.0%, 0.5% increment + let i = InclinationRange::decode(&[0x9c, 0xff, 0xc8, 0x00, 0x05, 0x00]).unwrap(); + assert_eq!(i.min_percent(), -10.0); + assert_eq!(i.max_percent(), 20.0); + assert_eq!(i.increment_percent(), 0.5); + } + + #[test] + fn range_decode_rejects_short_data() { + assert!(ResistanceLevelRange::decode(&[0, 0, 0, 0, 0]).is_err()); + assert!(PowerRange::decode(&[]).is_err()); + assert!(InclinationRange::decode(&[1, 2, 3]).is_err()); + } + + // -- gate_target ------------------------------------------------------- + + fn caps_all() -> TrainerCapabilities { + TrainerCapabilities { + feature: Some(feature( + 0, + target_feature::INCLINATION + | target_feature::RESISTANCE + | target_feature::POWER + | target_feature::INDOOR_BIKE_SIMULATION, + )), + resistance_range: Some(ResistanceLevelRange { + min: 0, + max: 100, + increment: 1, + }), + power_range: Some(PowerRange { + min_w: 50, + max_w: 600, + increment_w: 1, + }), + inclination_range: Some(InclinationRange { + raw_min: -100, + raw_max: 200, + raw_increment: 5, + }), + } + } + + #[test] + fn gate_passes_an_in_range_target_unchanged() { + let l = SafetyLimits::default(); + let c = caps_all(); + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap(), + ControlTarget::Power { watts: 200 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: 3.5 }, false).unwrap(), + ControlTarget::Gradient { percent: 3.5 } + ); + } + + #[test] + fn gate_clamps_to_safety_limits() { + let l = SafetyLimits::default(); // -10..15 %, 0..100 res, 50..600 W + let c = TrainerCapabilities::default(); // trainer told us nothing + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: 99.0 }, false).unwrap(), + ControlTarget::Gradient { percent: 15.0 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: -99.0 }, false).unwrap(), + ControlTarget::Gradient { percent: -10.0 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 5000 }, false).unwrap(), + ControlTarget::Power { watts: 600 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Resistance { level: 500 }, false).unwrap(), + ControlTarget::Resistance { level: 100 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Resistance { level: -500 }, false).unwrap(), + ControlTarget::Resistance { level: 0 } + ); + } + + /// The trainer's reported range is *narrower* than the safety limits, so it + /// must win. This is FR-2.6. + #[test] + fn gate_clamps_to_the_trainers_narrower_range() { + let l = SafetyLimits { + min_gradient_pct: -25.0, + max_gradient_pct: 25.0, + min_resistance: -200, + max_resistance: 200, + min_power_w: 0, + max_power_w: 2000, + }; + let c = caps_all(); // incl -10..20 %, res 0..100, power 50..600 W + + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: 24.0 }, false).unwrap(), + ControlTarget::Gradient { percent: 20.0 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Resistance { level: 150 }, false).unwrap(), + ControlTarget::Resistance { level: 100 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 1500 }, false).unwrap(), + ControlTarget::Power { watts: 600 } + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 10 }, false).unwrap(), + ControlTarget::Power { watts: 50 } + ); + } + + #[test] + fn gate_rejects_unadvertised_op_codes() { + let l = SafetyLimits::default(); + let c = TrainerCapabilities { + feature: Some(feature(0, target_feature::RESISTANCE)), + ..Default::default() + }; + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 200 }, false).unwrap_err(), + UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetPower) + ); + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, false).unwrap_err(), + UnsupportedTarget::OpCodeUnsupported(OpCode::SetTargetInclination) + ); + assert!(gate_target(&l, &c, ControlTarget::Resistance { level: 5 }, false).is_ok()); + } + + /// A-1 in gate form: if the trainer does not advertise bit 13, asking for + /// gradient in simulation mode is refused before anything is transmitted. + #[test] + fn gate_rejects_simulation_mode_when_unadvertised() { + let l = SafetyLimits::default(); + let c = TrainerCapabilities { + feature: Some(feature(0, target_feature::INCLINATION)), + ..Default::default() + }; + assert_eq!( + gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, true).unwrap_err(), + UnsupportedTarget::OpCodeUnsupported(OpCode::SetIndoorBikeSimulationParameters) + ); + // ...but plain inclination is fine. + assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 2.0 }, false).is_ok()); + } + + #[test] + fn gate_allows_everything_when_the_feature_characteristic_is_missing() { + let l = SafetyLimits::default(); + let c = TrainerCapabilities::default(); + assert!(gate_target(&l, &c, ControlTarget::Power { watts: 100 }, false).is_ok()); + assert!(gate_target(&l, &c, ControlTarget::Gradient { percent: 1.0 }, true).is_ok()); + } + + #[test] + fn gate_rejects_an_inverted_range() { + let l = SafetyLimits::default(); + let c = TrainerCapabilities { + resistance_range: Some(ResistanceLevelRange { + min: 50, + max: 10, + increment: 1, + }), + ..Default::default() + }; + assert_eq!( + gate_target(&l, &c, ControlTarget::Resistance { level: 20 }, false).unwrap_err(), + UnsupportedTarget::EmptyRange { + what: "resistance level", + min: 50, + max: 10 + } + ); + } + + #[test] + fn gate_handles_a_negative_power_minimum() { + let l = SafetyLimits { + min_power_w: 0, + ..SafetyLimits::default() + }; + let c = TrainerCapabilities { + power_range: Some(PowerRange { + min_w: -100, + max_w: 400, + increment_w: 1, + }), + ..Default::default() + }; + assert_eq!( + gate_target(&l, &c, ControlTarget::Power { watts: 0 }, false).unwrap(), + ControlTarget::Power { watts: 0 } + ); + } + + // -- encode_target ----------------------------------------------------- + + #[test] + fn encode_target_picks_the_right_op_code_and_bytes() { + let sim = crate::control_point::SimulationParameters::default(); + assert_eq!( + encode_target(ControlTarget::Gradient { percent: 5.0 }, false, sim), + (OpCode::SetTargetInclination, vec![0x03, 0x32, 0x00]) + ); + assert_eq!( + encode_target(ControlTarget::Resistance { level: 40 }, false, sim), + (OpCode::SetTargetResistanceLevel, vec![0x04, 0x28, 0x00]) + ); + assert_eq!( + encode_target(ControlTarget::Power { watts: 250 }, false, sim), + (OpCode::SetTargetPower, vec![0x05, 0xfa, 0x00]) + ); + } + + #[test] + fn encode_target_in_simulation_mode_carries_the_grade() { + let sim = crate::control_point::SimulationParameters { + wind_speed_mps: 0.0, + grade_percent: 999.0, // must be overridden by the target + crr: 0.004, + wind_resistance_coefficient: 0.51, + }; + let (op, bytes) = encode_target(ControlTarget::Gradient { percent: 4.5 }, true, sim); + assert_eq!(op, OpCode::SetIndoorBikeSimulationParameters); + assert_eq!(bytes, vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33]); + } + + #[test] + fn encode_target_power_never_wraps_negative() { + let sim = crate::control_point::SimulationParameters::default(); + let (_, bytes) = encode_target(ControlTarget::Power { watts: 60000 }, false, sim); + let raw = i16::from_le_bytes([bytes[1], bytes[2]]); + assert!(raw > 0, "power must not wrap to a negative sint16"); + assert_eq!(raw, i16::MAX); + } +} diff --git a/crates/ble/src/client.rs b/crates/ble/src/client.rs new file mode 100644 index 0000000..a700a7c --- /dev/null +++ b/crates/ble/src/client.rs @@ -0,0 +1,1632 @@ +//! The FTMS trainer client: an async actor that owns the BLE peripheral. +//! +//! Everything device-facing happens in one task. Callers hold an [`FtmsClient`] +//! handle, which is cheap to clone-by-reference and drives the actor over a +//! channel. That structure is what makes the safety requirements enforceable: +//! there is exactly one place that writes to the control point, so the rate +//! limit (FR-2.8), the clamp (SAF-3), the acknowledgement tracking (FR-2.7, +//! SAF-4) and the shutdown reset (SAF-2) cannot be bypassed. +//! +//! ```text +//! caller ──set_target()──► [cmd channel] ──► actor ──write──► trainer +//! caller ◄──broadcast────── telemetry ◄──── actor ◄─notify──── trainer +//! ``` +//! +//! The FTMS connection sequence and the "request control before anything else" +//! rule follow `obostjancic/smart-trainer-control` (`src/lib/bike/bluetooth-bike.ts`), +//! MIT licensed, Copyright (c) 2025 Ogi. See REQUIREMENTS.md §3.2. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry}; +use btleplug::api::{Characteristic, Peripheral as _, ValueNotification, WriteType}; +use btleplug::platform::{Adapter, Peripheral}; +use futures::StreamExt; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; +use uuid::Uuid; + +use crate::capabilities::{ + self, FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, + TrainerCapabilities, +}; +use crate::control_point::{self as cp, OpCode, ResultCode, SimulationParameters, StopOrPause}; +use crate::error::FtmsError; +use crate::indoor_bike_data::{self, hex}; +use crate::scan::{self, TrainerSelector}; +use crate::uuids; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Exponential backoff for auto-reconnect (FR-1.6). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Backoff { + pub initial: Duration, + pub max: Duration, + pub multiplier: f64, + /// `None` means retry forever — the right default for a ride in progress, + /// since the rider may simply have stopped pedalling and let the trainer + /// sleep (A-4). + pub max_attempts: Option, +} + +impl Default for Backoff { + fn default() -> Self { + Self { + initial: Duration::from_millis(500), + max: Duration::from_secs(30), + multiplier: 2.0, + max_attempts: None, + } + } +} + +impl Backoff { + /// Delay before attempt number `attempt` (0-based). Pure and total: no + /// overflow, no panic, saturating at [`Backoff::max`]. + pub fn delay(&self, attempt: u32) -> Duration { + if attempt == 0 { + return self.initial.min(self.max); + } + let factor = self.multiplier.max(1.0).powi(attempt.min(32) as i32); + let millis = self.initial.as_secs_f64() * 1000.0 * factor; + if !millis.is_finite() || millis >= self.max.as_millis() as f64 { + self.max + } else { + Duration::from_millis(millis as u64) + } + } + + pub fn exhausted(&self, attempts: u32) -> bool { + self.max_attempts.is_some_and(|m| attempts >= m) + } +} + +/// Tunables for [`FtmsClient`]. +#[derive(Debug, Clone)] +pub struct FtmsConfig { + /// Applied at the point of transmission, on every path (SAF-3). + pub limits: SafetyLimits, + /// Minimum gap between control point writes. 250 ms == 4 Hz (FR-2.8). + pub min_write_interval: Duration, + /// How long to wait for a control point indication before treating the + /// write as unacknowledged (FR-2.7). + pub ack_timeout: Duration, + /// How long to scan for the trainer before giving up. + pub scan_timeout: Duration, + pub backoff: Backoff, + /// Prefer `SetIndoorBikeSimulationParameters` (`0x11`) over + /// `SetTargetInclination` (`0x03`) for gradient. Defaults to `false` + /// because A-1 is unresolved and the reference implementation uses `0x03`. + pub use_simulation_mode: bool, + /// Rolling/aero coefficients accompanying the grade in simulation mode. + pub simulation_template: SimulationParameters, + /// Consecutive unacknowledged writes before the control path halts (SAF-4). + pub max_consecutive_failures: u32, + /// Capacity of the telemetry and event broadcast channels. + pub channel_capacity: usize, + /// Issue `StartOrResume` (`0x07`) once control is acquired. + pub start_on_connect: bool, + /// Ignore the Target Setting Features bitfield when deciding whether a + /// target may be sent. + /// + /// **For protocol discovery only.** `probe set` uses it to write `0x11` + /// even when the trainer does not advertise bit 13, which is the only way + /// to answer open question A-1: advertised support and actual support are + /// not the same thing. Leave `false` in the app — FR-2.6 exists so the + /// rider never sees an unexplained silent failure. + pub ignore_advertised_features: bool, +} + +impl Default for FtmsConfig { + fn default() -> Self { + Self { + limits: SafetyLimits::default(), + min_write_interval: Duration::from_millis(250), + ack_timeout: Duration::from_secs(2), + scan_timeout: Duration::from_secs(15), + backoff: Backoff::default(), + use_simulation_mode: false, + simulation_template: SimulationParameters::default(), + max_consecutive_failures: 5, + channel_capacity: 256, + start_on_connect: true, + ignore_advertised_features: false, + } + } +} + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +/// What happened to a [`FtmsClient::set_target`] call. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ControlOutcome { + /// The trainer acknowledged the write with `Success`. `sent` is the value + /// after clamping, which may differ from what was asked for. + Acknowledged { sent: ControlTarget }, + /// A newer target arrived before this one was transmitted, so it was + /// dropped by the rate limiter (FR-2.8). Not an error: for a streaming + /// target, the newest value is the only one that matters. + Superseded, +} + +/// A one-shot FTMS procedure that is not a target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Procedure { + RequestControl, + Reset, + Start, + Stop, + Pause, +} + +impl Procedure { + fn encode(self) -> (OpCode, Vec) { + match self { + Procedure::RequestControl => (OpCode::RequestControl, cp::request_control()), + Procedure::Reset => (OpCode::Reset, cp::reset()), + Procedure::Start => (OpCode::StartOrResume, cp::start_or_resume()), + Procedure::Stop => (OpCode::StopOrPause, cp::stop_or_pause(StopOrPause::Stop)), + Procedure::Pause => (OpCode::StopOrPause, cp::stop_or_pause(StopOrPause::Pause)), + } + } +} + +/// Everything the client reports. Telemetry is also available on its own +/// dedicated channel; it is duplicated here so a diagnostic consumer (the probe, +/// a debug log) can see one ordered stream (NFR-8). +#[derive(Debug, Clone, PartialEq)] +pub enum FtmsEvent { + State(ConnectionState), + Telemetry(Telemetry), + Capabilities(TrainerCapabilities), + /// A control point indication was received. + ControlResponse { op: Option, result: ResultCode }, + /// Fitness Machine Status (`0x2ADA`) notification, raw. + MachineStatus { raw: String }, + /// A packet arrived that could not be decoded. Reported, never fatal + /// (NFR-4). + DecodeFailure { + characteristic: Uuid, + raw: String, + error: String, + }, + /// SAF-4: the trainer stopped acknowledging writes. The control path is + /// halted and the rider must be told. + ControlFault { reason: String }, +} + +/// Handle to a connected trainer. +/// +/// Dropping the handle closes the command channel, which makes the actor run +/// the SAF-2 shutdown sequence (zero gradient, minimum resistance, `Reset`) and +/// disconnect. Prefer [`FtmsClient::shutdown`], which waits for that sequence to +/// finish — a drop cannot, and a runtime torn down immediately afterwards will +/// not give the actor a chance to run. +#[derive(Debug)] +pub struct FtmsClient { + cmd_tx: mpsc::Sender, + state_rx: watch::Receiver, + caps_rx: watch::Receiver, + telemetry_tx: broadcast::Sender, + events_tx: broadcast::Sender, + address: String, + name: Option, +} + +impl FtmsClient { + /// Scan for, connect to and take control of a trainer. + /// + /// Returns only once the trainer has acknowledged `RequestControl`, so a + /// successful return means "controllable", not merely "connected" + /// (FR-9.3). + pub async fn connect( + selector: TrainerSelector, + config: FtmsConfig, + ) -> Result { + let adapter = scan::default_adapter().await?; + Self::connect_with_adapter(adapter, selector, config).await + } + + /// As [`FtmsClient::connect`], but on a caller-supplied adapter. + pub async fn connect_with_adapter( + adapter: Adapter, + selector: TrainerSelector, + config: FtmsConfig, + ) -> Result { + let (state_tx, state_rx) = watch::channel(ConnectionState::Scanning); + let (caps_tx, caps_rx) = watch::channel(TrainerCapabilities::default()); + let (telemetry_tx, _) = broadcast::channel(config.channel_capacity); + let (events_tx, _) = broadcast::channel(config.channel_capacity); + let (cmd_tx, cmd_rx) = mpsc::channel(64); + + let connected = connect_session(&adapter, &selector, &config, &state_tx).await?; + + let address = connected.address.clone(); + let name = connected.name.clone(); + let _ = caps_tx.send(connected.capabilities); + let _ = events_tx.send(FtmsEvent::Capabilities(connected.capabilities)); + set_state(&state_tx, &events_tx, ConnectionState::Controlling); + + let actor = Actor { + adapter, + selector, + config, + session: Some(connected.session), + capabilities: connected.capabilities, + state_tx, + caps_tx, + telemetry_tx: telemetry_tx.clone(), + events_tx: events_tx.clone(), + pending: None, + queued_target: None, + queued_procedures: VecDeque::new(), + last_write: None, + consecutive_failures: 0, + halted: false, + ride_start: Instant::now(), + last_health_check: Instant::now(), + }; + + tokio::spawn(actor.run(cmd_rx, Some(connected.notifications))); + + Ok(Self { + cmd_tx, + state_rx, + caps_rx, + telemetry_tx, + events_tx, + address, + name, + }) + } + + /// The trainer's address. + pub fn address(&self) -> &str { + &self.address + } + + /// The trainer's advertised name, if it had one. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Current connection state (FR-1.7). + pub fn state(&self) -> ConnectionState { + self.state_rx.borrow().clone() + } + + /// Watch connection state changes. + pub fn state_stream(&self) -> watch::Receiver { + self.state_rx.clone() + } + + /// What the trainer reported it supports (FR-2.6). + pub fn capabilities(&self) -> TrainerCapabilities { + *self.caps_rx.borrow() + } + + pub fn capabilities_stream(&self) -> watch::Receiver { + self.caps_rx.clone() + } + + /// Subscribe to decoded telemetry (FR-2.2). + /// + /// This is a broadcast channel: a slow consumer lags rather than blocking + /// the BLE task (NFR-2). + pub fn telemetry(&self) -> broadcast::Receiver { + self.telemetry_tx.subscribe() + } + + /// Subscribe to the full diagnostic event stream (NFR-8). + pub fn events(&self) -> broadcast::Receiver { + self.events_tx.subscribe() + } + + /// Send a control target. + /// + /// The value is clamped to the configured safety limits *and* to the range + /// the trainer reported (SAF-3, FR-2.6), rate-limited to + /// [`FtmsConfig::min_write_interval`] (FR-2.8), and the returned future + /// resolves when the trainer acknowledges it or the write is superseded by + /// a newer target (FR-2.7). + pub async fn set_target(&self, target: ControlTarget) -> Result { + let (reply, rx) = oneshot::channel(); + self.cmd_tx + .send(Command::SetTarget { target, reply }) + .await + .map_err(|_| FtmsError::ClientShutDown)?; + rx.await.map_err(|_| FtmsError::ClientShutDown)? + } + + /// Re-acquire control (`0x00`). Rarely needed — [`FtmsClient::connect`] + /// already does it, and so does every reconnect. + pub async fn request_control(&self) -> Result<(), FtmsError> { + self.procedure(Procedure::RequestControl).await + } + + /// `StartOrResume` (`0x07`). + pub async fn start(&self) -> Result<(), FtmsError> { + self.procedure(Procedure::Start).await + } + + /// `StopOrPause` (`0x08`) with the Pause parameter. + pub async fn pause(&self) -> Result<(), FtmsError> { + self.procedure(Procedure::Pause).await + } + + /// `StopOrPause` (`0x08`) with the Stop parameter. + pub async fn stop(&self) -> Result<(), FtmsError> { + self.procedure(Procedure::Stop).await + } + + /// `Reset` (`0x01`) — returns the machine to its defaults. + pub async fn reset(&self) -> Result<(), FtmsError> { + self.procedure(Procedure::Reset).await + } + + pub async fn procedure(&self, kind: Procedure) -> Result<(), FtmsError> { + let (reply, rx) = oneshot::channel(); + self.cmd_tx + .send(Command::Procedure { kind, reply }) + .await + .map_err(|_| FtmsError::ClientShutDown)?; + rx.await.map_err(|_| FtmsError::ClientShutDown)? + } + + /// Run the SAF-2 shutdown sequence and disconnect, waiting for it to + /// complete. + /// + /// Always prefer this to simply dropping the client at the end of a ride or + /// on application exit: it is the difference between "the trainer is left + /// at zero" and "probably". + pub async fn shutdown(self) -> Result<(), FtmsError> { + let (reply, rx) = oneshot::channel(); + if self + .cmd_tx + .send(Command::Shutdown { reply }) + .await + .is_err() + { + // The actor is already gone; it ran its cleanup on the way out. + return Ok(()); + } + let _ = rx.await; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Actor +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum Command { + SetTarget { + target: ControlTarget, + reply: oneshot::Sender>, + }, + Procedure { + kind: Procedure, + reply: oneshot::Sender>, + }, + Shutdown { + reply: oneshot::Sender<()>, + }, +} + +/// The characteristics we need on a connected peripheral. +struct Session { + peripheral: Peripheral, + control_point: Characteristic, + #[allow(dead_code)] + indoor_bike_data: Characteristic, +} + +struct ConnectedTrainer { + session: Session, + capabilities: TrainerCapabilities, + notifications: mpsc::Receiver, + address: String, + name: Option, +} + +/// A control point write awaiting its indication. +struct Pending { + op: OpCode, + deadline: Instant, + reply: Reply, +} + +enum Reply { + Target { + sent: ControlTarget, + tx: oneshot::Sender>, + }, + Unit(oneshot::Sender>), +} + +struct Actor { + adapter: Adapter, + selector: TrainerSelector, + config: FtmsConfig, + session: Option, + capabilities: TrainerCapabilities, + state_tx: watch::Sender, + caps_tx: watch::Sender, + telemetry_tx: broadcast::Sender, + events_tx: broadcast::Sender, + pending: Option, + queued_target: Option<( + ControlTarget, + oneshot::Sender>, + )>, + queued_procedures: VecDeque<(Procedure, oneshot::Sender>)>, + last_write: Option, + consecutive_failures: u32, + halted: bool, + ride_start: Instant, + last_health_check: Instant, +} + +const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3); + +impl Actor { + async fn run( + mut self, + mut cmd_rx: mpsc::Receiver, + mut notifications: Option>, + ) { + let mut shutdown_reply: Option> = None; + + 'outer: loop { + if self.session.is_none() { + match self.reconnect(&mut cmd_rx).await { + Reconnected::Ok(rx) => { + notifications = Some(rx); + continue; + } + Reconnected::Shutdown(reply) => { + shutdown_reply = reply; + break 'outer; + } + Reconnected::GaveUp => break 'outer, + } + } + + let delay = self.next_delay(); + tokio::select! { + // Telemetry first: NFR-2 requires notifications to be drained at + // the trainer's native rate without backlog, and commands are + // rate-limited to 4 Hz anyway so they can afford to wait. + biased; + + notification = next_notification(&mut notifications) => match notification { + Some(n) => self.handle_notification(n), + None => { + self.on_link_lost("notification stream ended"); + notifications = None; + continue; + } + }, + + cmd = cmd_rx.recv() => match cmd { + Some(Command::Shutdown { reply }) => { + shutdown_reply = Some(reply); + break 'outer; + } + Some(cmd) => self.handle_command(cmd), + // Every handle dropped: run the safety sequence anyway. + None => break 'outer, + }, + + _ = tokio::time::sleep(delay) => {} + } + + self.service().await; + } + + self.safety_shutdown().await; + self.fail_all(|| FtmsError::ClientShutDown); + if let Some(reply) = shutdown_reply { + let _ = reply.send(()); + } + tracing::info!("FTMS client stopped"); + } + + fn elapsed_ms(&self) -> u64 { + self.ride_start.elapsed().as_millis() as u64 + } + + fn set_state(&self, state: ConnectionState) { + set_state(&self.state_tx, &self.events_tx, state); + } + + // -- inbound ---------------------------------------------------------- + + fn handle_notification(&mut self, n: ValueNotification) { + if n.uuid == uuids::INDOOR_BIKE_DATA { + tracing::trace!(raw = %hex(&n.value), "0x2AD2 indoor bike data"); + match indoor_bike_data::decode(&n.value) { + Ok(data) => { + if data.consumed < n.value.len() { + tracing::debug!( + raw = %hex(&n.value), + consumed = data.consumed, + "indoor bike data had trailing bytes we do not understand" + ); + } + let telemetry = data.to_telemetry(self.elapsed_ms()); + let _ = self.telemetry_tx.send(telemetry); + let _ = self.events_tx.send(FtmsEvent::Telemetry(telemetry)); + } + Err(e) => { + tracing::warn!(raw = %hex(&n.value), error = %e, "malformed indoor bike data"); + let _ = self.events_tx.send(FtmsEvent::DecodeFailure { + characteristic: n.uuid, + raw: hex(&n.value), + error: e.to_string(), + }); + } + } + } else if n.uuid == uuids::FITNESS_MACHINE_CONTROL_POINT { + tracing::debug!(raw = %hex(&n.value), "0x2AD9 control point indication"); + match cp::decode_response(&n.value) { + Ok(resp) => { + let _ = self.events_tx.send(FtmsEvent::ControlResponse { + op: resp.request_op_code, + result: resp.result, + }); + self.resolve_indication(resp.raw_request_op_code, resp.result); + } + Err(e) => { + tracing::warn!(raw = %hex(&n.value), error = %e, "malformed control point indication"); + let _ = self.events_tx.send(FtmsEvent::DecodeFailure { + characteristic: n.uuid, + raw: hex(&n.value), + error: e.to_string(), + }); + } + } + } else if n.uuid == uuids::FITNESS_MACHINE_STATUS { + tracing::debug!(raw = %hex(&n.value), "0x2ADA fitness machine status"); + let _ = self.events_tx.send(FtmsEvent::MachineStatus { + raw: hex(&n.value), + }); + } else { + tracing::trace!(uuid = %n.uuid, raw = %hex(&n.value), "notification on an unexpected characteristic"); + } + } + + fn resolve_indication(&mut self, raw_op: u8, result: ResultCode) { + let Some(pending) = self.pending.take() else { + tracing::debug!( + op = format_args!("0x{raw_op:02x}"), + %result, + "control point indication with nothing in flight" + ); + return; + }; + + if raw_op != pending.op.as_u8() { + // Put it back: the answer we are waiting for may still arrive. + tracing::warn!( + expected = format_args!("0x{:02x}", pending.op.as_u8()), + got = format_args!("0x{raw_op:02x}"), + "control point answered a different op code" + ); + self.pending = Some(pending); + return; + } + + if result.is_success() { + self.consecutive_failures = 0; + complete(pending.reply, Ok(())); + } else { + // An explicit rejection is an answer, not silence: it does not count + // towards the SAF-4 "trainer stopped acknowledging" fault. + self.consecutive_failures = 0; + let op = pending.op; + tracing::warn!(%op, %result, "trainer rejected a control point write"); + complete(pending.reply, Err(FtmsError::Rejected { op, result })); + } + } + + fn on_link_lost(&mut self, reason: &str) { + tracing::warn!(reason, "trainer link lost"); + self.session = None; + if let Some(pending) = self.pending.take() { + complete(pending.reply, Err(FtmsError::NotConnected)); + } + self.set_state(ConnectionState::Lost { + reason: reason.to_string(), + }); + } + + // -- outbound --------------------------------------------------------- + + fn handle_command(&mut self, cmd: Command) { + match cmd { + Command::SetTarget { target, reply } => { + if self.halted { + let _ = reply.send(Err(FtmsError::ControlHalted { + failures: self.consecutive_failures, + })); + return; + } + // Coalesce: the newest target wins, the old one is superseded. + if let Some((_, old)) = self.queued_target.replace((target, reply)) { + let _ = old.send(Ok(ControlOutcome::Superseded)); + } + } + Command::Procedure { kind, reply } => { + if self.halted { + let _ = reply.send(Err(FtmsError::ControlHalted { + failures: self.consecutive_failures, + })); + return; + } + self.queued_procedures.push_back((kind, reply)); + } + Command::Shutdown { .. } => unreachable!("handled in run()"), + } + } + + /// How long we may sleep before something needs doing. + fn next_delay(&self) -> Duration { + let now = Instant::now(); + let mut delay = HEALTH_CHECK_INTERVAL; + + if let Some(p) = &self.pending { + delay = delay.min(p.deadline.saturating_duration_since(now)); + } else if !self.halted && self.has_work() { + delay = delay.min(time_until_write_allowed( + self.last_write, + now, + self.config.min_write_interval, + )); + } + + // Never spin: a zero delay would busy-loop the select. + delay.max(Duration::from_millis(1)) + } + + fn has_work(&self) -> bool { + self.queued_target.is_some() || !self.queued_procedures.is_empty() + } + + /// Timeouts, health checks and the next rate-limited write. + async fn service(&mut self) { + let now = Instant::now(); + + // 1. Acknowledgement timeout (FR-2.7). + if let Some(p) = &self.pending { + if now >= p.deadline { + let pending = self.pending.take().expect("just checked"); + let op = pending.op; + let timeout_ms = self.config.ack_timeout.as_millis() as u64; + self.consecutive_failures += 1; + tracing::warn!( + %op, + failures = self.consecutive_failures, + "control point write was not acknowledged" + ); + complete(pending.reply, Err(FtmsError::Unacknowledged { op, timeout_ms })); + self.check_fault(); + } + } + + // 2. Link health. btleplug does not always end the notification stream + // on disconnect, so poll as well. + if now.duration_since(self.last_health_check) >= HEALTH_CHECK_INTERVAL { + self.last_health_check = now; + if let Some(session) = &self.session { + match session.peripheral.is_connected().await { + Ok(true) => {} + Ok(false) => { + self.on_link_lost("peripheral reports disconnected"); + return; + } + Err(e) => { + self.on_link_lost(&format!("connection check failed: {e}")); + return; + } + } + } + } + + // 3. Next write. + if self.pending.is_some() || self.halted || self.session.is_none() { + return; + } + if !write_allowed(self.last_write, now, self.config.min_write_interval) { + return; + } + + // Procedures first: RequestControl must precede targets. + if let Some((kind, reply)) = self.queued_procedures.pop_front() { + let (op, bytes) = kind.encode(); + self.write(op, bytes, Reply::Unit(reply)).await; + return; + } + + if let Some((target, reply)) = self.queued_target.take() { + let caps = if self.config.ignore_advertised_features { + TrainerCapabilities { + feature: None, + ..self.capabilities + } + } else { + self.capabilities + }; + let gated = capabilities::gate_target( + &self.config.limits, + &caps, + target, + self.config.use_simulation_mode, + ); + let sent = match gated { + Ok(t) => t, + Err(e) => { + let _ = reply.send(Err(FtmsError::Unsupported(e))); + return; + } + }; + if sent != target { + tracing::debug!(?target, ?sent, "target clamped before transmission (SAF-3)"); + } + let (op, bytes) = capabilities::encode_target( + sent, + self.config.use_simulation_mode, + self.config.simulation_template, + ); + self.write(op, bytes, Reply::Target { sent, tx: reply }).await; + } + } + + async fn write(&mut self, op: OpCode, bytes: Vec, reply: Reply) { + let Some(session) = &self.session else { + complete(reply, Err(FtmsError::NotConnected)); + return; + }; + tracing::debug!(%op, raw = %hex(&bytes), "control point write"); + self.last_write = Some(Instant::now()); + + match session + .peripheral + .write(&session.control_point, &bytes, WriteType::WithResponse) + .await + { + Ok(()) => { + self.pending = Some(Pending { + op, + deadline: Instant::now() + self.config.ack_timeout, + reply, + }); + } + Err(e) => { + tracing::warn!(%op, error = %e, "control point write failed"); + self.consecutive_failures += 1; + complete(reply, Err(FtmsError::Bluetooth(e))); + self.check_fault(); + } + } + } + + /// SAF-4: stop sending and alert once the trainer has gone quiet. + fn check_fault(&mut self) { + if self.halted || self.consecutive_failures < self.config.max_consecutive_failures { + return; + } + self.halted = true; + let reason = format!( + "trainer stopped acknowledging control point writes after {} consecutive failures", + self.consecutive_failures + ); + tracing::error!(reason, "control halted (SAF-4)"); + let _ = self + .events_tx + .send(FtmsEvent::ControlFault { reason }); + let failures = self.consecutive_failures; + self.fail_all(move || FtmsError::ControlHalted { failures }); + } + + fn fail_all(&mut self, error: impl Fn() -> FtmsError) { + if let Some((_, reply)) = self.queued_target.take() { + let _ = reply.send(Err(error())); + } + while let Some((_, reply)) = self.queued_procedures.pop_front() { + let _ = reply.send(Err(error())); + } + } + + // -- reconnect (FR-1.6) ----------------------------------------------- + + async fn reconnect(&mut self, cmd_rx: &mut mpsc::Receiver) -> Reconnected { + self.set_state(ConnectionState::Reconnecting); + self.fail_all(|| FtmsError::NotConnected); + + let mut attempt: u32 = 0; + loop { + if self.config.backoff.exhausted(attempt) { + let reason = format!("could not find or connect to {}", self.selector.describe()); + tracing::error!(attempt, reason, "giving up on reconnect"); + self.set_state(ConnectionState::Lost { reason }); + return Reconnected::GaveUp; + } + + let delay = self.config.backoff.delay(attempt); + tracing::info!(attempt, ?delay, "waiting before reconnect attempt"); + + // Stay responsive to shutdown while backing off. + let sleep = tokio::time::sleep(delay); + tokio::pin!(sleep); + loop { + tokio::select! { + biased; + cmd = cmd_rx.recv() => match cmd { + Some(Command::Shutdown { reply }) => return Reconnected::Shutdown(Some(reply)), + Some(Command::SetTarget { reply, .. }) => { + let _ = reply.send(Err(FtmsError::NotConnected)); + } + Some(Command::Procedure { reply, .. }) => { + let _ = reply.send(Err(FtmsError::NotConnected)); + } + None => return Reconnected::Shutdown(None), + }, + _ = &mut sleep => break, + } + } + + attempt += 1; + self.set_state(ConnectionState::Connecting); + match connect_session(&self.adapter, &self.selector, &self.config, &self.state_tx).await + { + Ok(connected) => { + self.session = Some(connected.session); + self.capabilities = connected.capabilities; + let _ = self.caps_tx.send(connected.capabilities); + self.pending = None; + self.consecutive_failures = 0; + self.halted = false; + self.last_write = None; + self.last_health_check = Instant::now(); + self.set_state(ConnectionState::Controlling); + tracing::info!(attempt, "reconnected to the trainer"); + return Reconnected::Ok(connected.notifications); + } + Err(e) => { + tracing::warn!(attempt, error = %e, "reconnect attempt failed"); + self.set_state(ConnectionState::Reconnecting); + } + } + } + } + + // -- shutdown (SAF-2) -------------------------------------------------- + + /// Leave the trainer safe: zero gradient, minimum resistance, `Reset`, + /// `Stop`. Best effort — every step is bounded and failures are logged + /// rather than propagated, because there is nothing left to propagate to. + async fn safety_shutdown(&mut self) { + if let Some(pending) = self.pending.take() { + complete(pending.reply, Err(FtmsError::ClientShutDown)); + } + + let Some(session) = self.session.take() else { + self.set_state(ConnectionState::Idle); + return; + }; + + tracing::info!("running the SAF-2 shutdown sequence"); + for (op, bytes) in safety_reset_commands( + &self.config.limits, + &self.capabilities, + self.config.use_simulation_mode, + self.config.simulation_template, + ) { + let write = session.peripheral.write( + &session.control_point, + &bytes, + WriteType::WithResponse, + ); + match tokio::time::timeout(Duration::from_millis(750), write).await { + Ok(Ok(())) => tracing::debug!(%op, raw = %hex(&bytes), "shutdown write"), + Ok(Err(e)) => tracing::warn!(%op, error = %e, "shutdown write failed"), + Err(_) => tracing::warn!(%op, "shutdown write timed out"), + } + // Respect the trainer's pacing even on the way out. + tokio::time::sleep(Duration::from_millis(60)).await; + } + + match tokio::time::timeout(Duration::from_secs(2), session.peripheral.disconnect()).await { + Ok(Ok(())) => tracing::info!("disconnected from the trainer"), + Ok(Err(e)) => tracing::warn!(error = %e, "disconnect failed"), + Err(_) => tracing::warn!("disconnect timed out"), + } + self.set_state(ConnectionState::Idle); + } +} + +/// FR-2.8: has enough time passed since the last control point write? +/// +/// Split out from the actor so the rate limit is testable with explicit +/// timestamps rather than by watching a real clock. +fn write_allowed(last_write: Option, now: Instant, min_interval: Duration) -> bool { + match last_write { + None => true, + Some(last) => now.saturating_duration_since(last) >= min_interval, + } +} + +/// How long until [`write_allowed`] becomes true. +fn time_until_write_allowed( + last_write: Option, + now: Instant, + min_interval: Duration, +) -> Duration { + match last_write { + None => Duration::ZERO, + Some(last) => min_interval.saturating_sub(now.saturating_duration_since(last)), + } +} + +enum Reconnected { + Ok(mpsc::Receiver), + Shutdown(Option>), + GaveUp, +} + +fn complete(reply: Reply, result: Result<(), FtmsError>) { + match reply { + Reply::Target { sent, tx } => { + let _ = tx.send(result.map(|()| ControlOutcome::Acknowledged { sent })); + } + Reply::Unit(tx) => { + let _ = tx.send(result); + } + } +} + +fn set_state( + state_tx: &watch::Sender, + events_tx: &broadcast::Sender, + state: ConnectionState, +) { + if *state_tx.borrow() == state { + return; + } + tracing::info!(?state, "connection state"); + let _ = state_tx.send(state.clone()); + let _ = events_tx.send(FtmsEvent::State(state)); +} + +async fn next_notification( + rx: &mut Option>, +) -> Option { + match rx { + Some(rx) => rx.recv().await, + // No stream: park forever and let the other select arms drive. + None => std::future::pending().await, + } +} + +/// The SAF-2 sequence, as pure data so it can be unit-tested. +/// +/// Order matters: zero the active targets *first*, so that even if `Reset` is +/// rejected the trainer is already at minimum load. +pub fn safety_reset_commands( + limits: &SafetyLimits, + caps: &TrainerCapabilities, + use_simulation: bool, + sim_template: SimulationParameters, +) -> Vec<(OpCode, Vec)> { + let mut out = Vec::new(); + let feature = caps.feature; + let supports = |f: fn(FitnessMachineFeature) -> bool| feature.map(f).unwrap_or(true); + + if use_simulation && supports(FitnessMachineFeature::supports_simulation) { + out.push(( + OpCode::SetIndoorBikeSimulationParameters, + cp::set_simulation_parameters(SimulationParameters { + grade_percent: 0.0, + wind_speed_mps: 0.0, + ..sim_template + }), + )); + } + if supports(FitnessMachineFeature::supports_inclination_target) { + out.push(( + OpCode::SetTargetInclination, + cp::set_target_inclination(0.0), + )); + } + if supports(FitnessMachineFeature::supports_resistance_target) { + // The lowest level both the trainer and the safety limits permit. + let trainer_min = caps.resistance_range.map(|r| r.min).unwrap_or(i16::MIN); + let level = limits.min_resistance.max(trainer_min); + out.push(( + OpCode::SetTargetResistanceLevel, + cp::set_target_resistance(level), + )); + } + out.push((OpCode::Reset, cp::reset())); + out.push(( + OpCode::StopOrPause, + cp::stop_or_pause(StopOrPause::Stop), + )); + out +} + +// --------------------------------------------------------------------------- +// Connection sequence +// --------------------------------------------------------------------------- + +async fn connect_session( + adapter: &Adapter, + selector: &TrainerSelector, + config: &FtmsConfig, + state_tx: &watch::Sender, +) -> Result { + let _ = state_tx.send_if_modified(|s| { + if *s == ConnectionState::Scanning { + false + } else { + *s = ConnectionState::Scanning; + true + } + }); + + let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?; + let described = scan::describe(&peripheral).await; + let address = described + .as_ref() + .map(|d| d.address.clone()) + .unwrap_or_else(|| peripheral.address().to_string().to_lowercase()); + let name = described.as_ref().and_then(|d| d.name.clone()); + + let _ = state_tx.send(ConnectionState::Connecting); + + if !peripheral.is_connected().await.unwrap_or(false) { + peripheral.connect().await?; + } + + // From here on a failure leaves a live GATT link behind, and a peripheral + // that accepts one connection at a time (A-3) would stay unavailable to the + // next attempt. Set it up separately so every error path disconnects. + match setup_session(peripheral.clone(), config, state_tx).await { + Ok((session, capabilities, notifications)) => Ok(ConnectedTrainer { + session, + capabilities, + notifications, + address, + name, + }), + Err(e) => { + tracing::warn!(error = %e, "connection setup failed; disconnecting"); + let _ = peripheral.disconnect().await; + Err(e) + } + } +} + +async fn setup_session( + peripheral: Peripheral, + config: &FtmsConfig, + _state_tx: &watch::Sender, +) -> Result<(Session, TrainerCapabilities, mpsc::Receiver), FtmsError> { + peripheral.discover_services().await?; + + let chars = peripheral.characteristics(); + let find = |uuid: Uuid| -> Option { + chars + .iter() + .find(|c| c.uuid == uuid && c.service_uuid == uuids::FITNESS_MACHINE_SERVICE) + .or_else(|| chars.iter().find(|c| c.uuid == uuid)) + .cloned() + }; + + let indoor_bike_data = find(uuids::INDOOR_BIKE_DATA) + .ok_or(FtmsError::MissingCharacteristic("Indoor Bike Data (0x2AD2)"))?; + let control_point = find(uuids::FITNESS_MACHINE_CONTROL_POINT).ok_or( + FtmsError::MissingCharacteristic("Fitness Machine Control Point (0x2AD9)"), + )?; + + // Capabilities before control, so the very first target is already gated. + let capabilities = read_capabilities(&peripheral, &find).await; + tracing::info!(?capabilities, "trainer capabilities"); + + // One forwarding task per connection turns the notification stream into a + // channel, which keeps the actor's select loop free of borrow gymnastics. + let mut stream = peripheral.notifications().await?; + let (notif_tx, notif_rx) = mpsc::channel(256); + tokio::spawn(async move { + while let Some(n) = stream.next().await { + if notif_tx.send(n).await.is_err() { + break; + } + } + tracing::debug!("notification stream ended"); + }); + + peripheral.subscribe(&indoor_bike_data).await?; + peripheral.subscribe(&control_point).await?; + if let Some(status) = find(uuids::FITNESS_MACHINE_STATUS) { + if let Err(e) = peripheral.subscribe(&status).await { + tracing::debug!(error = %e, "could not subscribe to Fitness Machine Status"); + } + } + + let mut notif_rx = notif_rx; + + // FR-2.1: control first, everything else after. + handshake( + &peripheral, + &control_point, + &mut notif_rx, + OpCode::RequestControl, + cp::request_control(), + config.ack_timeout, + ) + .await?; + + if config.start_on_connect { + // A trainer that does not implement Start/Resume is not a problem. + if let Err(e) = handshake( + &peripheral, + &control_point, + &mut notif_rx, + OpCode::StartOrResume, + cp::start_or_resume(), + config.ack_timeout, + ) + .await + { + tracing::info!(error = %e, "StartOrResume was not accepted; continuing"); + } + } + + Ok(( + Session { + peripheral, + control_point, + indoor_bike_data, + }, + capabilities, + notif_rx, + )) +} + +async fn read_capabilities( + peripheral: &Peripheral, + find: &impl Fn(Uuid) -> Option, +) -> TrainerCapabilities { + async fn read(peripheral: &Peripheral, ch: Option) -> Option> { + let ch = ch?; + match peripheral.read(&ch).await { + Ok(v) => { + tracing::debug!(uuid = %ch.uuid, raw = %hex(&v), "read capability characteristic"); + Some(v) + } + Err(e) => { + tracing::debug!(uuid = %ch.uuid, error = %e, "capability characteristic unreadable"); + None + } + } + } + + let feature = read(peripheral, find(uuids::FITNESS_MACHINE_FEATURE)) + .await + .and_then(|v| FitnessMachineFeature::decode(&v).ok()); + let resistance_range = read(peripheral, find(uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE)) + .await + .and_then(|v| ResistanceLevelRange::decode(&v).ok()); + let power_range = read(peripheral, find(uuids::SUPPORTED_POWER_RANGE)) + .await + .and_then(|v| PowerRange::decode(&v).ok()); + let inclination_range = read(peripheral, find(uuids::SUPPORTED_INCLINATION_RANGE)) + .await + .and_then(|v| InclinationRange::decode(&v).ok()); + + TrainerCapabilities { + feature, + resistance_range, + power_range, + inclination_range, + } +} + +/// Write a procedure and wait for its indication, discarding anything else that +/// arrives meanwhile. Used only during the connection handshake, before the +/// actor loop exists. +async fn handshake( + peripheral: &Peripheral, + control_point: &Characteristic, + notif_rx: &mut mpsc::Receiver, + op: OpCode, + bytes: Vec, + timeout: Duration, +) -> Result<(), FtmsError> { + tracing::debug!(%op, raw = %hex(&bytes), "handshake write"); + peripheral + .write(control_point, &bytes, WriteType::WithResponse) + .await?; + + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(FtmsError::Unacknowledged { + op, + timeout_ms: timeout.as_millis() as u64, + }); + } + match tokio::time::timeout(remaining, notif_rx.recv()).await { + Err(_) | Ok(None) => { + return Err(FtmsError::Unacknowledged { + op, + timeout_ms: timeout.as_millis() as u64, + }) + } + Ok(Some(n)) => { + if n.uuid != uuids::FITNESS_MACHINE_CONTROL_POINT { + continue; + } + let resp = cp::decode_response(&n.value)?; + if resp.raw_request_op_code != op.as_u8() { + continue; + } + return if resp.result.is_success() { + Ok(()) + } else { + Err(FtmsError::Rejected { + op, + result: resp.result, + }) + }; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capabilities::target_feature; + + #[test] + fn backoff_grows_then_saturates() { + let b = Backoff { + initial: Duration::from_millis(500), + max: Duration::from_secs(30), + multiplier: 2.0, + max_attempts: None, + }; + assert_eq!(b.delay(0), Duration::from_millis(500)); + assert_eq!(b.delay(1), Duration::from_millis(1000)); + assert_eq!(b.delay(2), Duration::from_millis(2000)); + assert_eq!(b.delay(3), Duration::from_millis(4000)); + assert_eq!(b.delay(6), Duration::from_millis(32000).min(b.max)); + assert_eq!(b.delay(6), Duration::from_secs(30)); + // Never overflows, however many attempts. + assert_eq!(b.delay(u32::MAX), Duration::from_secs(30)); + } + + #[test] + fn backoff_is_monotonic() { + let b = Backoff::default(); + let mut prev = Duration::ZERO; + for attempt in 0..40 { + let d = b.delay(attempt); + assert!(d >= prev, "backoff went backwards at attempt {attempt}"); + assert!(d <= b.max); + prev = d; + } + } + + #[test] + fn backoff_attempt_limit() { + let unlimited = Backoff::default(); + assert!(!unlimited.exhausted(1_000_000)); + + let limited = Backoff { + max_attempts: Some(3), + ..Backoff::default() + }; + assert!(!limited.exhausted(0)); + assert!(!limited.exhausted(2)); + assert!(limited.exhausted(3)); + assert!(limited.exhausted(4)); + } + + #[test] + fn default_config_meets_the_four_hertz_rate_limit() { + let c = FtmsConfig::default(); + assert!( + c.min_write_interval >= Duration::from_millis(250), + "FR-2.8 requires <= 4 Hz control writes" + ); + // A-1 is unresolved, so we default to the op code the reference + // implementation is known to work with. + assert!(!c.use_simulation_mode); + } + + // -- FR-2.8 rate limiting --------------------------------------------- + + #[test] + fn the_first_write_is_always_allowed() { + let now = Instant::now(); + assert!(write_allowed(None, now, Duration::from_millis(250))); + assert_eq!( + time_until_write_allowed(None, now, Duration::from_millis(250)), + Duration::ZERO + ); + } + + #[test] + fn writes_are_held_to_four_hertz() { + let interval = Duration::from_millis(250); + let last = Instant::now(); + + // Immediately after a write: blocked. + assert!(!write_allowed(Some(last), last, interval)); + assert_eq!(time_until_write_allowed(Some(last), last, interval), interval); + + // 249 ms later: still blocked. + let t = last + Duration::from_millis(249); + assert!(!write_allowed(Some(last), t, interval)); + assert_eq!( + time_until_write_allowed(Some(last), t, interval), + Duration::from_millis(1) + ); + + // Exactly at the interval: allowed. + let t = last + interval; + assert!(write_allowed(Some(last), t, interval)); + assert_eq!(time_until_write_allowed(Some(last), t, interval), Duration::ZERO); + + // Well past it: allowed, and no negative-duration underflow. + let t = last + Duration::from_secs(60); + assert!(write_allowed(Some(last), t, interval)); + assert_eq!(time_until_write_allowed(Some(last), t, interval), Duration::ZERO); + } + + #[test] + fn a_clock_that_appears_to_go_backwards_does_not_panic() { + let interval = Duration::from_millis(250); + let now = Instant::now(); + let later = now + Duration::from_secs(5); + // `now` is before `later`: saturating arithmetic, no underflow panic. + assert!(!write_allowed(Some(later), now, interval)); + assert_eq!(time_until_write_allowed(Some(later), now, interval), interval); + } + + /// Walk a simulated second of a 4 Hz limiter and count the writes. + #[test] + fn no_more_than_four_writes_per_second() { + let interval = FtmsConfig::default().min_write_interval; + let start = Instant::now(); + let mut last: Option = None; + let mut writes = 0; + // The caller offers a new target every 10 ms for one second. + for step in 0..100 { + let now = start + Duration::from_millis(step * 10); + if write_allowed(last, now, interval) { + writes += 1; + last = Some(now); + } + } + assert_eq!(writes, 4, "FR-2.8 caps control writes at 4 Hz"); + } + + // -- reply plumbing ---------------------------------------------------- + + #[test] + fn an_acknowledged_target_reports_the_value_actually_sent() { + let (tx, rx) = oneshot::channel(); + let sent = ControlTarget::Gradient { percent: 15.0 }; + complete(Reply::Target { sent, tx }, Ok(())); + assert_eq!( + rx.blocking_recv().unwrap().unwrap(), + ControlOutcome::Acknowledged { sent } + ); + } + + #[test] + fn a_rejected_target_surfaces_the_result_code() { + let (tx, rx) = oneshot::channel(); + complete( + Reply::Target { + sent: ControlTarget::Power { watts: 200 }, + tx, + }, + Err(FtmsError::Rejected { + op: OpCode::SetTargetPower, + result: ResultCode::ControlNotPermitted, + }), + ); + assert!(matches!( + rx.blocking_recv().unwrap(), + Err(FtmsError::Rejected { + op: OpCode::SetTargetPower, + result: ResultCode::ControlNotPermitted + }) + )); + } + + #[test] + fn dropping_the_caller_does_not_panic_the_actor() { + let (tx, rx) = oneshot::channel::>(); + drop(rx); + // Must be a no-op, not an unwrap on a closed channel. + complete(Reply::Unit(tx), Ok(())); + } + + #[test] + fn procedures_encode_to_the_right_op_codes() { + assert_eq!( + Procedure::RequestControl.encode(), + (OpCode::RequestControl, vec![0x00]) + ); + assert_eq!(Procedure::Reset.encode(), (OpCode::Reset, vec![0x01])); + assert_eq!(Procedure::Start.encode(), (OpCode::StartOrResume, vec![0x07])); + assert_eq!( + Procedure::Stop.encode(), + (OpCode::StopOrPause, vec![0x08, 0x01]) + ); + assert_eq!( + Procedure::Pause.encode(), + (OpCode::StopOrPause, vec![0x08, 0x02]) + ); + } + + // -- SAF-2 ------------------------------------------------------------- + + #[test] + fn safety_reset_zeroes_grade_and_minimises_resistance() { + let caps = TrainerCapabilities { + feature: Some(FitnessMachineFeature { + machine: 0, + target: target_feature::INCLINATION | target_feature::RESISTANCE, + }), + resistance_range: Some(ResistanceLevelRange { + min: 0, + max: 100, + increment: 1, + }), + ..Default::default() + }; + let cmds = safety_reset_commands( + &SafetyLimits::default(), + &caps, + false, + SimulationParameters::default(), + ); + let ops: Vec = cmds.iter().map(|(op, _)| *op).collect(); + assert_eq!( + ops, + vec![ + OpCode::SetTargetInclination, + OpCode::SetTargetResistanceLevel, + OpCode::Reset, + OpCode::StopOrPause, + ] + ); + // Inclination exactly zero. + assert_eq!(cmds[0].1, vec![0x03, 0x00, 0x00]); + // Resistance at the minimum. + assert_eq!(cmds[1].1, vec![0x04, 0x00, 0x00]); + // Targets are zeroed before Reset, not after. + assert!(ops.iter().position(|o| *o == OpCode::Reset).unwrap() > 1); + } + + #[test] + fn safety_reset_uses_the_trainers_minimum_when_it_is_above_the_configured_one() { + let caps = TrainerCapabilities { + resistance_range: Some(ResistanceLevelRange { + min: 5, + max: 40, + increment: 1, + }), + ..Default::default() + }; + let limits = SafetyLimits { + min_resistance: 0, + ..SafetyLimits::default() + }; + let cmds = + safety_reset_commands(&limits, &caps, false, SimulationParameters::default()); + let resistance = cmds + .iter() + .find(|(op, _)| *op == OpCode::SetTargetResistanceLevel) + .expect("resistance reset present"); + assert_eq!(resistance.1, vec![0x04, 0x05, 0x00], "must not go below the trainer's own minimum (SAF-3)"); + } + + #[test] + fn safety_reset_uses_the_configured_minimum_when_it_is_the_higher_one() { + let caps = TrainerCapabilities { + resistance_range: Some(ResistanceLevelRange { + min: -20, + max: 100, + increment: 1, + }), + ..Default::default() + }; + let limits = SafetyLimits { + min_resistance: 0, + ..SafetyLimits::default() + }; + let cmds = + safety_reset_commands(&limits, &caps, false, SimulationParameters::default()); + let resistance = cmds + .iter() + .find(|(op, _)| *op == OpCode::SetTargetResistanceLevel) + .unwrap(); + assert_eq!(resistance.1, vec![0x04, 0x00, 0x00]); + } + + #[test] + fn safety_reset_skips_unsupported_op_codes() { + let caps = TrainerCapabilities { + feature: Some(FitnessMachineFeature { + machine: 0, + target: target_feature::POWER, + }), + ..Default::default() + }; + let cmds = safety_reset_commands( + &SafetyLimits::default(), + &caps, + false, + SimulationParameters::default(), + ); + let ops: Vec = cmds.iter().map(|(op, _)| *op).collect(); + assert_eq!(ops, vec![OpCode::Reset, OpCode::StopOrPause]); + } + + #[test] + fn safety_reset_always_ends_with_reset_and_stop() { + for use_sim in [false, true] { + let cmds = safety_reset_commands( + &SafetyLimits::default(), + &TrainerCapabilities::default(), + use_sim, + SimulationParameters::default(), + ); + let ops: Vec = cmds.iter().map(|(op, _)| *op).collect(); + assert_eq!(ops[ops.len() - 2], OpCode::Reset); + assert_eq!(ops[ops.len() - 1], OpCode::StopOrPause); + } + } + + #[test] + fn safety_reset_in_simulation_mode_sends_zero_grade_and_zero_wind() { + let caps = TrainerCapabilities { + feature: Some(FitnessMachineFeature { + machine: 0, + target: target_feature::INDOOR_BIKE_SIMULATION, + }), + ..Default::default() + }; + let cmds = safety_reset_commands( + &SafetyLimits::default(), + &caps, + true, + SimulationParameters { + wind_speed_mps: 9.0, + grade_percent: 12.0, + crr: 0.004, + wind_resistance_coefficient: 0.51, + }, + ); + let (op, bytes) = &cmds[0]; + assert_eq!(*op, OpCode::SetIndoorBikeSimulationParameters); + // wind = 0, grade = 0 + assert_eq!(&bytes[1..5], &[0x00, 0x00, 0x00, 0x00]); + } +} diff --git a/crates/ble/src/control_point.rs b/crates/ble/src/control_point.rs new file mode 100644 index 0000000..f607e25 --- /dev/null +++ b/crates/ble/src/control_point.rs @@ -0,0 +1,488 @@ +//! Encoders and decoders for the FTMS **Fitness Machine Control Point** +//! (`0x2AD9`). +//! +//! The control point is a request/response characteristic: the central writes a +//! procedure (with response), and the fitness machine replies with an +//! **indication** of the form `[0x80, request_op_code, result_code, ...params]`. +//! Treating writes as fire-and-forget is a bug (FR-2.7) — a trainer that has +//! not granted control will silently ignore everything until `RequestControl` +//! succeeds, and there is no other way to find that out. +//! +//! The op-code table and the sint16 encodings for inclination, resistance and +//! target power were ported from `obostjancic/smart-trainer-control` +//! (`src/lib/bike/ftms-control.ts`), MIT licensed, Copyright (c) 2025 Ogi — +//! a working Van Rysel D100 client. See REQUIREMENTS.md §3.2. + +/// FTMS Fitness Machine Control Point op codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum OpCode { + RequestControl = 0x00, + Reset = 0x01, + SetTargetSpeed = 0x02, + SetTargetInclination = 0x03, + SetTargetResistanceLevel = 0x04, + SetTargetPower = 0x05, + SetTargetHeartRate = 0x06, + StartOrResume = 0x07, + StopOrPause = 0x08, + SetTargetedExpendedEnergy = 0x09, + SetTargetedNumberOfSteps = 0x0A, + SetTargetedNumberOfStrides = 0x0B, + SetTargetedDistance = 0x0C, + SetTargetedTrainingTime = 0x0D, + SetTargetedTimeInTwoHeartRateZones = 0x0E, + SetTargetedTimeInThreeHeartRateZones = 0x0F, + SetTargetedTimeInFiveHeartRateZones = 0x10, + SetIndoorBikeSimulationParameters = 0x11, + SetWheelCircumference = 0x12, + SetSpinDownControl = 0x13, + SetTargetedCadence = 0x14, +} + +impl OpCode { + pub fn from_u8(v: u8) -> Option { + use OpCode::*; + Some(match v { + 0x00 => RequestControl, + 0x01 => Reset, + 0x02 => SetTargetSpeed, + 0x03 => SetTargetInclination, + 0x04 => SetTargetResistanceLevel, + 0x05 => SetTargetPower, + 0x06 => SetTargetHeartRate, + 0x07 => StartOrResume, + 0x08 => StopOrPause, + 0x09 => SetTargetedExpendedEnergy, + 0x0A => SetTargetedNumberOfSteps, + 0x0B => SetTargetedNumberOfStrides, + 0x0C => SetTargetedDistance, + 0x0D => SetTargetedTrainingTime, + 0x0E => SetTargetedTimeInTwoHeartRateZones, + 0x0F => SetTargetedTimeInThreeHeartRateZones, + 0x10 => SetTargetedTimeInFiveHeartRateZones, + 0x11 => SetIndoorBikeSimulationParameters, + 0x12 => SetWheelCircumference, + 0x13 => SetSpinDownControl, + 0x14 => SetTargetedCadence, + _ => return None, + }) + } + + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +impl std::fmt::Display for OpCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?} (0x{:02x})", self.as_u8()) + } +} + +/// The first byte of every control point indication. +pub const RESPONSE_CODE: u8 = 0x80; + +/// Result codes carried in a control point indication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResultCode { + Success, + OpCodeNotSupported, + InvalidParameter, + OperationFailed, + ControlNotPermitted, + /// Reserved or vendor-specific. + Unknown(u8), +} + +impl ResultCode { + pub fn from_u8(v: u8) -> Self { + match v { + 0x01 => ResultCode::Success, + 0x02 => ResultCode::OpCodeNotSupported, + 0x03 => ResultCode::InvalidParameter, + 0x04 => ResultCode::OperationFailed, + 0x05 => ResultCode::ControlNotPermitted, + other => ResultCode::Unknown(other), + } + } + + pub fn as_u8(self) -> u8 { + match self { + ResultCode::Success => 0x01, + ResultCode::OpCodeNotSupported => 0x02, + ResultCode::InvalidParameter => 0x03, + ResultCode::OperationFailed => 0x04, + ResultCode::ControlNotPermitted => 0x05, + ResultCode::Unknown(v) => v, + } + } + + pub fn is_success(self) -> bool { + matches!(self, ResultCode::Success) + } +} + +impl std::fmt::Display for ResultCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResultCode::Success => write!(f, "Success"), + ResultCode::OpCodeNotSupported => write!(f, "Op Code not supported"), + ResultCode::InvalidParameter => write!(f, "Invalid parameter"), + ResultCode::OperationFailed => write!(f, "Operation failed"), + ResultCode::ControlNotPermitted => { + write!(f, "Control not permitted (RequestControl first)") + } + ResultCode::Unknown(v) => write!(f, "Unknown result code 0x{v:02x}"), + } + } +} + +/// A decoded control point indication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlPointResponse { + /// The op code this response refers to, if it is one we know. + pub request_op_code: Option, + /// The raw op code byte, even when unrecognised. + pub raw_request_op_code: u8, + pub result: ResultCode, + /// Response parameters, present only for some procedures. + pub parameters: Vec, +} + +impl ControlPointResponse { + pub fn is_success(&self) -> bool { + self.result.is_success() + } +} + +/// Why a control point indication could not be decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ResponseError { + #[error("control point indication is {len} bytes; at least 3 are required")] + TooShort { len: usize }, + #[error("control point indication started with 0x{first:02x}, expected 0x80")] + NotAResponse { first: u8 }, +} + +/// Decode a control point indication. Pure function; no hardware needed. +pub fn decode_response(data: &[u8]) -> Result { + if data.len() < 3 { + return Err(ResponseError::TooShort { len: data.len() }); + } + if data[0] != RESPONSE_CODE { + return Err(ResponseError::NotAResponse { first: data[0] }); + } + Ok(ControlPointResponse { + request_op_code: OpCode::from_u8(data[1]), + raw_request_op_code: data[1], + result: ResultCode::from_u8(data[2]), + parameters: data[3..].to_vec(), + }) +} + +// --------------------------------------------------------------------------- +// Request encoders +// --------------------------------------------------------------------------- + +/// `StopOrPause` (`0x08`) takes a one-byte control parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopOrPause { + Stop = 0x01, + Pause = 0x02, +} + +/// Parameters for `SetIndoorBikeSimulationParameters` (`0x11`). +/// +/// Whether the D100 accepts this at all is open question **A-1** — the MIT +/// reference drives grade with `SetTargetInclination` (`0x03`) instead. The +/// `probe set` subcommand exists to resolve it against real hardware. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SimulationParameters { + /// Wind speed, m/s. Encoded as sint16 with 0.001 resolution. + pub wind_speed_mps: f32, + /// Grade, percent. Encoded as sint16 with 0.01 resolution. + pub grade_percent: f32, + /// Coefficient of rolling resistance. Encoded as uint8 with 0.0001 + /// resolution (so the representable range is 0.0000 – 0.0255). + pub crr: f32, + /// Wind resistance coefficient, kg/m. Encoded as uint8 with 0.01 + /// resolution (representable range 0.00 – 2.55). + pub wind_resistance_coefficient: f32, +} + +impl Default for SimulationParameters { + fn default() -> Self { + Self { + wind_speed_mps: 0.0, + grade_percent: 0.0, + crr: 0.004, + wind_resistance_coefficient: 0.51, + } + } +} + +/// `[0x00]` — take control of the machine. Must succeed before any target +/// setting procedure is accepted (FR-2.1). +pub fn request_control() -> Vec { + vec![OpCode::RequestControl.as_u8()] +} + +/// `[0x01]` — reset the machine to its default state. Part of the shutdown +/// safety sequence (SAF-2). +pub fn reset() -> Vec { + vec![OpCode::Reset.as_u8()] +} + +/// `[0x07]` — start or resume. +pub fn start_or_resume() -> Vec { + vec![OpCode::StartOrResume.as_u8()] +} + +/// `[0x08, param]` — stop or pause. +pub fn stop_or_pause(what: StopOrPause) -> Vec { + vec![OpCode::StopOrPause.as_u8(), what as u8] +} + +/// `[0x03, sint16 LE]` — target inclination in percent, 0.1 resolution. +/// +/// Values outside the sint16 range after scaling are saturated rather than +/// wrapped: wrapping a +400% mistake into a large negative grade would be a +/// safety hazard. +pub fn set_target_inclination(percent: f32) -> Vec { + let raw = scale_to_i16(percent, 10.0); + let mut v = vec![OpCode::SetTargetInclination.as_u8()]; + v.extend_from_slice(&raw.to_le_bytes()); + v +} + +/// `[0x04, sint16 LE]` — target resistance level, in the trainer's own units. +/// +/// Note: the FTMS specification defines this parameter as a *uint8* with 0.1 +/// resolution, but the working D100 reference implementation (§3.2) sends a +/// sint16 and the trainer accepts it. We follow the reference, since it is the +/// only behaviour confirmed on this hardware. **Needs hardware confirmation** +/// if a different trainer is ever targeted. +pub fn set_target_resistance(level: i16) -> Vec { + let mut v = vec![OpCode::SetTargetResistanceLevel.as_u8()]; + v.extend_from_slice(&level.to_le_bytes()); + v +} + +/// `[0x05, sint16 LE]` — target power in watts. +pub fn set_target_power(watts: i16) -> Vec { + let mut v = vec![OpCode::SetTargetPower.as_u8()]; + v.extend_from_slice(&watts.to_le_bytes()); + v +} + +/// `[0x11, sint16 wind, sint16 grade, uint8 crr, uint8 cw]` — indoor bike +/// simulation parameters. See [`SimulationParameters`] and open question A-1. +pub fn set_simulation_parameters(p: SimulationParameters) -> Vec { + let wind = scale_to_i16(p.wind_speed_mps, 1000.0); + let grade = scale_to_i16(p.grade_percent, 100.0); + let crr = scale_to_u8(p.crr, 10_000.0); + let cw = scale_to_u8(p.wind_resistance_coefficient, 100.0); + + let mut v = vec![OpCode::SetIndoorBikeSimulationParameters.as_u8()]; + v.extend_from_slice(&wind.to_le_bytes()); + v.extend_from_slice(&grade.to_le_bytes()); + v.push(crr); + v.push(cw); + v +} + +/// Scale a physical value by `factor` and saturate into sint16. +fn scale_to_i16(value: f32, factor: f32) -> i16 { + if !value.is_finite() { + return 0; + } + let scaled = (value * factor).round(); + scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16 +} + +/// Scale a physical value by `factor` and saturate into uint8. +fn scale_to_u8(value: f32, factor: f32) -> u8 { + if !value.is_finite() { + return 0; + } + let scaled = (value * factor).round(); + scaled.clamp(0.0, u8::MAX as f32) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_procedures_are_single_bytes() { + assert_eq!(request_control(), vec![0x00]); + assert_eq!(reset(), vec![0x01]); + assert_eq!(start_or_resume(), vec![0x07]); + assert_eq!(stop_or_pause(StopOrPause::Stop), vec![0x08, 0x01]); + assert_eq!(stop_or_pause(StopOrPause::Pause), vec![0x08, 0x02]); + } + + #[test] + fn inclination_is_sint16_tenths_of_a_percent_little_endian() { + // +5.0% -> 50 -> 0x0032 + assert_eq!(set_target_inclination(5.0), vec![0x03, 0x32, 0x00]); + // 0% -> 0 + assert_eq!(set_target_inclination(0.0), vec![0x03, 0x00, 0x00]); + // -7.5% -> -75 -> 0xFFB5 + assert_eq!(set_target_inclination(-7.5), vec![0x03, 0xb5, 0xff]); + // Rounding, not truncation. + assert_eq!(set_target_inclination(1.26), vec![0x03, 0x0d, 0x00]); + } + + #[test] + fn inclination_saturates_instead_of_wrapping() { + // 5000% * 10 = 50000, past i16::MAX. Must clamp to +32767, not wrap to + // a large negative grade. + assert_eq!(set_target_inclination(5000.0), vec![0x03, 0xff, 0x7f]); + assert_eq!(set_target_inclination(-5000.0), vec![0x03, 0x00, 0x80]); + assert_eq!(set_target_inclination(f32::NAN), vec![0x03, 0x00, 0x00]); + assert_eq!(set_target_inclination(f32::INFINITY), vec![0x03, 0x00, 0x00]); + } + + #[test] + fn resistance_is_sint16_little_endian() { + assert_eq!(set_target_resistance(0), vec![0x04, 0x00, 0x00]); + assert_eq!(set_target_resistance(50), vec![0x04, 0x32, 0x00]); + assert_eq!(set_target_resistance(100), vec![0x04, 0x64, 0x00]); + assert_eq!(set_target_resistance(-1), vec![0x04, 0xff, 0xff]); + } + + #[test] + fn power_is_sint16_watts_little_endian() { + assert_eq!(set_target_power(200), vec![0x05, 0xc8, 0x00]); + assert_eq!(set_target_power(600), vec![0x05, 0x58, 0x02]); + // Negative target power is nonsense but must still encode as sint16 + // rather than panic; the safety gate is what prevents it being sent. + assert_eq!(set_target_power(-10), vec![0x05, 0xf6, 0xff]); + } + + #[test] + fn simulation_parameters_layout() { + let p = SimulationParameters { + wind_speed_mps: 0.0, + grade_percent: 4.5, // * 100 = 450 = 0x01C2 + crr: 0.004, // * 10000 = 40 = 0x28 + wind_resistance_coefficient: 0.51, // * 100 = 51 = 0x33 + }; + assert_eq!( + set_simulation_parameters(p), + vec![0x11, 0x00, 0x00, 0xc2, 0x01, 0x28, 0x33] + ); + assert_eq!(set_simulation_parameters(p).len(), 7); + } + + #[test] + fn simulation_parameters_negative_grade_and_wind() { + let p = SimulationParameters { + wind_speed_mps: -1.5, // * 1000 = -1500 = 0xFA24 + grade_percent: -8.0, // * 100 = -800 = 0xFCE0 + crr: 0.0, + wind_resistance_coefficient: 0.0, + }; + assert_eq!( + set_simulation_parameters(p), + vec![0x11, 0x24, 0xfa, 0xe0, 0xfc, 0x00, 0x00] + ); + } + + #[test] + fn simulation_parameter_bytes_saturate() { + let p = SimulationParameters { + wind_speed_mps: 0.0, + grade_percent: 0.0, + crr: 1.0, // * 10000 = 10000, way past u8 + wind_resistance_coefficient: -5.0, // negative, clamps to 0 + }; + let b = set_simulation_parameters(p); + assert_eq!(b[5], 0xff); + assert_eq!(b[6], 0x00); + } + + #[test] + fn response_decoding() { + let r = decode_response(&[0x80, 0x05, 0x01]).unwrap(); + assert_eq!(r.request_op_code, Some(OpCode::SetTargetPower)); + assert_eq!(r.result, ResultCode::Success); + assert!(r.is_success()); + assert!(r.parameters.is_empty()); + } + + #[test] + fn response_decoding_reports_every_error_code() { + for (byte, expect) in [ + (0x02u8, ResultCode::OpCodeNotSupported), + (0x03, ResultCode::InvalidParameter), + (0x04, ResultCode::OperationFailed), + (0x05, ResultCode::ControlNotPermitted), + (0x77, ResultCode::Unknown(0x77)), + ] { + let r = decode_response(&[0x80, 0x11, byte]).unwrap(); + assert_eq!(r.result, expect); + assert!(!r.is_success()); + } + } + + /// A-1: this is exactly the byte sequence that tells us the D100 rejects + /// simulation mode. + #[test] + fn sim_mode_rejection_is_recognisable() { + let r = decode_response(&[0x80, 0x11, 0x02]).unwrap(); + assert_eq!( + r.request_op_code, + Some(OpCode::SetIndoorBikeSimulationParameters) + ); + assert_eq!(r.result, ResultCode::OpCodeNotSupported); + } + + #[test] + fn response_with_parameters() { + let r = decode_response(&[0x80, 0x13, 0x01, 0xaa, 0xbb]).unwrap(); + assert_eq!(r.parameters, vec![0xaa, 0xbb]); + } + + #[test] + fn unknown_op_code_in_response_is_preserved_not_dropped() { + let r = decode_response(&[0x80, 0xfe, 0x02]).unwrap(); + assert_eq!(r.request_op_code, None); + assert_eq!(r.raw_request_op_code, 0xfe); + } + + #[test] + fn malformed_responses_are_errors() { + assert_eq!( + decode_response(&[]).unwrap_err(), + ResponseError::TooShort { len: 0 } + ); + assert_eq!( + decode_response(&[0x80, 0x05]).unwrap_err(), + ResponseError::TooShort { len: 2 } + ); + assert_eq!( + decode_response(&[0x01, 0x05, 0x01]).unwrap_err(), + ResponseError::NotAResponse { first: 0x01 } + ); + } + + #[test] + fn op_code_round_trips() { + for v in 0x00u8..=0x14 { + let op = OpCode::from_u8(v).expect("all of 0x00..=0x14 are defined"); + assert_eq!(op.as_u8(), v); + } + assert_eq!(OpCode::from_u8(0x15), None); + assert_eq!(OpCode::from_u8(0xff), None); + } + + #[test] + fn result_code_round_trips() { + for v in 0x00u8..=0xff { + assert_eq!(ResultCode::from_u8(v).as_u8(), v); + } + } +} diff --git a/crates/ble/src/error.rs b/crates/ble/src/error.rs new file mode 100644 index 0000000..91ab2dc --- /dev/null +++ b/crates/ble/src/error.rs @@ -0,0 +1,71 @@ +//! Error type for the BLE layer. +//! +//! NFR-4: no BLE dropout, malformed packet or missing characteristic may crash +//! the app. Everything that can go wrong on the wire is a value here, not a +//! panic. + +use crate::capabilities::{FieldTooShort, UnsupportedTarget}; +use crate::control_point::{OpCode, ResponseError, ResultCode}; +use crate::indoor_bike_data::DecodeError; + +#[derive(Debug, thiserror::Error)] +pub enum FtmsError { + #[error("bluetooth error: {0}")] + Bluetooth(#[from] btleplug::Error), + + #[error("no bluetooth adapter available")] + NoAdapter, + + #[error("no device found matching {0} (the trainer may be asleep — pedal it and retry)")] + NotFound(String), + + #[error("peripheral does not expose the Fitness Machine Service (0x1826)")] + NotAFitnessMachine, + + #[error("the trainer is missing a required characteristic: {0}")] + MissingCharacteristic(&'static str), + + #[error("{0} is not writable on this trainer")] + NotWritable(&'static str), + + #[error("could not decode Indoor Bike Data: {0}")] + IndoorBikeData(#[from] DecodeError), + + #[error("could not decode a control point indication: {0}")] + ControlPointResponse(#[from] ResponseError), + + #[error("could not decode a capability characteristic: {0}")] + Capability(#[from] FieldTooShort), + + #[error("{0}")] + Unsupported(#[from] UnsupportedTarget), + + /// The trainer answered, but with an error. FR-2.7 — this is the whole + /// reason control writes are not fire-and-forget. + #[error("trainer rejected {op}: {result}")] + Rejected { op: OpCode, result: ResultCode }, + + /// The trainer answered a *different* op code than the one in flight. + #[error("trainer answered op code 0x{got:02x} while 0x{expected:02x} was in flight")] + MismatchedResponse { expected: u8, got: u8 }, + + #[error("trainer did not acknowledge {op} within {timeout_ms} ms")] + Unacknowledged { op: OpCode, timeout_ms: u64 }, + + /// SAF-4 — repeated unacknowledged writes stop the control path and the + /// rider must be alerted. + #[error( + "control halted after {failures} consecutive unacknowledged control point writes (SAF-4); \ + reconnect the trainer to resume" + )] + ControlHalted { failures: u32 }, + + #[error("not connected to the trainer")] + NotConnected, + + #[error("the FTMS client has shut down")] + ClientShutDown, + + #[error("gave up reconnecting after {attempts} attempts: {reason}")] + ReconnectFailed { attempts: u32, reason: String }, +} diff --git a/crates/ble/src/indoor_bike_data.rs b/crates/ble/src/indoor_bike_data.rs new file mode 100644 index 0000000..78febaa --- /dev/null +++ b/crates/ble/src/indoor_bike_data.rs @@ -0,0 +1,520 @@ +//! Decoder for the FTMS **Indoor Bike Data** characteristic (`0x2AD2`). +//! +//! The packet is variable length. A leading little-endian 16-bit flags field +//! declares which fields follow, and the fields must be consumed in *strict* +//! specification order — there is no tagging, so a single mis-ordered or +//! mis-sized field turns everything after it into garbage. +//! +//! **The C1 flag (bit 0) is inverted.** It is named "More Data", and +//! Instantaneous Speed is present when the bit is **clear**. Every other bit is +//! a normal "present when set" flag. This trips up nearly every first +//! implementation; see REQUIREMENTS.md §5.2. +//! +//! Field order and units per the Bluetooth SIG FTMS specification v1.0: +//! +//! | # | Field | Flag | Type | Resolution | +//! |---|-------|------|------|------------| +//! | 1 | Instantaneous Speed | bit 0 **clear** | uint16 | 0.01 km/h | +//! | 2 | Average Speed | bit 1 set | uint16 | 0.01 km/h | +//! | 3 | Instantaneous Cadence | bit 2 set | uint16 | 0.5 rpm | +//! | 4 | Average Cadence | bit 3 set | uint16 | 0.5 rpm | +//! | 5 | Total Distance | bit 4 set | uint24 | 1 m | +//! | 6 | Resistance Level | bit 5 set | sint16 | 1 (unitless) | +//! | 7 | Instantaneous Power | bit 6 set | sint16 | 1 W | +//! | 8 | Average Power | bit 7 set | sint16 | 1 W | +//! | 9 | Total Energy / Energy per Hour / Energy per Minute | bit 8 set | uint16, uint16, uint8 | kcal | +//! | 10 | Heart Rate | bit 9 set | uint8 | 1 bpm | +//! | 11 | Metabolic Equivalent | bit 10 set | uint8 | 0.1 | +//! | 12 | Elapsed Time | bit 11 set | uint16 | 1 s | +//! | 13 | Remaining Time | bit 12 set | uint16 | 1 s | +//! +//! Portions of the field ordering and scaling in this module were ported from +//! `obostjancic/smart-trainer-control` (`src/lib/bike/ftms.ts`), MIT licensed, +//! Copyright (c) 2025 Ogi — a working Van Rysel D100 client. See +//! REQUIREMENTS.md §3.2. This Rust version differs in that a truncated packet +//! is a hard error rather than being silently zero-filled (NFR-4). + +use bikecontrol_core::types::Telemetry; + +/// Bit positions in the Indoor Bike Data flags field. +pub mod flag { + /// Bit 0 — **inverted**: Instantaneous Speed is present when this is CLEAR. + pub const MORE_DATA: u16 = 1 << 0; + pub const AVERAGE_SPEED: u16 = 1 << 1; + pub const INSTANTANEOUS_CADENCE: u16 = 1 << 2; + pub const AVERAGE_CADENCE: u16 = 1 << 3; + pub const TOTAL_DISTANCE: u16 = 1 << 4; + pub const RESISTANCE_LEVEL: u16 = 1 << 5; + pub const INSTANTANEOUS_POWER: u16 = 1 << 6; + pub const AVERAGE_POWER: u16 = 1 << 7; + pub const EXPENDED_ENERGY: u16 = 1 << 8; + pub const HEART_RATE: u16 = 1 << 9; + pub const METABOLIC_EQUIVALENT: u16 = 1 << 10; + pub const ELAPSED_TIME: u16 = 1 << 11; + pub const REMAINING_TIME: u16 = 1 << 12; +} + +/// Why an Indoor Bike Data packet could not be decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + /// Fewer than the two flag bytes were present. + #[error("indoor bike data packet is {len} bytes; at least 2 (flags) are required")] + MissingFlags { len: usize }, + /// The flags promised a field the packet was too short to contain. + #[error( + "indoor bike data packet truncated: field {field} needs {need} byte(s) at offset {offset}, \ + but the packet is only {len} bytes" + )] + Truncated { + field: &'static str, + offset: usize, + need: usize, + len: usize, + }, +} + +/// Every field FTMS can put in an Indoor Bike Data packet, already scaled into +/// physical units. `None` means the trainer did not send the field. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct IndoorBikeData { + /// Raw flags field, retained for diagnostics (NFR-8). + pub flags: u16, + /// km/h. + pub instant_speed_kph: Option, + /// km/h. + pub average_speed_kph: Option, + /// rpm. + pub instant_cadence_rpm: Option, + /// rpm. + pub average_cadence_rpm: Option, + /// metres. + pub total_distance_m: Option, + /// Trainer-specific resistance units. + pub resistance_level: Option, + /// watts. + pub instant_power_w: Option, + /// watts. + pub average_power_w: Option, + /// kcal. + pub total_energy_kcal: Option, + /// kcal/h. + pub energy_per_hour_kcal: Option, + /// kcal/min. + pub energy_per_minute_kcal: Option, + /// bpm. + pub heart_rate_bpm: Option, + pub metabolic_equivalent: Option, + /// seconds. + pub elapsed_time_s: Option, + /// seconds. + pub remaining_time_s: Option, + /// Number of bytes consumed. If this is less than the packet length the + /// trainer appended data we do not understand — worth logging, not an error. + pub consumed: usize, +} + +impl IndoorBikeData { + /// Project onto the shared [`Telemetry`] contract. `elapsed_ms` is the + /// ride-clock timestamp; the packet's own Elapsed Time field is the + /// *machine's* session clock and is deliberately not used for it. + pub fn to_telemetry(&self, elapsed_ms: u64) -> Telemetry { + Telemetry { + elapsed_ms, + power_w: self.instant_power_w, + cadence_rpm: self.instant_cadence_rpm, + speed_kph: self.instant_speed_kph, + resistance_level: self.resistance_level, + heart_rate_bpm: self.heart_rate_bpm, + total_distance_m: self.total_distance_m, + total_energy_kcal: self.total_energy_kcal, + } + } +} + +/// A bounds-checked little-endian cursor. Every read names the field it is +/// reading so a truncated packet produces a diagnosable error. +struct Cursor<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + fn take(&mut self, field: &'static str, n: usize) -> Result<&'a [u8], DecodeError> { + let end = self.offset.checked_add(n).ok_or(DecodeError::Truncated { + field, + offset: self.offset, + need: n, + len: self.data.len(), + })?; + if end > self.data.len() { + return Err(DecodeError::Truncated { + field, + offset: self.offset, + need: n, + len: self.data.len(), + }); + } + let out = &self.data[self.offset..end]; + self.offset = end; + Ok(out) + } + + fn u8(&mut self, field: &'static str) -> Result { + Ok(self.take(field, 1)?[0]) + } + + fn u16(&mut self, field: &'static str) -> Result { + let b = self.take(field, 2)?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + + fn i16(&mut self, field: &'static str) -> Result { + Ok(self.u16(field)? as i16) + } + + /// uint24, little-endian. + fn u24(&mut self, field: &'static str) -> Result { + let b = self.take(field, 3)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], 0])) + } +} + +/// Decode one Indoor Bike Data notification. +/// +/// This is a pure function over bytes — no I/O, no state — so it is fully +/// testable without hardware. +pub fn decode(data: &[u8]) -> Result { + if data.len() < 2 { + return Err(DecodeError::MissingFlags { len: data.len() }); + } + + let mut cur = Cursor::new(data); + let flags = cur.u16("flags")?; + let present = |bit: u16| flags & bit != 0; + + let mut out = IndoorBikeData { + flags, + ..Default::default() + }; + + // 1. Instantaneous Speed — INVERTED FLAG: present when bit 0 is CLEAR. + if !present(flag::MORE_DATA) { + out.instant_speed_kph = Some(cur.u16("instantaneous speed")? as f32 / 100.0); + } + // 2. Average Speed. + if present(flag::AVERAGE_SPEED) { + out.average_speed_kph = Some(cur.u16("average speed")? as f32 / 100.0); + } + // 3. Instantaneous Cadence. + if present(flag::INSTANTANEOUS_CADENCE) { + out.instant_cadence_rpm = Some(cur.u16("instantaneous cadence")? as f32 / 2.0); + } + // 4. Average Cadence. + if present(flag::AVERAGE_CADENCE) { + out.average_cadence_rpm = Some(cur.u16("average cadence")? as f32 / 2.0); + } + // 5. Total Distance — uint24. + if present(flag::TOTAL_DISTANCE) { + out.total_distance_m = Some(cur.u24("total distance")?); + } + // 6. Resistance Level — sint16. + if present(flag::RESISTANCE_LEVEL) { + out.resistance_level = Some(cur.i16("resistance level")?); + } + // 7. Instantaneous Power — sint16 watts. + if present(flag::INSTANTANEOUS_POWER) { + out.instant_power_w = Some(cur.i16("instantaneous power")?); + } + // 8. Average Power — sint16 watts. + if present(flag::AVERAGE_POWER) { + out.average_power_w = Some(cur.i16("average power")?); + } + // 9. Expended Energy — three fields under one flag. + if present(flag::EXPENDED_ENERGY) { + out.total_energy_kcal = Some(cur.u16("total energy")?); + out.energy_per_hour_kcal = Some(cur.u16("energy per hour")?); + out.energy_per_minute_kcal = Some(cur.u8("energy per minute")?); + } + // 10. Heart Rate. + if present(flag::HEART_RATE) { + out.heart_rate_bpm = Some(cur.u8("heart rate")?); + } + // 11. Metabolic Equivalent — uint8, 0.1 resolution. + if present(flag::METABOLIC_EQUIVALENT) { + out.metabolic_equivalent = Some(cur.u8("metabolic equivalent")? as f32 / 10.0); + } + // 12. Elapsed Time. + if present(flag::ELAPSED_TIME) { + out.elapsed_time_s = Some(cur.u16("elapsed time")?); + } + // 13. Remaining Time. + if present(flag::REMAINING_TIME) { + out.remaining_time_s = Some(cur.u16("remaining time")?); + } + + out.consumed = cur.offset; + Ok(out) +} + +/// Format a byte slice as lowercase hex, for `NFR-8` style raw logging. +pub fn hex(data: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(data.len() * 2); + for b in data { + let _ = write!(s, "{b:02x}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::flag::*; + use super::*; + + /// Build a packet from a flags value and a payload. + fn packet(flags: u16, payload: &[u8]) -> Vec { + let mut v = flags.to_le_bytes().to_vec(); + v.extend_from_slice(payload); + v + } + + #[test] + fn speed_present_when_bit0_is_clear() { + // flags = 0x0000: no bits set at all -> speed present, nothing else. + // 3000 * 0.01 = 30.00 km/h + let pkt = packet(0x0000, &3000u16.to_le_bytes()); + let d = decode(&pkt).unwrap(); + assert_eq!(d.instant_speed_kph, Some(30.0)); + assert_eq!(d.consumed, 4); + assert_eq!(d.instant_power_w, None); + } + + #[test] + fn speed_absent_when_bit0_is_set() { + // MORE_DATA set -> NO speed field. Power follows the flags directly. + let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &200i16.to_le_bytes()); + let d = decode(&pkt).unwrap(); + assert_eq!(d.instant_speed_kph, None); + assert_eq!(d.instant_power_w, Some(200)); + assert_eq!(d.consumed, 4); + } + + /// The regression this whole module exists to prevent: if bit 0 were + /// treated as a normal present-when-set flag, the two power bytes would be + /// eaten by "speed" and power would decode as garbage (or fail). + #[test] + fn inverted_bit0_does_not_shift_later_fields() { + let pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &250i16.to_le_bytes()); + let d = decode(&pkt).unwrap(); + assert_eq!(d.instant_power_w, Some(250)); + + // And the mirrored case: bit 0 clear means speed IS there and power + // starts two bytes later. + let mut payload = 1234u16.to_le_bytes().to_vec(); // 12.34 km/h + payload.extend_from_slice(&250i16.to_le_bytes()); + let pkt = packet(INSTANTANEOUS_POWER, &payload); + let d = decode(&pkt).unwrap(); + assert_eq!(d.instant_speed_kph, Some(12.34)); + assert_eq!(d.instant_power_w, Some(250)); + } + + #[test] + fn typical_trainer_packet_speed_cadence_power() { + // A very common D100-class combination: speed (implicit), cadence, + // power. flags = cadence | power, bit 0 clear. + let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER; + let mut p = Vec::new(); + p.extend_from_slice(&2550u16.to_le_bytes()); // 25.50 km/h + p.extend_from_slice(&180u16.to_le_bytes()); // 90.0 rpm (0.5 resolution) + p.extend_from_slice(&213i16.to_le_bytes()); // 213 W + let d = decode(&packet(flags, &p)).unwrap(); + assert_eq!(d.instant_speed_kph, Some(25.5)); + assert_eq!(d.instant_cadence_rpm, Some(90.0)); + assert_eq!(d.instant_power_w, Some(213)); + assert_eq!(d.consumed, 8); + assert_eq!(d.consumed, packet(flags, &p).len()); + } + + #[test] + fn all_fields_present() { + let flags = AVERAGE_SPEED + | INSTANTANEOUS_CADENCE + | AVERAGE_CADENCE + | TOTAL_DISTANCE + | RESISTANCE_LEVEL + | INSTANTANEOUS_POWER + | AVERAGE_POWER + | EXPENDED_ENERGY + | HEART_RATE + | METABOLIC_EQUIVALENT + | ELAPSED_TIME + | REMAINING_TIME; // bit 0 clear -> instantaneous speed also present + + let mut p = Vec::new(); + p.extend_from_slice(&3512u16.to_le_bytes()); // instant speed 35.12 + p.extend_from_slice(&3000u16.to_le_bytes()); // average speed 30.00 + p.extend_from_slice(&191u16.to_le_bytes()); // cadence 95.5 + p.extend_from_slice(&180u16.to_le_bytes()); // avg cadence 90.0 + p.extend_from_slice(&[0x40, 0x0d, 0x03]); // distance uint24 LE = 0x030d40 = 200000 + p.extend_from_slice(&12i16.to_le_bytes()); // resistance 12 + p.extend_from_slice(&245i16.to_le_bytes()); // power 245 + p.extend_from_slice(&230i16.to_le_bytes()); // avg power 230 + p.extend_from_slice(&150u16.to_le_bytes()); // total energy 150 kcal + p.extend_from_slice(&600u16.to_le_bytes()); // energy/hour + p.push(10); // energy/minute + p.push(142); // heart rate + p.push(85); // MET 8.5 + p.extend_from_slice(&3600u16.to_le_bytes()); // elapsed 3600 s + p.extend_from_slice(&1800u16.to_le_bytes()); // remaining 1800 s + + let pkt = packet(flags, &p); + let d = decode(&pkt).unwrap(); + + assert_eq!(d.instant_speed_kph, Some(35.12)); + assert_eq!(d.average_speed_kph, Some(30.0)); + assert_eq!(d.instant_cadence_rpm, Some(95.5)); + assert_eq!(d.average_cadence_rpm, Some(90.0)); + assert_eq!(d.total_distance_m, Some(200_000)); + assert_eq!(d.resistance_level, Some(12)); + assert_eq!(d.instant_power_w, Some(245)); + assert_eq!(d.average_power_w, Some(230)); + assert_eq!(d.total_energy_kcal, Some(150)); + assert_eq!(d.energy_per_hour_kcal, Some(600)); + assert_eq!(d.energy_per_minute_kcal, Some(10)); + assert_eq!(d.heart_rate_bpm, Some(142)); + assert_eq!(d.metabolic_equivalent, Some(8.5)); + assert_eq!(d.elapsed_time_s, Some(3600)); + assert_eq!(d.remaining_time_s, Some(1800)); + // Every byte consumed: proof the field order and widths line up. + assert_eq!(d.consumed, pkt.len()); + } + + #[test] + fn total_distance_is_uint24_little_endian() { + // 0xAABBCC as uint24 LE is bytes CC BB AA. + let pkt = packet(MORE_DATA | TOTAL_DISTANCE, &[0xcc, 0xbb, 0xaa]); + let d = decode(&pkt).unwrap(); + assert_eq!(d.total_distance_m, Some(0x00AA_BBCC)); + assert_eq!(d.consumed, 5); + } + + #[test] + fn negative_power_and_resistance_are_signed() { + let flags = MORE_DATA | RESISTANCE_LEVEL | INSTANTANEOUS_POWER; + let mut p = Vec::new(); + p.extend_from_slice(&(-5i16).to_le_bytes()); + p.extend_from_slice(&(-30i16).to_le_bytes()); + let d = decode(&packet(flags, &p)).unwrap(); + assert_eq!(d.resistance_level, Some(-5)); + assert_eq!(d.instant_power_w, Some(-30)); + } + + #[test] + fn energy_block_is_three_fields_under_one_flag() { + // If EXPENDED_ENERGY were treated as a single uint16 the heart rate + // would come out wrong. This pins all five bytes. + let flags = MORE_DATA | EXPENDED_ENERGY | HEART_RATE; + let mut p = Vec::new(); + p.extend_from_slice(&321u16.to_le_bytes()); + p.extend_from_slice(&654u16.to_le_bytes()); + p.push(9); + p.push(155); + let d = decode(&packet(flags, &p)).unwrap(); + assert_eq!(d.total_energy_kcal, Some(321)); + assert_eq!(d.energy_per_hour_kcal, Some(654)); + assert_eq!(d.energy_per_minute_kcal, Some(9)); + assert_eq!(d.heart_rate_bpm, Some(155)); + } + + #[test] + fn flags_only_packet_with_more_data_set_is_valid_and_empty() { + let d = decode(&packet(MORE_DATA, &[])).unwrap(); + assert_eq!(d, IndoorBikeData { + flags: MORE_DATA, + consumed: 2, + ..Default::default() + }); + } + + #[test] + fn unknown_high_bits_are_ignored_not_fatal() { + // Bits 13-15 are RFU. A trainer setting them must not break decoding of + // the fields we do understand. + let flags = 0xE000 | MORE_DATA | INSTANTANEOUS_POWER; + let d = decode(&packet(flags, &100i16.to_le_bytes())).unwrap(); + assert_eq!(d.instant_power_w, Some(100)); + } + + #[test] + fn trailing_unknown_bytes_are_reported_not_fatal() { + let mut pkt = packet(MORE_DATA | INSTANTANEOUS_POWER, &100i16.to_le_bytes()); + pkt.extend_from_slice(&[0xde, 0xad]); + let d = decode(&pkt).unwrap(); + assert_eq!(d.instant_power_w, Some(100)); + assert_eq!(d.consumed, 4); + assert!(d.consumed < pkt.len()); + } + + #[test] + fn truncated_packet_is_an_error_not_a_panic() { + // Flags promise power but only one byte follows. + let err = decode(&packet(MORE_DATA | INSTANTANEOUS_POWER, &[0x01])).unwrap_err(); + assert_eq!( + err, + DecodeError::Truncated { + field: "instantaneous power", + offset: 2, + need: 2, + len: 3, + } + ); + } + + #[test] + fn short_packets_are_errors() { + assert_eq!(decode(&[]).unwrap_err(), DecodeError::MissingFlags { len: 0 }); + assert_eq!( + decode(&[0x00]).unwrap_err(), + DecodeError::MissingFlags { len: 1 } + ); + } + + #[test] + fn no_input_panics_across_every_flag_combination_and_length() { + // Exhaustive robustness sweep (NFR-4): every meaningful flags value + // against every payload length up to a full packet must either decode + // or return an error, never panic. + for flags in 0u16..=0x1FFF { + for len in 0..40usize { + let payload: Vec = (0..len).map(|i| i as u8).collect(); + let _ = decode(&packet(flags, &payload)); + } + } + } + + #[test] + fn to_telemetry_maps_the_core_contract() { + let flags = INSTANTANEOUS_CADENCE | INSTANTANEOUS_POWER | HEART_RATE; + let mut p = Vec::new(); + p.extend_from_slice(&2000u16.to_le_bytes()); + p.extend_from_slice(&170u16.to_le_bytes()); + p.extend_from_slice(&199i16.to_le_bytes()); + p.push(130); + let t = decode(&packet(flags, &p)).unwrap().to_telemetry(1234); + assert_eq!(t.elapsed_ms, 1234); + assert_eq!(t.speed_kph, Some(20.0)); + assert_eq!(t.cadence_rpm, Some(85.0)); + assert_eq!(t.power_w, Some(199)); + assert_eq!(t.heart_rate_bpm, Some(130)); + assert_eq!(t.total_distance_m, None); + } + + #[test] + fn hex_formats_lowercase_fixed_width() { + assert_eq!(hex(&[0x00, 0x0f, 0xff]), "000fff"); + } +} diff --git a/crates/ble/src/lib.rs b/crates/ble/src/lib.rs index 14f3033..e3989ab 100644 --- a/crates/ble/src/lib.rs +++ b/crates/ble/src/lib.rs @@ -1 +1,70 @@ //! FTMS client and BLE transport. See REQUIREMENTS.md §5.1–5.2. +//! +//! # Layout +//! +//! The crate is split so that everything protocol-shaped is a pure function +//! over bytes, and only [`client`] and [`scan`] touch a radio. That is what +//! lets the whole wire format be tested without a trainer on the desk: +//! +//! | Module | Contents | Needs hardware | +//! |--------|----------|----------------| +//! | [`uuids`] | FTMS assigned numbers | no | +//! | [`indoor_bike_data`] | `0x2AD2` decoder | no | +//! | [`control_point`] | `0x2AD9` encoders and response decoding | no | +//! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no | +//! | [`scan`] | discovery | yes | +//! | [`client`] | the connection actor | yes | +//! +//! # Usage +//! +//! ```no_run +//! use bikecontrol_ble::{FtmsClient, FtmsConfig, TrainerSelector}; +//! use bikecontrol_core::types::ControlTarget; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = FtmsClient::connect(TrainerSelector::Any, FtmsConfig::default()).await?; +//! +//! let mut telemetry = client.telemetry(); +//! tokio::spawn(async move { +//! while let Ok(sample) = telemetry.recv().await { +//! println!("{:?} W", sample.power_w); +//! } +//! }); +//! +//! client.set_target(ControlTarget::Gradient { percent: 4.0 }).await?; +//! +//! // SAF-2: always leave the trainer at zero. +//! client.shutdown().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Attribution +//! +//! The FTMS field ordering, scaling and control-point encodings are ported from +//! [`obostjancic/smart-trainer-control`](https://github.com/obostjancic/smart-trainer-control), +//! MIT licensed, Copyright (c) 2025 Ogi — a working Van Rysel D100 client +//! (REQUIREMENTS.md §3.2). Per-module attribution notes mark where. + +pub mod capabilities; +pub mod client; +pub mod control_point; +pub mod error; +pub mod indoor_bike_data; +pub mod scan; +pub mod uuids; + +pub use capabilities::{ + FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities, + UnsupportedTarget, +}; +pub use client::{ + safety_reset_commands, Backoff, ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent, Procedure, +}; +pub use control_point::{ControlPointResponse, OpCode, ResultCode, SimulationParameters, StopOrPause}; +pub use error::FtmsError; +pub use indoor_bike_data::{DecodeError, IndoorBikeData}; +pub use scan::{ + default_adapter, scan, scan_trainers, DiscoveredDevice, ScanKind, TrainerSelector, +}; +pub use uuids::FITNESS_MACHINE_SERVICE; diff --git a/crates/ble/src/scan.rs b/crates/ble/src/scan.rs new file mode 100644 index 0000000..0022bb4 --- /dev/null +++ b/crates/ble/src/scan.rs @@ -0,0 +1,319 @@ +//! BLE discovery (FR-1.1, FR-1.2). + +use std::collections::HashMap; +use std::time::Duration; + +use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter}; +use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; +use uuid::Uuid; + +use crate::error::FtmsError; +use crate::uuids; + +/// Zwift's custom service UUID, used to recognise Click pods during a scan +/// (FR-1.2). The Click *client* is Phase 3 and lives elsewhere; discovery only +/// needs the UUID so `probe scan` can label them. +pub const ZWIFT_SERVICE: Uuid = Uuid::from_fields( + 0x0000_0001, + 0x19CA, + 0x4651, + &[0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1], +); + +/// Zwift's Bluetooth SIG manufacturer ID (2378). +pub const ZWIFT_MANUFACTURER_ID: u16 = 0x094A; + +/// A peripheral seen during a scan. +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub id: PeripheralId, + /// Canonical lowercase MAC-style address string. + pub address: String, + pub name: Option, + pub rssi: Option, + pub tx_power: Option, + pub services: Vec, + pub manufacturer_data: HashMap>, + pub service_data: HashMap>, +} + +impl DiscoveredDevice { + /// True when the peripheral advertises the FTMS service (FR-1.2). + /// + /// Note that advertising is not mandatory: a trainer may expose FTMS + /// without listing it in its advertisement. `probe scan` therefore lists + /// everything, and connecting by address always works. + pub fn is_fitness_machine(&self) -> bool { + self.services.contains(&uuids::FITNESS_MACHINE_SERVICE) + } + + /// True when the peripheral looks like a Zwift controller. + pub fn is_zwift_device(&self) -> bool { + self.services.contains(&ZWIFT_SERVICE) + || self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID) + } + + /// Best-effort human label. + pub fn label(&self) -> String { + match &self.name { + Some(n) if !n.is_empty() => n.clone(), + _ => "(no name)".to_string(), + } + } +} + +/// Get the first Bluetooth adapter on the system. +pub async fn default_adapter() -> Result { + let manager = Manager::new().await?; + manager + .adapters() + .await? + .into_iter() + .next() + .ok_or(FtmsError::NoAdapter) +} + +/// What to scan for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanKind { + /// Every peripheral the adapter reports. + All, + /// Only peripherals advertising the FTMS service. + FitnessMachines, +} + +impl ScanKind { + fn filter(self) -> ScanFilter { + match self { + // An empty filter means "everything". Some backends require a + // filter for privacy reasons; on Linux/BlueZ an empty one is fine. + ScanKind::All => ScanFilter::default(), + ScanKind::FitnessMachines => ScanFilter { + services: vec![uuids::FITNESS_MACHINE_SERVICE], + }, + } + } +} + +/// Scan for `duration` and return everything seen. +/// +/// Devices may be asleep (A-4) — a trainer often does not advertise until it is +/// pedalled. An empty result means "nothing was advertising", not "no such +/// device exists"; FR-1.8 requires the UI to say so. +pub async fn scan( + adapter: &Adapter, + duration: Duration, + kind: ScanKind, +) -> Result, FtmsError> { + adapter.start_scan(kind.filter()).await?; + tokio::time::sleep(duration).await; + let peripherals = adapter.peripherals().await?; + // Stopping the scan is best effort; a failure here must not lose results. + if let Err(e) = adapter.stop_scan().await { + tracing::debug!(error = %e, "stop_scan failed"); + } + + let mut out = Vec::with_capacity(peripherals.len()); + for p in peripherals { + if let Some(d) = describe(&p).await { + if kind == ScanKind::FitnessMachines && !d.is_fitness_machine() { + // Some backends ignore the service filter; enforce it here too. + continue; + } + out.push(d); + } + } + out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN))); + Ok(out) +} + +/// Convenience wrapper: scan the default adapter for trainers. +pub async fn scan_trainers(duration: Duration) -> Result, FtmsError> { + let adapter = default_adapter().await?; + scan(&adapter, duration, ScanKind::FitnessMachines).await +} + +/// Snapshot a peripheral's advertisement data. +pub async fn describe(p: &Peripheral) -> Option { + let props = p.properties().await.ok().flatten()?; + Some(DiscoveredDevice { + id: p.id(), + address: props.address.to_string().to_lowercase(), + name: props.local_name.clone(), + rssi: props.rssi, + tx_power: props.tx_power_level, + services: props.services.clone(), + manufacturer_data: props.manufacturer_data.clone(), + service_data: props.service_data.clone(), + }) +} + +/// How to pick a trainer out of a scan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrainerSelector { + /// The first peripheral advertising FTMS. Fine when only one trainer is in + /// the room; ambiguous otherwise. + Any, + /// Match on address, case-insensitively (`AA:BB:CC:DD:EE:FF`, or the + /// platform's opaque identifier on macOS). + Address(String), + /// Match when the advertised local name contains this, case-insensitively. + NameContains(String), +} + +impl TrainerSelector { + /// Does this peripheral match the selector? + pub fn matches(&self, d: &DiscoveredDevice) -> bool { + self.matches_parts( + &d.address, + d.name.as_deref(), + d.is_fitness_machine(), + &format!("{:?}", d.id), + ) + } + + /// 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, + name: Option<&str>, + is_fitness_machine: bool, + id_debug: &str, + ) -> bool { + match self { + TrainerSelector::Any => is_fitness_machine, + TrainerSelector::Address(a) => { + address.eq_ignore_ascii_case(a) + || id_debug.to_lowercase().contains(&a.to_lowercase()) + } + TrainerSelector::NameContains(n) => name + .map(|name| name.to_lowercase().contains(&n.to_lowercase())) + .unwrap_or(false), + } + } + + /// Human-readable description, for error messages. + pub fn describe(&self) -> String { + match self { + TrainerSelector::Any => "any FTMS trainer".to_string(), + TrainerSelector::Address(a) => format!("address {a}"), + TrainerSelector::NameContains(n) => format!("name containing {n:?}"), + } + } + + /// A selector by address needs an unfiltered scan, because a peripheral is + /// not obliged to advertise the FTMS service. + fn scan_kind(&self) -> ScanKind { + match self { + TrainerSelector::Any => ScanKind::FitnessMachines, + _ => ScanKind::All, + } + } +} + +/// Scan until a peripheral matching `selector` appears, or `timeout` elapses. +pub async fn find_peripheral( + adapter: &Adapter, + selector: &TrainerSelector, + timeout: Duration, +) -> Result { + let kind = selector.scan_kind(); + adapter.start_scan(kind.filter()).await?; + + let deadline = tokio::time::Instant::now() + timeout; + let poll = Duration::from_millis(400); + let mut found: Option = None; + + 'search: loop { + for p in adapter.peripherals().await?.into_iter() { + if let Some(d) = describe(&p).await { + if selector.matches(&d) { + tracing::info!( + address = %d.address, + name = d.label(), + rssi = ?d.rssi, + "matched trainer" + ); + found = Some(p); + break 'search; + } + } + } + if tokio::time::Instant::now() >= deadline { + break 'search; + } + tokio::time::sleep(poll).await; + } + + if let Err(e) = adapter.stop_scan().await { + tracing::debug!(error = %e, "stop_scan failed"); + } + + found.ok_or_else(|| FtmsError::NotFound(selector.describe())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zwift_service_uuid_matches_the_spec() { + assert_eq!( + ZWIFT_SERVICE.to_string(), + "00000001-19ca-4651-86e5-fa29dcdd09d1" + ); + assert_eq!(ZWIFT_MANUFACTURER_ID, 2378); + } + + #[test] + fn selector_any_requires_the_ftms_service() { + let s = TrainerSelector::Any; + assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), true, "")); + assert!(!s.matches_parts("aa:bb:cc:dd:ee:ff", Some("D100"), false, "")); + } + + #[test] + fn selector_address_is_case_insensitive_and_ignores_advertised_services() { + let s = TrainerSelector::Address("AA:BB:CC:DD:EE:FF".into()); + assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", None, false, "")); + assert!(!s.matches_parts("11:22:33:44:55:66", None, true, "")); + } + + #[test] + fn selector_address_also_matches_an_opaque_platform_id() { + // macOS gives UUID-shaped PeripheralIds rather than MACs. + let s = TrainerSelector::Address("1E2F3A4B".into()); + assert!(s.matches_parts("", None, false, "PeripheralId(1e2f3a4b-....)")); + } + + #[test] + fn selector_name_is_a_case_insensitive_substring() { + let s = TrainerSelector::NameContains("d100".into()); + assert!(s.matches_parts("", Some("VAN RYSEL D100 4321"), false, "")); + assert!(!s.matches_parts("", Some("KICKR CORE"), true, "")); + assert!(!s.matches_parts("", None, true, "")); + } + + #[test] + fn selector_scan_kind_widens_for_address_and_name() { + assert_eq!(TrainerSelector::Any.scan_kind(), ScanKind::FitnessMachines); + assert_eq!( + TrainerSelector::Address("x".into()).scan_kind(), + ScanKind::All + ); + assert_eq!( + TrainerSelector::NameContains("x".into()).scan_kind(), + ScanKind::All + ); + } + + #[test] + fn scan_filter_for_fitness_machines_carries_the_ftms_uuid() { + assert_eq!( + ScanKind::FitnessMachines.filter().services, + vec![uuids::FITNESS_MACHINE_SERVICE] + ); + assert!(ScanKind::All.filter().services.is_empty()); + } +} diff --git a/crates/ble/src/uuids.rs b/crates/ble/src/uuids.rs new file mode 100644 index 0000000..90ed1de --- /dev/null +++ b/crates/ble/src/uuids.rs @@ -0,0 +1,152 @@ +//! Bluetooth SIG assigned UUIDs for the Fitness Machine Service (FTMS). +//! +//! All of these are 16-bit assigned numbers expanded onto the Bluetooth Base +//! UUID `0000xxxx-0000-1000-8000-00805F9B34FB`. + +use uuid::Uuid; + +/// The tail of the Bluetooth Base UUID, `0000xxxx-0000-1000-8000-00805F9B34FB`. +const BASE_D2: u16 = 0x0000; +const BASE_D3: u16 = 0x1000; +const BASE_D4: [u8; 8] = [0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb]; + +/// Expand a 16-bit Bluetooth SIG assigned number onto the Bluetooth Base UUID. +pub const fn uuid16(assigned: u16) -> Uuid { + Uuid::from_fields(assigned as u32, BASE_D2, BASE_D3, &BASE_D4) +} + +/// Fitness Machine Service — `0x1826`. Trainers are identified by advertising +/// this (FR-1.2). +pub const FITNESS_MACHINE_SERVICE: Uuid = uuid16(0x1826); + +/// Fitness Machine Feature — `0x2ACC`, read. +pub const FITNESS_MACHINE_FEATURE: Uuid = uuid16(0x2ACC); + +/// Indoor Bike Data — `0x2AD2`, notify. +pub const INDOOR_BIKE_DATA: Uuid = uuid16(0x2AD2); + +/// Training Status — `0x2AD3`, read/notify. +pub const TRAINING_STATUS: Uuid = uuid16(0x2AD3); + +/// Supported Speed Range — `0x2AD4`, read. +pub const SUPPORTED_SPEED_RANGE: Uuid = uuid16(0x2AD4); + +/// Supported Inclination Range — `0x2AD5`, read. +pub const SUPPORTED_INCLINATION_RANGE: Uuid = uuid16(0x2AD5); + +/// Supported Resistance Level Range — `0x2AD6`, read. +pub const SUPPORTED_RESISTANCE_LEVEL_RANGE: Uuid = uuid16(0x2AD6); + +/// Supported Heart Rate Range — `0x2AD7`, read. +pub const SUPPORTED_HEART_RATE_RANGE: Uuid = uuid16(0x2AD7); + +/// Supported Power Range — `0x2AD8`, read. +pub const SUPPORTED_POWER_RANGE: Uuid = uuid16(0x2AD8); + +/// Fitness Machine Control Point — `0x2AD9`, write + indicate. +pub const FITNESS_MACHINE_CONTROL_POINT: Uuid = uuid16(0x2AD9); + +/// Fitness Machine Status — `0x2ADA`, notify. +pub const FITNESS_MACHINE_STATUS: Uuid = uuid16(0x2ADA); + +/// Device Information Service — `0x180A`. Useful for the probe. +pub const DEVICE_INFORMATION_SERVICE: Uuid = uuid16(0x180A); + +/// Battery Service — `0x180F`. +pub const BATTERY_SERVICE: Uuid = uuid16(0x180F); + +/// Human-readable name for a well-known UUID, for logging and the probe CLI. +/// Returns `None` for anything not recognised. +pub fn well_known_name(uuid: Uuid) -> Option<&'static str> { + let name = match short_id(uuid)? { + 0x1826 => "Fitness Machine Service", + 0x2ACC => "Fitness Machine Feature", + 0x2AD2 => "Indoor Bike Data", + 0x2AD3 => "Training Status", + 0x2AD4 => "Supported Speed Range", + 0x2AD5 => "Supported Inclination Range", + 0x2AD6 => "Supported Resistance Level Range", + 0x2AD7 => "Supported Heart Rate Range", + 0x2AD8 => "Supported Power Range", + 0x2AD9 => "Fitness Machine Control Point", + 0x2ADA => "Fitness Machine Status", + 0x180A => "Device Information", + 0x180F => "Battery Service", + 0x1800 => "Generic Access", + 0x1801 => "Generic Attribute", + 0x180D => "Heart Rate", + 0x1818 => "Cycling Power", + 0x1816 => "Cycling Speed and Cadence", + 0x2A00 => "Device Name", + 0x2A19 => "Battery Level", + 0x2A24 => "Model Number String", + 0x2A25 => "Serial Number String", + 0x2A26 => "Firmware Revision String", + 0x2A27 => "Hardware Revision String", + 0x2A29 => "Manufacturer Name String", + _ => return None, + }; + Some(name) +} + +/// If `uuid` sits on the Bluetooth Base UUID, return its 16-bit assigned number. +pub fn short_id(uuid: Uuid) -> Option { + let (d1, d2, d3, d4) = uuid.as_fields(); + if d2 == BASE_D2 && d3 == BASE_D3 && *d4 == BASE_D4 && d1 <= u16::MAX as u32 { + Some(d1 as u16) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ftms_service_uuid_is_correct() { + assert_eq!( + FITNESS_MACHINE_SERVICE.to_string(), + "00001826-0000-1000-8000-00805f9b34fb" + ); + } + + #[test] + fn characteristic_uuids_are_correct() { + assert_eq!( + INDOOR_BIKE_DATA.to_string(), + "00002ad2-0000-1000-8000-00805f9b34fb" + ); + assert_eq!( + FITNESS_MACHINE_CONTROL_POINT.to_string(), + "00002ad9-0000-1000-8000-00805f9b34fb" + ); + assert_eq!( + FITNESS_MACHINE_FEATURE.to_string(), + "00002acc-0000-1000-8000-00805f9b34fb" + ); + assert_eq!( + SUPPORTED_RESISTANCE_LEVEL_RANGE.to_string(), + "00002ad6-0000-1000-8000-00805f9b34fb" + ); + } + + #[test] + fn short_id_round_trips() { + assert_eq!(short_id(uuid16(0x2AD2)), Some(0x2AD2)); + assert_eq!(short_id(FITNESS_MACHINE_SERVICE), Some(0x1826)); + // A vendor UUID (the Zwift custom service) is not on the base UUID. + let zwift = Uuid::parse_str("00000001-19CA-4651-86E5-FA29DCDD09D1").unwrap(); + assert_eq!(short_id(zwift), None); + } + + #[test] + fn well_known_names_resolve() { + assert_eq!( + well_known_name(INDOOR_BIKE_DATA), + Some("Indoor Bike Data") + ); + assert_eq!(well_known_name(uuid16(0x2A19)), Some("Battery Level")); + assert_eq!(well_known_name(uuid16(0xFF01)), None); + } +} diff --git a/crates/core/src/gpx.rs b/crates/core/src/gpx.rs index fa05c0c..632c76a 100644 --- a/crates/core/src/gpx.rs +++ b/crates/core/src/gpx.rs @@ -6,7 +6,14 @@ //! Elevation must be smoothed before gradients are derived, and the result //! clamped (FR-5.3). -use crate::profile::{Profile, TerrainPoint}; +use crate::profile::{Block, Profile, TerrainPoint}; + +/// Mean Earth radius (IUGG), metres. +const EARTH_RADIUS_M: f64 = 6_371_008.8; + +/// Upper bound on resampled points, so a 300 km track with a 10 cm spacing +/// cannot allocate gigabytes. Exceeding it widens the spacing instead. +const MAX_RESAMPLED_POINTS: usize = 200_000; /// A single trackpoint read from a GPX file. #[derive(Debug, Clone, Copy, PartialEq)] @@ -54,27 +61,865 @@ pub enum GpxError { /// Must tolerate real-world GPX: `//` and `/`, /// missing `` on some points, multiple segments, and namespaced documents. pub fn parse(xml: &str) -> Result, GpxError> { - let _ = xml; - todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") + let doc = roxmltree::Document::parse(xml).map_err(|e| GpxError::Malformed(e.to_string()))?; + + // Match on the local name only: GPX 1.0 and 1.1 use different namespace + // URIs and plenty of files in the wild declare neither. + let mut coords: Vec<(f64, f64)> = Vec::new(); + let mut elevations: Vec> = Vec::new(); + + for node in doc.descendants() { + if !node.is_element() { + continue; + } + let name = node.tag_name().name(); + if name != "trkpt" && name != "rtept" && name != "wpt" { + continue; + } + // A waypoint outside a track or route is a POI, not part of the line. + if name == "wpt" && !has_ancestor(node, &["trkseg", "trk", "rte"]) { + continue; + } + let (Some(lat), Some(lon)) = ( + node.attribute("lat") + .and_then(|v| v.trim().parse::().ok()), + node.attribute("lon") + .and_then(|v| v.trim().parse::().ok()), + ) else { + // Tolerate a junk point rather than losing the whole file. + continue; + }; + if !lat.is_finite() || !lon.is_finite() { + continue; + } + + let ele = node + .children() + .find(|c| c.is_element() && c.tag_name().name() == "ele") + .and_then(|c| c.text()) + .and_then(|t| t.trim().parse::().ok()) + .filter(|v| v.is_finite()); + + coords.push((lat, lon)); + elevations.push(ele); + } + + if coords.is_empty() { + return Ok(Vec::new()); + } + if elevations.iter().all(Option::is_none) { + return Err(GpxError::NoElevation); + } + + let filled = fill_missing_elevations(&elevations); + Ok(coords + .into_iter() + .zip(filled) + .map(|((lat_deg, lon_deg), elevation_m)| TrackPoint { + lat_deg, + lon_deg, + elevation_m, + }) + .collect()) +} + +fn has_ancestor(node: roxmltree::Node<'_, '_>, names: &[&str]) -> bool { + node.ancestors() + .any(|a| a.is_element() && names.contains(&a.tag_name().name())) +} + +/// Points without `` are bridged from their neighbours rather than +/// dropped — dropping them would corrupt the distance axis, and a hole in the +/// elevation series would read as a cliff once differentiated. +fn fill_missing_elevations(elevations: &[Option]) -> Vec { + let mut out = vec![0.0f32; elevations.len()]; + let mut last_known: Option<(usize, f32)> = None; + + for (i, known) in elevations.iter().enumerate() { + let Some(value) = *known else { continue }; + match last_known { + // Linearly bridge the gap by index. + Some((prev_index, prev_value)) => { + let span = (i - prev_index) as f32; + for (offset, slot) in out[prev_index + 1..i].iter_mut().enumerate() { + let f = (offset + 1) as f32 / span; + *slot = prev_value + (value - prev_value) * f; + } + } + // Leading gap: hold the first known value backwards. + None => { + for slot in out.iter_mut().take(i) { + *slot = value; + } + } + } + out[i] = value; + last_known = Some((i, value)); + } + + // Trailing gap: hold the last known value forwards. + if let Some((last_index, last_value)) = last_known { + for slot in out.iter_mut().skip(last_index + 1) { + *slot = last_value; + } + } + out } /// Great-circle distance between two points, in metres. pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 { - let _ = (a, b); - todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") + let lat1 = a.lat_deg.to_radians(); + let lat2 = b.lat_deg.to_radians(); + let dlat = lat2 - lat1; + let dlon = (b.lon_deg - a.lon_deg).to_radians(); + + let h = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2); + let d = 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin(); + if d.is_finite() { + d + } else { + 0.0 + } } /// Turn track points into a smoothed, clamped gradient profile. +/// +/// The pipeline is deliberate and its order matters (FR-5.2): +/// +/// 1. Accumulate ground distance with the haversine formula, discarding +/// repeated fixes so the distance axis is strictly increasing. +/// 2. Resample elevation onto an even `resample_m` grid. Uneven GPS spacing +/// otherwise weights a stationary cluster of fixes as heavily as a fast +/// descent. +/// 3. Smooth elevation with two cascaded centred moving averages `window_m` +/// wide, over a reflected extension so the window never truncates at the +/// ends. Consumer GPS elevation carries metres of noise; differentiating it +/// directly gives gradients swinging tens of percent between neighbours. +/// 4. Differentiate over the *same* window rather than between neighbours. A +/// neighbour difference re-amplifies whatever noise survived smoothing; +/// taking the rise across ±half a window makes the run large enough that +/// residual noise is a fraction of a percent. +/// 5. Clamp to the configured range (FR-5.3). +/// +/// On the ±1.5 m fixture in `testdata/` this holds the largest gradient change +/// between adjacent 10 m samples under 1%, while recovering the route's real +/// 6–8% climb and −4.5% descent. pub fn to_terrain( points: &[TrackPoint], cfg: &SmoothingConfig, ) -> Result, GpxError> { - let _ = (points, cfg); - todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") + if points.len() < 2 { + return Err(GpxError::TooShort); + } + + // 1. Cumulative ground distance, dropping non-advancing fixes. + let mut cum_m: Vec = Vec::with_capacity(points.len()); + let mut raw_ele: Vec = Vec::with_capacity(points.len()); + cum_m.push(0.0); + raw_ele.push(points[0].elevation_m); + let mut total = 0.0f64; + for pair in points.windows(2) { + let step = haversine_m(pair[0], pair[1]); + if !step.is_finite() || step <= 0.0 { + continue; + } + total += step; + cum_m.push(total); + raw_ele.push(pair[1].elevation_m); + } + if cum_m.len() < 2 || total <= 0.0 { + return Err(GpxError::TooShort); + } + + // 2. Even resampling. + let mut spacing = if cfg.resample_m.is_finite() && cfg.resample_m > 0.0 { + cfg.resample_m + } else { + SmoothingConfig::default().resample_m + }; + if total / spacing > MAX_RESAMPLED_POINTS as f64 { + spacing = total / MAX_RESAMPLED_POINTS as f64; + } + let count = (total / spacing).floor() as usize + 1; + if count < 3 { + return Err(GpxError::TooShort); + } + + let mut grid_ele = Vec::with_capacity(count); + let mut cursor = 0usize; + for i in 0..count { + let x = i as f64 * spacing; + while cursor + 2 < cum_m.len() && cum_m[cursor + 1] < x { + cursor += 1; + } + let (x0, x1) = (cum_m[cursor], cum_m[cursor + 1]); + let (y0, y1) = (raw_ele[cursor], raw_ele[cursor + 1]); + let span = x1 - x0; + let f = if span > 0.0 { + ((x - x0) / span).clamp(0.0, 1.0) as f32 + } else { + 0.0 + }; + grid_ele.push(y0 + (y1 - y0) * f); + } + + // 3. Smooth, over a reflected extension of the series so that the window + // stays full width at the ends. Truncating the window instead leaves + // the first and last samples barely smoothed, and since step 4 reads + // exactly those samples the route would open and close with a gradient + // spike — the very thing this module exists to prevent. + // + // Two passes, not one. A single boxcar has a poor stopband: neighbouring + // windows share all but two samples, so the residual after one pass is + // strongly correlated and re-emerges as a step change once + // differentiated. Cascading two boxcars gives a triangular kernel, + // which cuts that step-to-step residual by roughly a factor of five + // while still reproducing a constant gradient exactly. + let window_m = if cfg.window_m.is_finite() && cfg.window_m > 0.0 { + cfg.window_m + } else { + SmoothingConfig::default().window_m + }; + let half = (((window_m / spacing) * 0.5).round().max(1.0) as usize).min(count - 1); + // Two smoothing passes and the derivative each eat `half` at both ends. + let pad = 3 * half; + let padded = reflect_pad(&grid_ele, pad); + let smoothed = moving_average(&moving_average(&padded, half), half); + + // 4. Differentiate over the smoothing window, then 5. clamp. + let (lo, hi) = gradient_bounds(cfg); + let run = 2.0 * half as f64 * spacing; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let centre = i + pad; + let gradient = if run > 0.0 { + 100.0 * (smoothed[centre + half] - smoothed[centre - half]) as f64 / run + } else { + 0.0 + }; + out.push(TerrainPoint { + distance_m: i as f64 * spacing, + gradient_pct: (gradient as f32).clamp(lo, hi), + elevation_m: smoothed[centre], + }); + } + Ok(out) +} + +/// Extend a series by `pad` samples at each end by reflecting *through* the +/// endpoint rather than about it: `x[-k] = 2·x[0] − x[k]`. +/// +/// A plain mirror would fold a climb back on itself and read as a summit at +/// the trailhead. Reflecting through the endpoint continues the local trend +/// instead, so a constant gradient stays constant right to the edge. +fn reflect_pad(src: &[f32], pad: usize) -> Vec { + let n = src.len(); + debug_assert!(n > 0); + let last = n - 1; + let mut out = Vec::with_capacity(n + 2 * pad); + for k in (1..=pad).rev() { + out.push(2.0 * src[0] - src[k.min(last)]); + } + out.extend_from_slice(src); + for k in 1..=pad { + out.push(2.0 * src[last] - src[last.saturating_sub(k)]); + } + out +} + +/// A config with the bounds the wrong way round should not produce an empty +/// clamp range and a stream of NaN. +fn gradient_bounds(cfg: &SmoothingConfig) -> (f32, f32) { + let defaults = SmoothingConfig::default(); + let lo = if cfg.min_gradient_pct.is_finite() { + cfg.min_gradient_pct + } else { + defaults.min_gradient_pct + }; + let hi = if cfg.max_gradient_pct.is_finite() { + cfg.max_gradient_pct + } else { + defaults.max_gradient_pct + }; + if lo <= hi { + (lo, hi) + } else { + (hi, lo) + } +} + +/// Centred moving average over `2·half + 1` samples, with the window truncated +/// symmetrically at the ends so the series is not phase-shifted. Prefix sums +/// in f64 keep it O(n) without losing precision on long tracks. +fn moving_average(src: &[f32], half: usize) -> Vec { + let n = src.len(); + let mut prefix = Vec::with_capacity(n + 1); + prefix.push(0.0f64); + for &v in src { + prefix.push(prefix[prefix.len() - 1] + v as f64); + } + + let mut out = Vec::with_capacity(n); + for i in 0..n { + // Shrink from both sides equally near an edge, so the window stays + // centred on `i`. + let reach = half.min(i).min(n - 1 - i); + let a = i - reach; + let b = i + reach; + let sum = prefix[b + 1] - prefix[a]; + out.push((sum / (b - a + 1) as f64) as f32); + } + out } /// Convenience: GPX document to a ready-to-ride single-block profile. pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result { - let _ = (xml, name, cfg); - todo!("implemented in crates/core/src/gpx.rs — see AGENT task A") + let points = parse(xml)?; + let terrain = to_terrain(&points, cfg)?; + Ok(Profile { + name: name.to_string(), + description: Some(format!( + "Imported from GPX: {:.1} km", + terrain.last().map(|p| p.distance_m).unwrap_or(0.0) / 1000.0 + )), + blocks: vec![Block::Terrain { points: terrain }], + // FR-5.6 leaves the choice to the rider; a real route finishes. + looping: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_CLIMB: &str = include_str!("../../../testdata/sample-climb.gpx"); + + fn point(lat: f64, lon: f64, ele: f32) -> TrackPoint { + TrackPoint { + lat_deg: lat, + lon_deg: lon, + elevation_m: ele, + } + } + + // ---- haversine ------------------------------------------------------- + + #[test] + fn haversine_matches_one_degree_of_latitude() { + // A degree of latitude on a sphere of radius R is π·R/180. + let d = haversine_m(point(0.0, 0.0, 0.0), point(1.0, 0.0, 0.0)); + let expected = std::f64::consts::PI * EARTH_RADIUS_M / 180.0; + assert!((d - expected).abs() < 1.0, "{d} vs {expected}"); + assert!((d - 111_194.9).abs() < 2.0); + } + + #[test] + fn haversine_matches_a_known_city_pair() { + // Paris (Notre-Dame) to London (Charing Cross), ~343 km great circle. + let paris = point(48.8530, 2.3499, 0.0); + let london = point(51.5074, -0.1278, 0.0); + let d = haversine_m(paris, london) / 1000.0; + assert!((d - 343.0).abs() < 3.0, "{d} km"); + } + + #[test] + fn haversine_is_symmetric_and_zero_for_identical_points() { + let a = point(45.0, 6.0, 100.0); + let b = point(45.001, 6.001, 100.0); + assert_eq!(haversine_m(a, a), 0.0); + assert!((haversine_m(a, b) - haversine_m(b, a)).abs() < 1e-9); + } + + #[test] + fn haversine_shrinks_with_latitude_for_a_fixed_longitude_step() { + let equator = haversine_m(point(0.0, 0.0, 0.0), point(0.0, 1.0, 0.0)); + let high = haversine_m(point(60.0, 0.0, 0.0), point(60.0, 1.0, 0.0)); + // cos(60°) = 0.5. + assert!((high / equator - 0.5).abs() < 1e-3); + } + + // ---- parsing --------------------------------------------------------- + + #[test] + fn parses_a_namespaced_track() { + let xml = r#" + + t + 100.0 + 105.0 + +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[0], point(45.0, 6.0, 100.0)); + assert_eq!(points[1].elevation_m, 105.0); + } + + #[test] + fn parses_a_prefixed_namespace() { + let xml = r#" + + 10 + 20 + "#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[1].elevation_m, 20.0); + } + + #[test] + fn parses_multiple_segments_in_order() { + let xml = r#" + + 100 + 110 + + + 120 + +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 3); + assert_eq!(points[2].elevation_m, 120.0); + } + + #[test] + fn parses_a_route_rather_than_a_track() { + let xml = r#" + 100 + 110 +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 2); + } + + #[test] + fn standalone_waypoints_are_ignored() { + let xml = r#" + 999café + + 100 + 110 + +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[0].lat_deg, 45.0); + } + + #[test] + fn missing_elevation_is_bridged_from_neighbours() { + let xml = r#" + 100 + + + 130 +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 4); + assert!((points[1].elevation_m - 110.0).abs() < 1e-4); + assert!((points[2].elevation_m - 120.0).abs() < 1e-4); + } + + #[test] + fn leading_and_trailing_missing_elevation_are_held() { + let xml = r#" + + 100 + 200 + +"#; + let points = parse(xml).unwrap(); + assert_eq!(points[0].elevation_m, 100.0); + assert_eq!(points[3].elevation_m, 200.0); + } + + #[test] + fn a_track_with_no_elevation_at_all_is_an_error() { + let xml = r#" + + +"#; + assert!(matches!(parse(xml), Err(GpxError::NoElevation))); + } + + #[test] + fn broken_xml_is_reported_not_panicked() { + assert!(matches!(parse(""), Err(GpxError::Malformed(_)))); + assert!(matches!(parse(""), Err(GpxError::Malformed(_)))); + } + + #[test] + fn points_with_unparseable_coordinates_are_skipped() { + let xml = r#" + 100 + 105 + 106 + 110 +"#; + let points = parse(xml).unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[1].elevation_m, 110.0); + } + + #[test] + fn an_empty_gpx_yields_no_points() { + assert!(parse("").unwrap().is_empty()); + } + + // ---- smoothing ------------------------------------------------------- + + /// Build a track running due north with a prescribed elevation series, + /// spaced roughly `spacing_m` apart. + fn synthetic_track(elevations: &[f32], spacing_m: f64) -> Vec { + let dlat = spacing_m / (std::f64::consts::PI * EARTH_RADIUS_M / 180.0); + elevations + .iter() + .enumerate() + .map(|(i, &e)| point(45.0 + i as f64 * dlat, 6.0, e)) + .collect() + } + + /// Deterministic pseudo-noise; no rand dependency in the core crate. + fn noise(i: usize) -> f32 { + let x = (i as f32 * 12.9898).sin() * 43758.547; + (x - x.floor()) * 2.0 - 1.0 + } + + /// The largest gradient change between adjacent samples — the quantity a + /// rider feels as a lurch. + fn worst_gradient_step(terrain: &[TerrainPoint]) -> f32 { + terrain + .windows(2) + .map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs()) + .fold(0.0f32, f32::max) + } + + /// Mean gradient over a distance range, for asserting on route structure. + fn mean_gradient(terrain: &[TerrainPoint], from_m: f64, to_m: f64) -> f32 { + let values: Vec = terrain + .iter() + .filter(|p| p.distance_m >= from_m && p.distance_m <= to_m) + .map(|p| p.gradient_pct) + .collect(); + assert!(!values.is_empty(), "no samples in {from_m}..{to_m} m"); + values.iter().sum::() / values.len() as f32 + } + + #[test] + fn noisy_elevation_yields_smooth_bounded_gradients() { + // A true 5% climb, 2 km long, buried in ±3 m of GPS elevation noise — + // differentiating this raw would swing by ±60% between samples. + let spacing = 10.0; + let elevations: Vec = (0..200) + .map(|i| 1000.0 + i as f32 * spacing as f32 * 0.05 + noise(i) * 3.0) + .collect(); + let track = synthetic_track(&elevations, spacing); + + let cfg = SmoothingConfig::default(); + let terrain = to_terrain(&track, &cfg).unwrap(); + assert!(terrain.len() > 100); + + for p in &terrain { + assert!(p.gradient_pct.is_finite()); + assert!( + (cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct), + "gradient {} escaped the clamp", + p.gradient_pct + ); + } + + // Smooth: no violent sample-to-sample steps, anywhere including the + // ends, where a truncated window would otherwise leave a spike. + let worst_step = worst_gradient_step(&terrain); + // At 10 m spacing and 20 km/h that is well under 0.5 %/s of gradient + // change — below the trainer's own resolution, let alone the rider's. + assert!( + worst_step < 0.75, + "gradient jumped by {worst_step}% in one step" + ); + + // Accurate: the interior tracks the true 5%. + let interior = &terrain[20..terrain.len() - 20]; + let mean: f32 = + interior.iter().map(|p| p.gradient_pct).sum::() / interior.len() as f32; + assert!( + (mean - 5.0).abs() < 0.5, + "mean gradient {mean}%, expected 5%" + ); + for p in interior { + assert!( + (p.gradient_pct - 5.0).abs() < 2.0, + "noise survived smoothing: {}%", + p.gradient_pct + ); + } + } + + #[test] + fn naive_differentiation_would_have_failed_the_same_data() { + // Guards the test above from being vacuous: confirm the input really + // is too noisy to differentiate directly. + let spacing = 10.0f32; + let elevations: Vec = (0..200) + .map(|i| 1000.0 + i as f32 * spacing * 0.05 + noise(i) * 3.0) + .collect(); + let worst = elevations + .windows(2) + .map(|w| ((w[1] - w[0]) / spacing * 100.0).abs()) + .fold(0.0f32, f32::max); + assert!( + worst > 30.0, + "test data is not actually noisy (peak {worst}%)" + ); + } + + #[test] + fn a_flat_track_produces_zero_gradient() { + let track = synthetic_track(&[100.0; 100], 10.0); + let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap(); + assert!(terrain.iter().all(|p| p.gradient_pct.abs() < 1e-3)); + } + + #[test] + fn a_clean_ramp_recovers_its_true_gradient() { + // 8% over 3 km, no noise. + let elevations: Vec = (0..300).map(|i| 500.0 + i as f32 * 10.0 * 0.08).collect(); + let track = synthetic_track(&elevations, 10.0); + let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap(); + let interior = &terrain[15..terrain.len() - 15]; + for p in interior { + assert!((p.gradient_pct - 8.0).abs() < 0.2, "{}", p.gradient_pct); + } + } + + #[test] + fn gradients_are_clamped_to_the_configured_range() { + // A 40% wall — far beyond anything safe to send to a trainer. + let elevations: Vec = (0..200).map(|i| i as f32 * 10.0 * 0.4).collect(); + let track = synthetic_track(&elevations, 10.0); + let cfg = SmoothingConfig { + min_gradient_pct: -8.0, + max_gradient_pct: 12.0, + ..Default::default() + }; + let terrain = to_terrain(&track, &cfg).unwrap(); + assert!(terrain.iter().all(|p| p.gradient_pct <= 12.0)); + assert!(terrain.iter().all(|p| p.gradient_pct >= -8.0)); + assert!(terrain.iter().any(|p| (p.gradient_pct - 12.0).abs() < 1e-4)); + } + + #[test] + fn descents_produce_negative_gradients() { + let elevations: Vec = (0..200).map(|i| 1000.0 - i as f32 * 10.0 * 0.06).collect(); + let track = synthetic_track(&elevations, 10.0); + let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap(); + let mid = terrain[terrain.len() / 2].gradient_pct; + assert!((mid + 6.0).abs() < 0.2, "{mid}"); + } + + #[test] + fn a_wider_window_gives_a_smoother_result() { + let elevations: Vec = (0..400) + .map(|i| 1000.0 + i as f32 * 0.3 + noise(i) * 4.0) + .collect(); + let track = synthetic_track(&elevations, 10.0); + + let roughness = |window_m: f64| { + let cfg = SmoothingConfig { + window_m, + ..Default::default() + }; + let terrain = to_terrain(&track, &cfg).unwrap(); + terrain + .windows(2) + .map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs()) + .sum::() + }; + assert!(roughness(200.0) < roughness(30.0)); + } + + #[test] + fn distance_axis_is_evenly_spaced_and_monotone() { + let track = synthetic_track(&[100.0; 150], 7.0); + let cfg = SmoothingConfig { + resample_m: 25.0, + ..Default::default() + }; + let terrain = to_terrain(&track, &cfg).unwrap(); + for (i, p) in terrain.iter().enumerate() { + assert!((p.distance_m - i as f64 * 25.0).abs() < 1e-6); + } + } + + #[test] + fn stationary_and_duplicate_fixes_are_discarded() { + let mut track = synthetic_track(&[100.0, 105.0, 110.0, 115.0], 100.0); + // Insert repeats of the second fix, as a GPS does at a traffic light. + for _ in 0..20 { + track.insert(2, track[1]); + } + let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap(); + assert!(terrain.iter().all(|p| p.gradient_pct.is_finite())); + assert!((terrain.last().unwrap().distance_m - 300.0).abs() < 15.0); + } + + #[test] + fn short_or_degenerate_tracks_are_rejected() { + assert!(matches!( + to_terrain(&[], &SmoothingConfig::default()), + Err(GpxError::TooShort) + )); + assert!(matches!( + to_terrain(&[point(45.0, 6.0, 100.0)], &SmoothingConfig::default()), + Err(GpxError::TooShort) + )); + // Two identical points: no distance at all. + let same = [point(45.0, 6.0, 100.0), point(45.0, 6.0, 100.0)]; + assert!(matches!( + to_terrain(&same, &SmoothingConfig::default()), + Err(GpxError::TooShort) + )); + // Real but far shorter than one resample step. + let tiny = synthetic_track(&[100.0, 101.0], 2.0); + assert!(matches!( + to_terrain(&tiny, &SmoothingConfig::default()), + Err(GpxError::TooShort) + )); + } + + #[test] + fn a_degenerate_config_falls_back_rather_than_dividing_by_zero() { + let track = synthetic_track(&[100.0, 110.0, 120.0, 130.0, 140.0], 100.0); + let cfg = SmoothingConfig { + resample_m: 0.0, + window_m: -5.0, + min_gradient_pct: 15.0, + max_gradient_pct: -10.0, + }; + let terrain = to_terrain(&track, &cfg).unwrap(); + assert!(!terrain.is_empty()); + assert!(terrain + .iter() + .all(|p| p.gradient_pct.is_finite() && (-10.0..=15.0).contains(&p.gradient_pct))); + } + + // ---- end to end ------------------------------------------------------ + + #[test] + fn the_shipped_sample_climb_imports_cleanly() { + let points = parse(SAMPLE_CLIMB).unwrap(); + assert!(points.len() > 100, "{} points", points.len()); + + let cfg = SmoothingConfig::default(); + let profile = import(SAMPLE_CLIMB, "Sample climb", &cfg).unwrap(); + assert_eq!(profile.name, "Sample climb"); + assert!(!profile.looping); + assert_eq!(profile.blocks.len(), 1); + profile.validate().unwrap(); + + let Block::Terrain { points: terrain } = &profile.blocks[0] else { + panic!("expected a terrain block"); + }; + assert!(terrain.len() > 10); + for p in terrain { + assert!(p.gradient_pct.is_finite()); + assert!((cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct)); + } + // The fixture is noisy but is a genuine climb, so the mean must be up. + let mean: f32 = terrain.iter().map(|p| p.gradient_pct).sum::() / terrain.len() as f32; + assert!(mean > 0.0, "sample climb averaged {mean}%"); + + // And the profile it produces is rideable. + let extent = profile.total_extent(); + assert!(extent.metres.unwrap_or(0.0) > 100.0); + assert!(profile + .sample(crate::profile::Position { + elapsed_s: 0.0, + distance_m: 50.0, + }) + .is_some()); + } + + /// The fixture carries ±1.5 m of elevation noise on every point over a + /// route with known structure: ~500 m flat, ~1.8 km climbing at 6–8% + /// (sinusoidally varying), then ~700 m descending at about −4.5%. The + /// pipeline has to recover that structure, not the noise. + #[test] + fn the_shipped_sample_climb_recovers_its_real_structure() { + let cfg = SmoothingConfig::default(); + let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap(); + let total = terrain.last().unwrap().distance_m; + assert!((total - 3040.0).abs() < 100.0, "route measured {total} m"); + + // No lurches anywhere on the route, ends included. + let worst_step = worst_gradient_step(&terrain); + assert!( + worst_step < 1.0, + "gradient jumped by {worst_step}% in one step" + ); + + // Opening flat. + let flat = mean_gradient(&terrain, 0.0, 400.0); + assert!(flat.abs() < 1.0, "flat section read {flat}%"); + + // The climb, sampled clear of the transitions at either end. + let climb = mean_gradient(&terrain, 700.0, 2200.0); + assert!((3.0..8.0).contains(&climb), "climb averaged {climb}%"); + for p in terrain + .iter() + .filter(|p| (700.0..=2200.0).contains(&p.distance_m)) + { + assert!( + (2.0..10.0).contains(&p.gradient_pct), + "climb sample at {} m read {}%", + p.distance_m, + p.gradient_pct + ); + } + + // The closing descent. + let descent = mean_gradient(&terrain, 2500.0, 2900.0); + assert!( + (-6.0..-3.0).contains(&descent), + "descent averaged {descent}%" + ); + + // Net ascent, integrated from the smoothed gradient, matches the route. + let spacing = cfg.resample_m as f32; + let ascent: f32 = terrain + .iter() + .map(|p| (p.gradient_pct / 100.0 * spacing).max(0.0)) + .sum(); + assert!((60.0..110.0).contains(&ascent), "net ascent {ascent} m"); + } + + #[test] + fn the_shipped_sample_climb_survives_a_tight_smoothing_window() { + // Even at a third of the default window the result must stay usable: + // noisier, but still free of step changes a rider would feel. + let cfg = SmoothingConfig { + window_m: 30.0, + ..Default::default() + }; + let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap(); + let worst_step = worst_gradient_step(&terrain); + assert!( + worst_step < 4.0, + "gradient jumped by {worst_step}% in one step" + ); + assert!(terrain.iter().all(|p| p.gradient_pct.is_finite())); + } + + #[test] + fn import_propagates_parse_errors() { + assert!(matches!( + import("", "n", &SmoothingConfig::default()), + Err(GpxError::Malformed(_)) + )); + assert!(matches!( + import("", "n", &SmoothingConfig::default()), + Err(GpxError::TooShort) + )); + } } diff --git a/crates/core/src/physics.rs b/crates/core/src/physics.rs index c759947..b1da8b3 100644 --- a/crates/core/src/physics.rs +++ b/crates/core/src/physics.rs @@ -23,6 +23,24 @@ pub const GRAVITY: f32 = 9.80665; /// below which the rider is considered stopped. pub const MIN_SPEED_MPS: f32 = 0.5; +/// Absolute ceiling on virtual speed, ~144 km/h. Aerodynamic drag bounds the +/// model well below this for any plausible input; the cap exists so that +/// absurd configuration (CdA of zero, a 90% descent) still cannot run away. +pub const MAX_SPEED_MPS: f32 = 40.0; + +/// Longest tick the integrator will honour. A caller that stalls for a minute +/// must not be allowed to teleport the rider down a mountain. +const MAX_DT_S: f32 = 10.0; + +/// The integrator sub-divides the caller's `dt` to this resolution. Forward +/// Euler on `P/v` is stiff at low speed, so the result would otherwise depend +/// on how often the caller happens to tick; sub-stepping makes a 1 Hz tick and +/// a 4 Hz tick agree. +const SUBSTEP_S: f32 = 0.02; + +/// Gradients beyond this are not physical roads and only appear as bad input. +const MAX_ABS_GRADIENT_PCT: f32 = 100.0; + /// Evolving physical state of the virtual rider. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct PhysicsState { @@ -41,8 +59,50 @@ impl PhysicsState { /// rather than snapping to it — and must never produce 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 _ = (power_w, gradient_pct, cfg, dt); - todo!("implemented in crates/core/src/physics.rs — see AGENT task A") + let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S); + if dt <= 0.0 { + return; + } + + let forces = Forces::new(power_w, gradient_pct, cfg); + + // Recover from a poisoned state rather than propagating it: a single + // bad tick must not permanently wedge the ride. + if !self.speed_mps.is_finite() { + self.speed_mps = 0.0; + } + if !self.distance_m.is_finite() { + self.distance_m = 0.0; + } + if !self.elevation_gain_m.is_finite() { + self.elevation_gain_m = 0.0; + } + + let steps = (dt / SUBSTEP_S).ceil().max(1.0); + let h = dt / steps; + let steps = steps as u32; + + for _ in 0..steps { + let v0 = self.speed_mps.clamp(0.0, MAX_SPEED_MPS); + let v1 = (v0 + forces.acceleration(v0) * h).clamp(0.0, MAX_SPEED_MPS); + self.speed_mps = v1; + + // Trapezoidal: with forward Euler on velocity this is the exact + // integral of the linear velocity ramp over the sub-step. + let ds = (0.5 * (v0 + v1) * h) as f64; + self.distance_m += ds; + + // `ds` is measured along the road surface, so the vertical + // component is sin(θ). Only ascent counts (FR-7.6). + let climb = ds as f32 * forces.sin_theta; + if climb > 0.0 { + self.elevation_gain_m += climb; + } + } + + if !self.speed_mps.is_finite() { + self.speed_mps = 0.0; + } } pub fn speed_kph(&self) -> f32 { @@ -54,10 +114,375 @@ impl PhysicsState { } } +/// The speed-independent parts of the force balance, computed once per tick. +struct Forces { + /// `P × efficiency`; divided by speed to give propulsive force. + wheel_power_w: f32, + sin_theta: f32, + /// Gravity plus rolling resistance, newtons. Constant in speed. + resistive_n: f32, + /// `½ρ·CdA`; multiplied by v² to give drag. + drag_k: f32, + mass_kg: f32, +} + +impl Forces { + fn new(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> Self { + // Braking is not modelled, so negative power is treated as coasting. + let power = sanitise(power_w, 0.0).max(0.0); + let gradient = + sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT); + let theta = (gradient / 100.0).atan(); + + // A zero or negative mass would divide by zero; a config that broken + // should degrade rather than produce NaN. + let mass = sanitise(cfg.total_mass_kg(), 83.0).max(1.0); + let efficiency = sanitise(cfg.drivetrain_efficiency, 1.0).clamp(0.0, 1.0); + let crr = sanitise(cfg.crr, 0.0).max(0.0); + let cda = sanitise(cfg.cda, 0.0).max(0.0); + let rho = sanitise(cfg.air_density, 0.0).max(0.0); + + Self { + wheel_power_w: power * efficiency, + sin_theta: theta.sin(), + resistive_n: mass * GRAVITY * (theta.sin() + crr * theta.cos()), + drag_k: 0.5 * rho * cda, + mass_kg: mass, + } + } + + 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 a = net / self.mass_kg; + if a.is_finite() { + a + } else { + 0.0 + } + } +} + +fn sanitise(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value + } else { + fallback + } +} + /// Steady-state speed for a given power and gradient — the speed at which /// propulsive and resistive forces balance. Useful for tests and for sanity /// checks on the resistance curve later. +/// +/// The balance is a cubic in `v` (`P·η = F_const·v + k·v³`) with no clean +/// closed form once the `max(v, v_min)` floor is included, so it is solved by +/// bisection. Net force is non-increasing in `v`, which makes the bracket +/// unambiguous. pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 { - let _ = (power_w, gradient_pct, cfg); - todo!("implemented in crates/core/src/physics.rs — see AGENT task A") + let forces = Forces::new(power_w, gradient_pct, cfg); + + // Cannot get moving at all: the rider stalls on the climb. + if forces.acceleration(0.0) <= 0.0 { + return 0.0; + } + if forces.acceleration(MAX_SPEED_MPS) > 0.0 { + return MAX_SPEED_MPS; + } + + let mut lo = 0.0f32; + let mut hi = MAX_SPEED_MPS; + // 60 halvings takes the bracket far below f32 resolution. + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if mid <= lo || mid >= hi { + break; + } + if forces.acceleration(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> RiderConfig { + RiderConfig::default() + } + + /// Run the integrator to steady state and return the state. + fn settle(power_w: f32, gradient_pct: f32, seconds: f32) -> PhysicsState { + let mut s = PhysicsState::default(); + let cfg = cfg(); + let dt = 0.25; + let ticks = (seconds / dt) as u32; + for _ in 0..ticks { + s.step(power_w, gradient_pct, &cfg, dt); + } + s + } + + #[test] + fn equilibrium_is_a_fixed_point_of_the_integrator() { + for (power, gradient) in [(200.0, 0.0), (300.0, 5.0), (150.0, -2.0), (400.0, 8.0)] { + let target = equilibrium_speed_mps(power, gradient, &cfg()); + let settled = settle(power, gradient, 900.0).speed_mps; + assert!( + (settled - target).abs() < 0.05, + "P={power} g={gradient}: integrator settled at {settled}, equilibrium says {target}" + ); + } + } + + #[test] + fn equilibrium_matches_hand_computed_flat_case() { + // 250 W on the flat with the default rider: solve P·η = 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}"); + // 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); + } + + #[test] + fn speed_approaches_equilibrium_rather_than_snapping() { + let c = cfg(); + let target = equilibrium_speed_mps(250.0, 0.0, &c); + let mut s = PhysicsState::default(); + + s.step(250.0, 0.0, &c, 1.0); + let after_one_second = s.speed_mps; + assert!( + after_one_second < target * 0.75, + "one second reached {after_one_second} of {target} — no inertia" + ); + assert!(after_one_second > 0.0); + + for _ in 0..600 { + s.step(250.0, 0.0, &c, 1.0); + } + assert!((s.speed_mps - target).abs() < 0.05); + } + + #[test] + fn tick_rate_does_not_change_the_outcome() { + let c = cfg(); + let mut coarse = PhysicsState::default(); + let mut fine = PhysicsState::default(); + for _ in 0..60 { + coarse.step(300.0, 3.0, &c, 1.0); + } + for _ in 0..600 { + fine.step(300.0, 3.0, &c, 0.1); + } + assert!((coarse.speed_mps - fine.speed_mps).abs() < 0.02); + assert!((coarse.distance_m - fine.distance_m).abs() < 1.0); + } + + #[test] + fn zero_power_coasts_to_a_stop_on_the_flat() { + let c = cfg(); + let mut s = PhysicsState { + speed_mps: 11.0, + ..Default::default() + }; + let start = s.speed_mps; + s.step(0.0, 0.0, &c, 1.0); + assert!(s.speed_mps < start, "coasting must decelerate"); + + for _ in 0..600 { + s.step(0.0, 0.0, &c, 1.0); + } + assert_eq!(s.speed_mps, 0.0, "should have come to rest"); + assert!(!s.is_moving()); + assert!(s.distance_m > 0.0 && s.distance_m < 2000.0); + } + + #[test] + fn stationary_with_no_power_never_goes_backwards() { + let c = cfg(); + let mut s = PhysicsState::default(); + for _ in 0..100 { + s.step(0.0, 0.0, &c, 1.0); + assert_eq!(s.speed_mps, 0.0); + } + assert_eq!(s.distance_m, 0.0); + } + + #[test] + fn steep_climb_stalls_but_stays_non_negative() { + let c = cfg(); + let mut s = PhysicsState::default(); + for _ in 0..300 { + s.step(60.0, 20.0, &c, 1.0); + assert!(s.speed_mps >= 0.0); + } + assert!(s.speed_mps < 1.0, "60 W up 20% should barely move"); + assert_eq!(equilibrium_speed_mps(60.0, 20.0, &c), 0.0); + } + + #[test] + fn steep_descent_accelerates_to_a_bounded_terminal_speed() { + let c = cfg(); + let mut s = PhysicsState::default(); + for _ in 0..600 { + s.step(0.0, -12.0, &c, 1.0); + } + let terminal = equilibrium_speed_mps(0.0, -12.0, &c); + assert!(terminal > 10.0, "should freewheel downhill, got {terminal}"); + assert!(terminal < MAX_SPEED_MPS); + assert!((s.speed_mps - terminal).abs() < 0.1); + assert_eq!(s.elevation_gain_m, 0.0, "descending gains no elevation"); + } + + #[test] + fn more_power_always_means_more_speed() { + let c = cfg(); + let mut previous = -1.0; + for power in [0.0, 50.0, 100.0, 200.0, 300.0, 500.0, 1000.0] { + let v = equilibrium_speed_mps(power, 0.0, &c); + assert!( + v > previous, + "{power} W gave {v} m/s, not more than {previous}" + ); + previous = v; + } + } + + #[test] + fn steeper_gradient_always_means_less_speed() { + let c = cfg(); + let mut previous = f32::INFINITY; + for gradient in [-10.0, -5.0, 0.0, 2.0, 5.0, 10.0, 15.0] { + let v = equilibrium_speed_mps(300.0, gradient, &c); + assert!( + v < previous, + "{gradient}% gave {v} m/s, not less than {previous}" + ); + previous = v; + } + } + + #[test] + fn distance_and_elevation_accumulate_consistently() { + let s = settle(250.0, 5.0, 600.0); + assert!(s.distance_m > 0.0); + // 5% grade: vertical is sin(atan(0.05)) ≈ 0.0499 of distance travelled. + let expected = s.distance_m as f32 * (0.05f32.atan()).sin(); + assert!( + (s.elevation_gain_m - expected).abs() < expected * 0.01, + "gain {} vs expected {expected}", + s.elevation_gain_m + ); + } + + #[test] + fn elevation_gain_counts_only_ascent() { + let c = cfg(); + let mut s = PhysicsState::default(); + for _ in 0..300 { + s.step(250.0, 5.0, &c, 1.0); + } + let after_climb = s.elevation_gain_m; + assert!(after_climb > 10.0); + for _ in 0..300 { + s.step(250.0, -5.0, &c, 1.0); + } + assert_eq!( + s.elevation_gain_m, after_climb, + "descent must not reduce gain" + ); + } + + #[test] + fn hostile_inputs_never_produce_nan_or_negatives() { + let mut c = cfg(); + let hostile = [ + f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + -1.0e30, + 1.0e30, + 0.0, + -0.0, + ]; + for &power in &hostile { + for &gradient in &hostile { + for &dt in &hostile { + let mut s = PhysicsState::default(); + s.step(power, gradient, &c, dt); + s.step(power, gradient, &c, 1.0); + assert!( + s.speed_mps.is_finite(), + "speed NaN for {power}/{gradient}/{dt}" + ); + assert!(s.speed_mps >= 0.0, "negative speed {}", s.speed_mps); + assert!(s.speed_mps <= MAX_SPEED_MPS); + assert!(s.distance_m.is_finite() && s.distance_m >= 0.0); + assert!(s.elevation_gain_m.is_finite() && s.elevation_gain_m >= 0.0); + } + } + } + + // A degenerate rider config must degrade, not explode. + c.rider_kg = 0.0; + c.bike_kg = 0.0; + c.cda = 0.0; + c.air_density = 0.0; + c.crr = f32::NAN; + let mut s = PhysicsState::default(); + for _ in 0..100 { + s.step(500.0, -30.0, &c, 1.0); + } + assert!(s.speed_mps.is_finite() && (0.0..=MAX_SPEED_MPS).contains(&s.speed_mps)); + assert!(equilibrium_speed_mps(500.0, -30.0, &c).is_finite()); + } + + #[test] + fn poisoned_state_is_recovered() { + let c = cfg(); + let mut s = PhysicsState { + speed_mps: f32::NAN, + distance_m: f64::NAN, + elevation_gain_m: f32::NAN, + }; + s.step(200.0, 0.0, &c, 1.0); + assert!(s.speed_mps.is_finite()); + assert!(s.distance_m.is_finite()); + assert!(s.elevation_gain_m.is_finite()); + } + + #[test] + fn zero_and_negative_dt_are_no_ops() { + let c = cfg(); + let mut s = PhysicsState { + speed_mps: 8.0, + ..Default::default() + }; + let before = s; + s.step(300.0, 0.0, &c, 0.0); + s.step(300.0, 0.0, &c, -5.0); + assert_eq!(s, before); + } + + #[test] + fn speed_kph_conversion() { + let s = PhysicsState { + speed_mps: 10.0, + ..Default::default() + }; + assert!((s.speed_kph() - 36.0).abs() < 1e-5); + } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 8fbee65..3bf7f5a 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -34,6 +34,9 @@ pub enum Waveform { impl Waveform { /// Evaluate at `phase` in `[0, 1)`, returning `[-1, 1]`. pub fn eval(self, phase: f32) -> f32 { + if !phase.is_finite() { + return 0.0; + } let p = phase.rem_euclid(1.0); match self { Waveform::Sine => (p * std::f32::consts::TAU).sin(), @@ -44,10 +47,11 @@ impl Waveform { -1.0 } } - Waveform::Triangle => { - // Rises 0→1 over the first quarter, falls 1→-1, returns to 0. - 4.0 * (p - (p + 0.25).floor()).abs() - 1.0 - } + // Shifted-and-folded ramp: 0 at phase 0, peaking at ¼, back + // through 0 at ½ and troughing at ¾ — the same shape and sign + // convention as the sine, so swapping shapes keeps the same + // interval structure. + Waveform::Triangle => 1.0 - 4.0 * ((p + 0.25).rem_euclid(1.0) - 0.5).abs(), Waveform::Sawtooth => 2.0 * p - 1.0, } } @@ -61,6 +65,24 @@ pub enum Extent { Metres(f64), } +impl Extent { + /// The extent's magnitude, floored at zero and never NaN. + fn amount(self) -> f64 { + let v = match self { + Extent::Seconds(s) | Extent::Metres(s) => s, + }; + if v.is_finite() && v > 0.0 { + v + } else { + 0.0 + } + } + + fn is_time(self) -> bool { + matches!(self, Extent::Seconds(_)) + } +} + /// A single terrain segment: hold a gradient for a distance (FR-5.4). #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Segment { @@ -174,22 +196,192 @@ impl Profile { /// Implementations must clamp nothing here — safety clamping happens once, /// at transmission (SAF-3). pub fn sample(&self, position: Position) -> Option { - let _ = position; - todo!("implemented in crates/core/src/profile.rs — see AGENT task A") + let (channel, value) = self.sample_channel(position)?; + Some(match channel { + Channel::Gradient => ControlTarget::Gradient { percent: value }, + // `as` saturates on out-of-range floats, which is the only bound + // applied here; the real limits are enforced at transmission. + Channel::Resistance => ControlTarget::Resistance { + level: round_or_zero(value) as i16, + }, + Channel::Power => ControlTarget::Power { + watts: round_or_zero(value) as u16, + }, + }) + } + + /// The raw channel and numeric value at `position`. Kept separate from + /// [`Profile::sample`] so the preview can chart values without going + /// through the lossy integer target representation. + pub fn sample_channel(&self, position: Position) -> Option<(Channel, f32)> { + let (block, local) = self.locate(position)?; + Some((block.channel(), block.value_at(local))) } /// Total extent of the profile, if finite. Used for progress display /// (FR-9.7) and to know when a non-looping profile has ended. + /// + /// A field is `None` when no block measures itself that way — a profile of + /// purely distance-based blocks has no duration, and vice versa. pub fn total_extent(&self) -> ProfileExtent { - todo!("implemented in crates/core/src/profile.rs — see AGENT task A") + let mut extent = ProfileExtent::default(); + for block in &self.blocks { + let e = block.extent(); + let slot = if e.is_time() { + &mut extent.seconds + } else { + &mut extent.metres + }; + *slot = Some(slot.unwrap_or(0.0) + e.amount()); + } + extent } /// Sample the whole profile ahead of time for the preview chart (FR-6.7). /// Returns `(x, value)` pairs where `x` is seconds or metres depending on /// the profile's dominant extent kind. + /// + /// Blocks are walked in order rather than by sampling a position, because + /// a profile mixing time- and distance-based blocks has no single + /// coordinate that visits every block: the distance cursor is only spent + /// by riding. Each block is instead given a slice of the chart equal to + /// its share of its own kind's total. For the usual single-kind profile + /// that makes `x` exactly seconds or metres; for a mixed one it is the + /// dominant unit stretched over the sequence. pub fn preview(&self, samples: usize) -> Vec<(f64, f32)> { - let _ = samples; - todo!("implemented in crates/core/src/profile.rs — see AGENT task A") + if samples == 0 { + return Vec::new(); + } + let extent = self.total_extent(); + let axis = match self.dominant_kind() { + Some(true) => extent.seconds.unwrap_or(0.0), + Some(false) => extent.metres.unwrap_or(0.0), + None => return Vec::new(), + }; + if axis <= 0.0 { + return Vec::new(); + } + + let share = |block: &Block| { + let e = block.extent(); + let kind_total = if e.is_time() { + extent.seconds.unwrap_or(0.0) + } else { + extent.metres.unwrap_or(0.0) + }; + if kind_total > 0.0 { + e.amount() / kind_total + } else { + 0.0 + } + }; + let total_share: f64 = self.blocks.iter().map(&share).sum(); + if total_share <= 0.0 { + return Vec::new(); + } + + let mut out = Vec::with_capacity(samples); + let mut consumed = 0.0f64; + for block in &self.blocks { + let block_share = share(block); + if block_share <= 0.0 { + continue; + } + let count = ((block_share / total_share) * samples as f64) + .round() + .max(1.0) as usize; + let amount = block.extent().amount(); + for i in 0..count { + let f = i as f64 / count as f64; + let x = (consumed + block_share * f) / total_share * axis; + out.push((x, block.value_at(amount * f))); + } + consumed += block_share; + } + out + } + + /// `Some(true)` for a time axis, `Some(false)` for distance, `None` if the + /// profile has no positive extent at all. Ties go to time. + fn dominant_kind(&self) -> Option { + let extent = self.total_extent(); + match ( + extent.seconds.filter(|s| *s > 0.0), + extent.metres.filter(|m| *m > 0.0), + ) { + (Some(_), Some(_)) => { + let time_blocks = self.blocks.iter().filter(|b| b.extent().is_time()).count(); + Some(time_blocks * 2 >= self.blocks.len()) + } + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + (None, None) => None, + } + } + + /// Find the active block and the offset into it, in that block's own unit. + /// + /// Blocks run in sequence, but a time-based block only consumes time and a + /// distance-based block only consumes distance, so both coordinates are + /// tracked as the list is walked. + fn locate(&self, position: Position) -> Option<(&Block, f64)> { + let mut elapsed = clamp_non_negative(position.elapsed_s); + let mut distance = clamp_non_negative(position.distance_m); + + if self.looping { + let extent = self.total_extent(); + let per_loop_s = extent.seconds.unwrap_or(0.0); + let per_loop_m = extent.metres.unwrap_or(0.0); + + // A lap is only complete once *both* coordinates have paid for the + // whole block list, so the number of finished laps is the smaller + // of the two counts. Kinds the profile does not use impose no bound. + let mut laps = f64::INFINITY; + if per_loop_s > 0.0 { + laps = laps.min((elapsed / per_loop_s).floor()); + } + if per_loop_m > 0.0 { + laps = laps.min((distance / per_loop_m).floor()); + } + + if laps.is_finite() && laps > 0.0 { + elapsed = clamp_non_negative(elapsed - laps * per_loop_s); + distance = clamp_non_negative(distance - laps * per_loop_m); + } + } + + // The reduction above is exact in principle; a rounding error could + // still leave the cursor a hair past the end, so a looping profile + // gets one extra attempt with another lap removed. + let attempts = if self.looping { 2 } else { 1 }; + for attempt in 0..attempts { + if attempt > 0 { + let extent = self.total_extent(); + elapsed = clamp_non_negative(elapsed - extent.seconds.unwrap_or(0.0)); + distance = clamp_non_negative(distance - extent.metres.unwrap_or(0.0)); + } + if let Some(found) = self.walk(elapsed, distance) { + return Some(found); + } + } + None + } + + fn walk(&self, mut elapsed: f64, mut distance: f64) -> Option<(&Block, f64)> { + for block in &self.blocks { + let extent = block.extent(); + let amount = extent.amount(); + let cursor = if extent.is_time() { + &mut elapsed + } else { + &mut distance + }; + if *cursor < amount { + return Some((block, *cursor)); + } + *cursor -= amount; + } + None } } @@ -201,6 +393,29 @@ pub struct ProfileExtent { pub metres: Option, } +impl ProfileExtent { + /// Fraction of the profile consumed at `position`, if it has any finite + /// extent. A profile measured both ways is only finished when both + /// coordinates are, so the lesser fraction governs. + pub fn progress(&self, position: Position) -> Option { + let by_time = self + .seconds + .filter(|s| *s > 0.0) + .map(|s| position.elapsed_s / s); + let by_distance = self + .metres + .filter(|m| *m > 0.0) + .map(|m| position.distance_m / m); + let fraction = match (by_time, by_distance) { + (Some(t), Some(d)) => t.min(d), + (Some(t), None) => t, + (None, Some(d)) => d, + (None, None) => return None, + }; + Some(fraction.clamp(0.0, 1.0) as f32) + } +} + impl Block { pub fn channel(&self) -> Channel { match self { @@ -211,9 +426,77 @@ impl Block { } } + /// How much of its own coordinate this block consumes. + pub fn extent(&self) -> Extent { + match self { + Block::Constant { extent, .. } | Block::Ramp { extent, .. } => *extent, + Block::Wave { + period, repeats, .. + } => { + let total = period.amount() * f64::from(*repeats).max(0.0); + if period.is_time() { + Extent::Seconds(total) + } else { + Extent::Metres(total) + } + } + // Terrain is inherently a function of distance. + Block::Segments { segments } => { + Extent::Metres(segments.iter().map(|s| s.distance_m.max(0.0)).sum()) + } + Block::Terrain { points } => { + let span = match (points.first(), points.last()) { + (Some(first), Some(last)) => (last.distance_m - first.distance_m).max(0.0), + _ => 0.0, + }; + Extent::Metres(span) + } + } + } + + /// The block's value at `local`, an offset into the block in its own unit. + pub fn value_at(&self, local: f64) -> f32 { + let local = clamp_non_negative(local); + match self { + Block::Constant { value, .. } => *value, + Block::Ramp { + from, to, extent, .. + } => { + let span = extent.amount(); + if span <= 0.0 { + return *to; + } + let f = (local / span).clamp(0.0, 1.0) as f32; + from + (to - from) * f + } + Block::Wave { + shape, + midpoint, + amplitude, + period, + phase, + .. + } => { + let cycle = period.amount(); + if cycle <= 0.0 { + return *midpoint; + } + // Reduce before narrowing to f32: a long ride is many periods + // in, and f32 would quantise the phase visibly. + let cycles = (local / cycle).rem_euclid(1.0) as f32; + midpoint + amplitude * shape.eval(cycles + *phase) + } + // Both terrain forms interpolate rather than step (FR-5.5). + Block::Segments { segments } => interpolate_segments(segments, local), + Block::Terrain { points } => interpolate_terrain(points, local), + } + } + fn validate(&self) -> Result<(), String> { match self { - Block::Wave { repeats, period, .. } => { + Block::Wave { + repeats, period, .. + } => { if *repeats <= 0.0 { return Err("repeats must be positive".into()); } @@ -233,3 +516,727 @@ impl Block { } } } + +/// Interpolate a hand-authored segment list. +/// +/// A segment states a gradient for a stretch of road, so the natural control +/// point is the stretch's midpoint: interpolating between midpoints gives a +/// continuous gradient (FR-5.5) while keeping each segment's stated value at +/// its centre and preserving the profile's mean. Before the first midpoint and +/// after the last, the end segments' gradients are held. +fn interpolate_segments(segments: &[Segment], local: f64) -> f32 { + if segments.is_empty() { + return 0.0; + } + let mut previous: Option<(f64, f32)> = None; + let mut start = 0.0f64; + for segment in segments { + let length = segment.distance_m.max(0.0); + let centre = start + length * 0.5; + if local <= centre { + return match previous { + Some((prev_centre, prev_grad)) => { + lerp(prev_centre, prev_grad, centre, segment.gradient_pct, local) + } + None => segment.gradient_pct, + }; + } + previous = Some((centre, segment.gradient_pct)); + start += length; + } + segments[segments.len() - 1].gradient_pct +} + +/// Interpolate a GPX-derived terrain profile. Points carry absolute cumulative +/// distance, so `local` is measured from the first point. +fn interpolate_terrain(points: &[TerrainPoint], local: f64) -> f32 { + let Some(first) = points.first() else { + return 0.0; + }; + let target = first.distance_m + local; + if target <= first.distance_m { + return first.gradient_pct; + } + for pair in points.windows(2) { + let (a, b) = (pair[0], pair[1]); + if target <= b.distance_m { + return lerp( + a.distance_m, + a.gradient_pct, + b.distance_m, + b.gradient_pct, + target, + ); + } + } + points[points.len() - 1].gradient_pct +} + +fn lerp(x0: f64, y0: f32, x1: f64, y1: f32, x: f64) -> f32 { + let span = x1 - x0; + if span <= 0.0 { + return y1; + } + let f = ((x - x0) / span).clamp(0.0, 1.0) as f32; + y0 + (y1 - y0) * f +} + +fn clamp_non_negative(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { + v + } else { + 0.0 + } +} + +fn round_or_zero(v: f32) -> f32 { + if v.is_finite() { + v.round() + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(elapsed_s: f64, distance_m: f64) -> Position { + Position { + elapsed_s, + distance_m, + } + } + + fn gradient_of(target: ControlTarget) -> f32 { + match target { + ControlTarget::Gradient { percent } => percent, + other => panic!("expected a gradient target, got {other:?}"), + } + } + + fn power_of(target: ControlTarget) -> u16 { + match target { + ControlTarget::Power { watts } => watts, + other => panic!("expected a power target, got {other:?}"), + } + } + + // ---- waveforms ------------------------------------------------------- + + #[test] + fn sine_hits_known_phases() { + assert!((Waveform::Sine.eval(0.0) - 0.0).abs() < 1e-6); + assert!((Waveform::Sine.eval(0.25) - 1.0).abs() < 1e-6); + assert!((Waveform::Sine.eval(0.5) - 0.0).abs() < 1e-6); + assert!((Waveform::Sine.eval(0.75) + 1.0).abs() < 1e-6); + } + + #[test] + fn square_is_high_then_low() { + assert_eq!(Waveform::Square.eval(0.0), 1.0); + assert_eq!(Waveform::Square.eval(0.49), 1.0); + assert_eq!(Waveform::Square.eval(0.5), -1.0); + assert_eq!(Waveform::Square.eval(0.99), -1.0); + } + + #[test] + fn triangle_is_actually_a_triangle() { + // Known phases: zero-crossings at 0 and ½, peak at ¼, trough at ¾. + for (phase, expected) in [ + (0.0, 0.0), + (0.125, 0.5), + (0.25, 1.0), + (0.375, 0.5), + (0.5, 0.0), + (0.625, -0.5), + (0.75, -1.0), + (0.875, -0.5), + ] { + let got = Waveform::Triangle.eval(phase); + assert!( + (got - expected).abs() < 1e-6, + "triangle at {phase} gave {got}, expected {expected}" + ); + } + } + + #[test] + fn triangle_stays_in_range_and_is_piecewise_linear() { + let mut previous = Waveform::Triangle.eval(0.0); + for i in 1..=2000 { + let phase = i as f32 / 2000.0; + let value = Waveform::Triangle.eval(phase); + assert!( + (-1.0..=1.0).contains(&value), + "triangle escaped range: {value}" + ); + // Slope magnitude is a constant 4 per unit phase. + let slope = (value - previous).abs() * 2000.0; + assert!((slope - 4.0).abs() < 0.05, "slope {slope} at {phase}"); + previous = value; + } + } + + #[test] + fn sawtooth_ramps_and_wraps() { + assert!((Waveform::Sawtooth.eval(0.0) + 1.0).abs() < 1e-6); + assert!((Waveform::Sawtooth.eval(0.5) - 0.0).abs() < 1e-6); + assert!((Waveform::Sawtooth.eval(0.999) - 0.998).abs() < 1e-3); + // Phase wraps rather than running away. + assert!((Waveform::Sawtooth.eval(2.25) - Waveform::Sawtooth.eval(0.25)).abs() < 1e-6); + assert!((Waveform::Sawtooth.eval(-0.75) - Waveform::Sawtooth.eval(0.25)).abs() < 1e-6); + } + + #[test] + fn all_waveforms_stay_within_unit_range() { + for shape in [ + Waveform::Sine, + Waveform::Square, + Waveform::Triangle, + Waveform::Sawtooth, + ] { + for i in -500..1500 { + let value = shape.eval(i as f32 / 500.0); + assert!( + (-1.0..=1.0).contains(&value), + "{shape:?} produced {value} out of range" + ); + } + assert_eq!(shape.eval(f32::NAN), 0.0); + } + } + + // ---- block sequencing ------------------------------------------------ + + fn time_profile() -> Profile { + Profile { + name: "time".into(), + description: None, + looping: false, + blocks: vec![ + Block::Constant { + channel: Channel::Power, + value: 100.0, + extent: Extent::Seconds(60.0), + }, + Block::Ramp { + channel: Channel::Power, + from: 100.0, + to: 200.0, + extent: Extent::Seconds(100.0), + }, + Block::Constant { + channel: Channel::Power, + value: 50.0, + extent: Extent::Seconds(40.0), + }, + ], + } + } + + #[test] + fn blocks_run_in_sequence_on_a_time_axis() { + let p = time_profile(); + assert_eq!(power_of(p.sample(at(0.0, 0.0)).unwrap()), 100); + assert_eq!(power_of(p.sample(at(59.9, 0.0)).unwrap()), 100); + // Straight into the ramp's first value. + assert_eq!(power_of(p.sample(at(60.0, 0.0)).unwrap()), 100); + assert_eq!(power_of(p.sample(at(110.0, 0.0)).unwrap()), 150); + assert_eq!(power_of(p.sample(at(159.9, 0.0)).unwrap()), 200); + assert_eq!(power_of(p.sample(at(160.0, 0.0)).unwrap()), 50); + assert_eq!(power_of(p.sample(at(199.9, 0.0)).unwrap()), 50); + } + + #[test] + fn non_looping_profile_is_exhausted_at_its_end() { + let p = time_profile(); + assert!(p.sample(at(200.0, 0.0)).is_none()); + assert!(p.sample(at(1.0e6, 0.0)).is_none()); + } + + #[test] + fn total_extent_reports_only_the_kinds_in_use() { + let extent = time_profile().total_extent(); + assert_eq!(extent.seconds, Some(200.0)); + assert_eq!(extent.metres, None); + + let distance_only = Profile { + name: "d".into(), + description: None, + looping: false, + blocks: vec![Block::Segments { + segments: vec![ + Segment { + distance_m: 500.0, + gradient_pct: 0.0, + }, + Segment { + distance_m: 800.0, + gradient_pct: 6.0, + }, + ], + }], + }; + let extent = distance_only.total_extent(); + assert_eq!(extent.seconds, None); + assert_eq!(extent.metres, Some(1300.0)); + } + + /// A time block, then a distance block, then a time block — the awkward + /// case, and the shape of `profiles/sawtooth-gradient.yaml`. + fn mixed_profile() -> Profile { + Profile { + name: "mixed".into(), + description: None, + looping: false, + blocks: vec![ + Block::Constant { + channel: Channel::Gradient, + value: 1.0, + extent: Extent::Seconds(300.0), + }, + Block::Constant { + channel: Channel::Gradient, + value: 2.0, + extent: Extent::Metres(4000.0), + }, + Block::Constant { + channel: Channel::Gradient, + value: 3.0, + extent: Extent::Seconds(300.0), + }, + ], + } + } + + #[test] + fn time_and_distance_extents_consume_independent_cursors() { + let p = mixed_profile(); + // Distance alone cannot advance past the opening time block. + assert_eq!(gradient_of(p.sample(at(0.0, 9999.0)).unwrap()), 1.0); + // Time alone cannot advance past the distance block. + assert_eq!(gradient_of(p.sample(at(9999.0, 0.0)).unwrap()), 2.0); + // Both consumed: the closing time block. + assert_eq!(gradient_of(p.sample(at(400.0, 4000.0)).unwrap()), 3.0); + // Both fully consumed: finished. + assert!(p.sample(at(600.0, 4000.0)).is_none()); + assert!(p.sample(at(601.0, 5000.0)).is_none()); + } + + #[test] + fn mixed_total_extent_covers_both_axes() { + let extent = mixed_profile().total_extent(); + assert_eq!(extent.seconds, Some(600.0)); + assert_eq!(extent.metres, Some(4000.0)); + } + + // ---- looping --------------------------------------------------------- + + #[test] + fn looping_wraps_back_to_the_start() { + let mut p = time_profile(); + p.looping = true; + assert!(p.sample(at(200.0, 0.0)).is_some()); + for lap in 0..5 { + let base = lap as f64 * 200.0; + assert_eq!(power_of(p.sample(at(base, 0.0)).unwrap()), 100); + assert_eq!(power_of(p.sample(at(base + 110.0, 0.0)).unwrap()), 150); + assert_eq!(power_of(p.sample(at(base + 170.0, 0.0)).unwrap()), 50); + } + // Still going a hundred laps later. + assert_eq!(power_of(p.sample(at(20_110.0, 0.0)).unwrap()), 150); + } + + #[test] + fn looping_distance_profile_wraps() { + let p = Profile { + name: "loop".into(), + description: None, + looping: true, + blocks: vec![Block::Segments { + segments: vec![ + Segment { + distance_m: 1000.0, + gradient_pct: 5.0, + }, + Segment { + distance_m: 1000.0, + gradient_pct: 5.0, + }, + ], + }], + }; + for lap in 0..10 { + let d = lap as f64 * 2000.0 + 1000.0; + assert_eq!(gradient_of(p.sample(at(0.0, d)).unwrap()), 5.0); + } + assert!(p.sample(at(0.0, 1.0e7)).is_some()); + } + + #[test] + fn looping_mixed_profile_only_laps_when_both_axes_are_spent() { + let mut p = mixed_profile(); + p.looping = true; + // 600 s and 4000 m is exactly one lap; the next sample is block one. + assert_eq!(gradient_of(p.sample(at(600.0, 4000.0)).unwrap()), 1.0); + // Plenty of time but not enough distance: still in the lap. + assert_eq!(gradient_of(p.sample(at(5000.0, 100.0)).unwrap()), 2.0); + // One lap done, then 400 s and 4000 m into the second: past the first + // time block and past the distance block, so the closing block. + assert_eq!(gradient_of(p.sample(at(1000.0, 8000.0)).unwrap()), 3.0); + } + + // ---- interpolation --------------------------------------------------- + + #[test] + fn segments_interpolate_between_midpoints_rather_than_stepping() { + let block = Block::Segments { + segments: vec![ + Segment { + distance_m: 100.0, + gradient_pct: 0.0, + }, + Segment { + distance_m: 100.0, + gradient_pct: 10.0, + }, + ], + }; + // Held at the stated value up to the first midpoint. + assert_eq!(block.value_at(0.0), 0.0); + assert_eq!(block.value_at(50.0), 0.0); + // Ramps linearly between midpoints — no step at the 100 m boundary. + assert!((block.value_at(100.0) - 5.0).abs() < 1e-5); + assert!((block.value_at(75.0) - 2.5).abs() < 1e-5); + assert!((block.value_at(125.0) - 7.5).abs() < 1e-5); + // Held again past the last midpoint. + assert_eq!(block.value_at(150.0), 10.0); + assert_eq!(block.value_at(200.0), 10.0); + } + + #[test] + fn segment_gradient_is_continuous() { + let block = Block::Segments { + segments: vec![ + Segment { + distance_m: 500.0, + gradient_pct: 0.0, + }, + Segment { + distance_m: 800.0, + gradient_pct: 6.5, + }, + Segment { + distance_m: 200.0, + gradient_pct: 9.0, + }, + Segment { + distance_m: 400.0, + gradient_pct: -3.0, + }, + ], + }; + let mut previous = block.value_at(0.0); + let mut i = 1; + while i <= 1900 { + let value = block.value_at(i as f64); + assert!( + (value - previous).abs() < 0.1, + "gradient stepped by {} at {i} m", + value - previous + ); + previous = value; + i += 1; + } + } + + #[test] + fn terrain_points_interpolate_by_distance() { + let block = Block::Terrain { + points: vec![ + TerrainPoint { + distance_m: 0.0, + gradient_pct: 0.0, + elevation_m: 100.0, + }, + TerrainPoint { + distance_m: 100.0, + gradient_pct: 8.0, + elevation_m: 108.0, + }, + TerrainPoint { + distance_m: 200.0, + gradient_pct: -4.0, + elevation_m: 104.0, + }, + ], + }; + assert_eq!(block.value_at(0.0), 0.0); + assert!((block.value_at(25.0) - 2.0).abs() < 1e-5); + assert!((block.value_at(50.0) - 4.0).abs() < 1e-5); + assert!((block.value_at(100.0) - 8.0).abs() < 1e-5); + assert!((block.value_at(150.0) - 2.0).abs() < 1e-5); + assert_eq!(block.value_at(200.0), -4.0); + assert_eq!(block.value_at(500.0), -4.0); + assert_eq!(block.extent(), Extent::Metres(200.0)); + } + + #[test] + fn terrain_points_with_a_non_zero_origin_are_offset_correctly() { + let block = Block::Terrain { + points: vec![ + TerrainPoint { + distance_m: 1000.0, + gradient_pct: 0.0, + elevation_m: 0.0, + }, + TerrainPoint { + distance_m: 1100.0, + gradient_pct: 10.0, + elevation_m: 10.0, + }, + ], + }; + assert_eq!(block.extent(), Extent::Metres(100.0)); + assert!((block.value_at(50.0) - 5.0).abs() < 1e-5); + } + + // ---- waves ----------------------------------------------------------- + + #[test] + fn wave_block_scales_and_offsets_the_shape() { + let block = Block::Wave { + channel: Channel::Power, + shape: Waveform::Sine, + midpoint: 240.0, + amplitude: 40.0, + period: Extent::Seconds(120.0), + repeats: 8.0, + phase: 0.0, + }; + assert_eq!(block.extent(), Extent::Seconds(960.0)); + assert!((block.value_at(0.0) - 240.0).abs() < 1e-3); + assert!((block.value_at(30.0) - 280.0).abs() < 1e-3); + assert!((block.value_at(60.0) - 240.0).abs() < 1e-3); + assert!((block.value_at(90.0) - 200.0).abs() < 1e-3); + // Cycle 5 matches cycle 0. + assert!((block.value_at(630.0) - 280.0).abs() < 1e-3); + } + + #[test] + fn wave_phase_offset_shifts_the_shape() { + let make = |phase| Block::Wave { + channel: Channel::Gradient, + shape: Waveform::Sine, + midpoint: 0.0, + amplitude: 1.0, + period: Extent::Seconds(100.0), + repeats: 1.0, + phase, + }; + assert!((make(0.25).value_at(0.0) - 1.0).abs() < 1e-5); + assert!((make(0.5).value_at(25.0) + 1.0).abs() < 1e-5); + } + + #[test] + fn distance_based_wave_uses_the_distance_cursor() { + let p = Profile { + name: "saw".into(), + description: None, + looping: false, + blocks: vec![Block::Wave { + channel: Channel::Gradient, + shape: Waveform::Sawtooth, + midpoint: 4.0, + amplitude: 4.0, + period: Extent::Metres(400.0), + repeats: 10.0, + phase: 0.0, + }], + }; + assert_eq!(p.total_extent().metres, Some(4000.0)); + assert_eq!(p.total_extent().seconds, None); + assert_eq!(gradient_of(p.sample(at(0.0, 0.0)).unwrap()), 0.0); + assert!((gradient_of(p.sample(at(0.0, 200.0)).unwrap()) - 4.0).abs() < 1e-4); + // Time does not advance a distance-based profile. + assert_eq!(gradient_of(p.sample(at(99999.0, 0.0)).unwrap()), 0.0); + assert!(p.sample(at(0.0, 4000.0)).is_none()); + } + + // ---- no clamping here ------------------------------------------------ + + #[test] + fn sample_does_not_clamp_absurd_values() { + let p = Profile { + name: "absurd".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 400.0, + extent: Extent::Seconds(10.0), + }], + }; + // SAF-3: clamping belongs at transmission, not here. + assert_eq!(gradient_of(p.sample(at(1.0, 0.0)).unwrap()), 400.0); + } + + // ---- preview --------------------------------------------------------- + + #[test] + fn preview_covers_the_profile_on_the_dominant_axis() { + let p = time_profile(); + let preview = p.preview(200); + assert_eq!(preview.len(), 200); + assert_eq!(preview[0].0, 0.0); + assert!(preview.last().unwrap().0 < 200.0); + assert!(preview.last().unwrap().0 > 198.0); + // Monotone x, and the values track the blocks. + for pair in preview.windows(2) { + assert!(pair[1].0 > pair[0].0); + } + assert!((preview[0].1 - 100.0).abs() < 1e-3); + assert!((preview.last().unwrap().1 - 50.0).abs() < 1e-3); + } + + #[test] + fn preview_of_a_distance_profile_uses_metres() { + let p = Profile { + name: "d".into(), + description: None, + looping: false, + blocks: vec![Block::Segments { + segments: vec![Segment { + distance_m: 1000.0, + gradient_pct: 5.0, + }], + }], + }; + let preview = p.preview(50); + assert_eq!(preview.len(), 50); + assert!(preview.last().unwrap().0 > 900.0 && preview.last().unwrap().0 < 1000.0); + assert!(preview.iter().all(|(_, v)| (*v - 5.0).abs() < 1e-5)); + } + + #[test] + fn preview_of_a_mixed_profile_reaches_every_block() { + let preview = mixed_profile().preview(300); + assert!(!preview.is_empty()); + let values: Vec = preview.iter().map(|(_, v)| *v).collect(); + for expected in [1.0, 2.0, 3.0] { + assert!( + values.iter().any(|v| (*v - expected).abs() < 1e-5), + "preview never reached the {expected}% block" + ); + } + } + + #[test] + fn preview_of_zero_samples_is_empty() { + assert!(time_profile().preview(0).is_empty()); + } + + // ---- progress -------------------------------------------------------- + + #[test] + fn progress_uses_the_slower_axis() { + let extent = mixed_profile().total_extent(); + assert_eq!(extent.progress(at(300.0, 2000.0)), Some(0.5)); + // Half the time but all the distance: still only half done. + assert_eq!(extent.progress(at(300.0, 4000.0)), Some(0.5)); + assert_eq!(extent.progress(at(9999.0, 9999.0)), Some(1.0)); + assert_eq!( + time_profile().total_extent().progress(at(100.0, 0.0)), + Some(0.5) + ); + } + + // ---- parsing --------------------------------------------------------- + + #[test] + fn shipped_profiles_parse_and_sample() { + for src in [ + include_str!("../../../profiles/sine-overunders.yaml"), + include_str!("../../../profiles/square-resistance.yaml"), + include_str!("../../../profiles/sawtooth-gradient.yaml"), + include_str!("../../../profiles/hill-repeats.yaml"), + ] { + let profile = Profile::from_yaml(src).expect("shipped profile must parse"); + let extent = profile.total_extent(); + assert!(extent.seconds.is_some() || extent.metres.is_some()); + assert!(profile.sample(at(0.0, 0.0)).is_some()); + assert!(!profile.preview(64).is_empty()); + } + } + + #[test] + fn hill_repeats_loops_forever() { + let p = Profile::from_yaml(include_str!("../../../profiles/hill-repeats.yaml")).unwrap(); + assert!(p.looping); + assert_eq!(p.total_extent().metres, Some(2200.0)); + assert!(p.sample(at(0.0, 1.0e6)).is_some()); + } + + #[test] + fn empty_profile_is_rejected() { + let p = Profile { + name: "n".into(), + description: None, + blocks: vec![], + looping: false, + }; + assert!(matches!(p.validate(), Err(ProfileError::Empty))); + } + + // ---- degenerate input ------------------------------------------------ + + #[test] + fn zero_extent_blocks_are_skipped_without_hanging() { + let p = Profile { + name: "zeroes".into(), + description: None, + looping: true, + blocks: vec![ + Block::Constant { + channel: Channel::Gradient, + value: 1.0, + extent: Extent::Seconds(0.0), + }, + Block::Constant { + channel: Channel::Gradient, + value: 2.0, + extent: Extent::Seconds(0.0), + }, + ], + }; + // Nothing consumes anything, so nothing is active — and crucially the + // walk terminates rather than spinning. + assert!(p.sample(at(0.0, 0.0)).is_none()); + assert!(p.sample(at(1000.0, 1000.0)).is_none()); + assert!(p.preview(10).is_empty()); + } + + #[test] + fn non_finite_positions_are_treated_as_the_start() { + let p = time_profile(); + assert_eq!(power_of(p.sample(at(f64::NAN, f64::NAN)).unwrap()), 100); + assert_eq!(power_of(p.sample(at(-50.0, -50.0)).unwrap()), 100); + assert_eq!(power_of(p.sample(at(f64::INFINITY, 0.0)).unwrap()), 100); + } + + #[test] + fn non_finite_block_values_do_not_produce_garbage_integers() { + let p = Profile { + name: "nan".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Power, + value: f32::NAN, + extent: Extent::Seconds(10.0), + }], + }; + assert_eq!(power_of(p.sample(at(1.0, 0.0)).unwrap()), 0); + } +} diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 6a4c063..f4762de 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -32,6 +32,12 @@ pub enum RideStatus { Finished, } +/// Smallest change worth spending a control-point write on. FR-2.8 caps writes +/// at 4 Hz; suppressing no-op targets keeps a 10 Hz tick loop comfortably +/// inside that without a timer, and avoids churning the trainer with values it +/// cannot resolve anyway. +const GRADIENT_EPSILON_PCT: f32 = 0.05; + pub struct RideSession { pub config: RiderConfig, pub limits: SafetyLimits, @@ -41,6 +47,10 @@ pub struct RideSession { profile: Option, /// Manual gradient trim applied on top of the profile's gradient. gradient_offset_pct: f32, + /// Level held in [`ControlMode::Resistance`] (FR-4.3). + manual_resistance: i16, + /// Wattage held in [`ControlMode::Erg`] (FR-4.6). + erg_watts: u16, elapsed_ms: u64, last_target: Option, } @@ -55,6 +65,8 @@ impl RideSession { physics: PhysicsState::default(), profile: None, gradient_offset_pct: 0.0, + manual_resistance: 0, + erg_watts: 150, elapsed_ms: 0, last_target: None, } @@ -93,6 +105,50 @@ impl RideSession { self.gradient_offset_pct = 0.0; } + pub fn gradient_offset_pct(&self) -> f32 { + self.gradient_offset_pct + } + + /// Set the resistance level held in [`ControlMode::Resistance`] (FR-4.3). + /// + /// Stored unclamped; `SafetyLimits` still has the final say at + /// transmission, so the rider's setting is never silently rewritten here. + pub fn set_resistance(&mut self, level: i16) { + self.manual_resistance = level; + } + + pub fn nudge_resistance(&mut self, delta: i16) { + self.manual_resistance = self.manual_resistance.saturating_add(delta); + } + + pub fn resistance_level(&self) -> i16 { + self.manual_resistance + } + + /// Set the wattage held in [`ControlMode::Erg`] (FR-4.6). + pub fn set_erg_power(&mut self, watts: u16) { + self.erg_watts = watts; + } + + pub fn nudge_erg_power(&mut self, delta: i16) { + self.erg_watts = self.erg_watts.saturating_add_signed(delta); + } + + pub fn erg_power_w(&self) -> u16 { + self.erg_watts + } + + /// Read-only view of the physics model, for diagnostics and recording. + pub fn physics(&self) -> &PhysicsState { + &self.physics + } + + /// The target most recently sent to the trainer, post-clamp (SAF-1: this + /// is what should be held when input is lost). + pub fn last_target(&self) -> Option { + self.last_target + } + /// Advance the ride by one tick. /// /// Feeds telemetry into the physics model, advances the profile, and @@ -100,18 +156,709 @@ impl RideSession { /// when paused (no distance accrues) and when telemetry is missing power /// (treat as zero rather than panicking). pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec { - let _ = (telemetry, dt_s); - todo!("implemented in crates/core/src/session.rs — see AGENT task A") + let mut events = Vec::new(); + let dt = if dt_s.is_finite() { dt_s.max(0.0) } else { 0.0 }; + let running = self.status == RideStatus::Running; + + if running { + self.elapsed_ms = self + .elapsed_ms + .saturating_add((dt as f64 * 1000.0).round() as u64); + } + + // Resolve the target *before* stepping, so the physics see the same + // gradient the trainer is being asked for this tick. + let desired = self.desired_target(); + let exhausted = self.profile.is_some() && desired.is_none(); + + if running { + // 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); + } + + // Only a running ride commands the trainer. When paused or finished the + // last target simply stands (SAF-1) rather than being re-sent or reset. + if running { + if let Some(target) = desired { + let clamped = self.limits.clamp(target); + if changed_meaningfully(self.last_target, clamped) { + self.last_target = Some(clamped); + events.push(SessionEvent::Command(clamped)); + } + } + } + + if exhausted && running { + self.status = RideStatus::Finished; + events.push(SessionEvent::ProfileFinished); + } + + events.push(SessionEvent::Snapshot(self.snapshot(telemetry))); + events } /// Build the snapshot the UI renders. pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot { - let _ = telemetry; - todo!("implemented in crates/core/src/session.rs — see AGENT task A") + RideSnapshot { + elapsed_ms: self.elapsed_ms, + telemetry, + virtual_speed_kph: self.physics.speed_kph(), + virtual_distance_m: self.physics.distance_m, + gradient_pct: self.simulated_gradient_pct(), + elevation_gain_m: self.physics.elevation_gain_m, + mode: self.mode, + target: self.last_target, + profile_progress: self.profile_progress(), + } + } + + /// Fractional progress through the loaded profile (FR-9.7). `None` for a + /// looping profile, which never ends, or when nothing is loaded. + pub fn profile_progress(&self) -> Option { + let profile = self.profile.as_ref()?; + if profile.looping { + return None; + } + profile.total_extent().progress(self.position()) } /// The target that should be in force right now, before clamping. fn desired_target(&self) -> Option { - todo!("implemented in crates/core/src/session.rs — see AGENT task A") + match self.mode { + // No profile involved: the trim *is* the gradient. + ControlMode::ManualGrade => Some(ControlTarget::Gradient { + percent: self.gradient_offset_pct, + }), + ControlMode::Profile => { + let sampled = self.profile.as_ref()?.sample(self.position())?; + Some(match sampled { + // The D-pad trim rides on top of the route (FR-4.2 and + // FR-4.4 are simultaneously active, §5.4). + ControlTarget::Gradient { percent } => ControlTarget::Gradient { + percent: percent + self.gradient_offset_pct, + }, + other => other, + }) + } + // These modes hold a value the rider set directly and ignore any + // loaded profile — selecting the mode *is* the statement that the + // rider is driving the trainer, not the route. + ControlMode::Resistance => Some(ControlTarget::Resistance { + level: self.manual_resistance, + }), + ControlMode::Erg => Some(ControlTarget::Power { + watts: self.erg_watts, + }), + } + } + + /// The gradient the physics model should simulate this tick: the profile's + /// gradient, if it is driving one, plus the manual trim. A profile driving + /// power or resistance contributes no slope, so the rider is on the flat + /// plus whatever trim they have dialled in. + fn simulated_gradient_pct(&self) -> f32 { + let base = match self.mode { + ControlMode::Profile => match self + .profile + .as_ref() + .and_then(|p| p.sample(self.position())) + { + Some(ControlTarget::Gradient { percent }) => percent, + _ => 0.0, + }, + _ => 0.0, + }; + base + self.gradient_offset_pct + } +} + +/// Whether a new target differs enough from the last one to be worth sending. +/// A change of channel always counts. +fn changed_meaningfully(previous: Option, next: ControlTarget) -> bool { + match (previous, next) { + (None, _) => true, + (Some(ControlTarget::Gradient { percent: a }), ControlTarget::Gradient { percent: b }) => { + (a - b).abs() >= GRADIENT_EPSILON_PCT + } + (Some(ControlTarget::Resistance { level: a }), ControlTarget::Resistance { level: b }) => { + a != b + } + (Some(ControlTarget::Power { watts: a }), ControlTarget::Power { watts: b }) => a != b, + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{Block, Channel, Extent, Segment, Waveform}; + + fn session() -> RideSession { + RideSession::new(RiderConfig::default(), SafetyLimits::default()) + } + + fn powered(watts: i16) -> Telemetry { + Telemetry { + power_w: Some(watts), + ..Default::default() + } + } + + fn commands(events: &[SessionEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + SessionEvent::Command(t) => Some(*t), + _ => None, + }) + .collect() + } + + fn snapshot_of(events: &[SessionEvent]) -> RideSnapshot { + events + .iter() + .find_map(|e| match e { + SessionEvent::Snapshot(s) => Some(*s), + _ => None, + }) + .expect("every tick emits a snapshot") + } + + fn gradient_of(target: ControlTarget) -> f32 { + match target { + ControlTarget::Gradient { percent } => percent, + other => panic!("expected a gradient target, got {other:?}"), + } + } + + // ---- basic loop ------------------------------------------------------ + + #[test] + fn every_tick_emits_exactly_one_snapshot() { + let mut s = session(); + s.start(); + for _ in 0..10 { + let events = s.tick(powered(200), 1.0); + let snapshots = events + .iter() + .filter(|e| matches!(e, SessionEvent::Snapshot(_))) + .count(); + assert_eq!(snapshots, 1); + } + } + + #[test] + fn running_accrues_time_distance_and_speed() { + let mut s = session(); + s.start(); + for _ in 0..60 { + s.tick(powered(250), 1.0); + } + let snap = snapshot_of(&s.tick(powered(250), 1.0)); + assert_eq!(snap.elapsed_ms, 61_000); + assert!(snap.virtual_distance_m > 300.0); + assert!(snap.virtual_speed_kph > 20.0); + } + + // ---- pause ----------------------------------------------------------- + + #[test] + fn pausing_accrues_neither_time_nor_distance() { + let mut s = session(); + s.start(); + for _ in 0..30 { + s.tick(powered(250), 1.0); + } + let before = snapshot_of(&s.tick(powered(250), 1.0)); + + s.pause(); + for _ in 0..100 { + let events = s.tick(powered(250), 1.0); + // Paused: nothing new is commanded, the last target stands (SAF-1). + assert!(commands(&events).is_empty()); + } + let after = snapshot_of(&s.tick(powered(250), 1.0)); + assert_eq!(after.virtual_distance_m, before.virtual_distance_m); + assert_eq!(after.elapsed_ms, before.elapsed_ms); + assert_eq!(after.elevation_gain_m, before.elevation_gain_m); + assert_eq!(after.target, before.target); + } + + #[test] + fn an_idle_session_never_commands_the_trainer() { + let mut s = session(); + for _ in 0..5 { + assert!(commands(&s.tick(powered(300), 1.0)).is_empty()); + } + assert_eq!(s.last_target(), None); + assert_eq!( + snapshot_of(&s.tick(powered(300), 1.0)).virtual_distance_m, + 0.0 + ); + } + + #[test] + fn resuming_after_a_pause_continues_from_where_it_stopped() { + let mut s = session(); + s.start(); + for _ in 0..30 { + s.tick(powered(250), 1.0); + } + let mid = snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m; + s.pause(); + s.tick(powered(250), 1.0); + s.start(); + for _ in 0..10 { + s.tick(powered(250), 1.0); + } + assert!(snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m > mid); + } + + // ---- missing / hostile telemetry ------------------------------------ + + #[test] + fn missing_power_is_treated_as_zero() { + let mut s = session(); + s.start(); + for _ in 0..30 { + s.tick(powered(300), 1.0); + } + let moving = snapshot_of(&s.tick(powered(300), 1.0)).virtual_speed_kph; + assert!(moving > 10.0); + + // Empty packets — a real FTMS possibility, not a hypothetical. + for _ in 0..300 { + s.tick(Telemetry::default(), 1.0); + } + let snap = snapshot_of(&s.tick(Telemetry::default(), 1.0)); + assert!(snap.virtual_speed_kph < moving); + assert_eq!(snap.virtual_speed_kph, 0.0, "should coast to a stop"); + } + + #[test] + fn negative_power_does_not_drive_the_rider_backwards() { + let mut s = session(); + s.start(); + for _ in 0..50 { + let snap = snapshot_of(&s.tick(powered(-500), 1.0)); + assert!(snap.virtual_speed_kph >= 0.0); + assert_eq!(snap.virtual_distance_m, 0.0); + } + } + + #[test] + fn hostile_dt_does_not_corrupt_the_ride() { + let mut s = session(); + s.start(); + for dt in [f32::NAN, f32::INFINITY, -1.0, 0.0, 1e20] { + let snap = snapshot_of(&s.tick(powered(200), dt)); + assert!(snap.virtual_speed_kph.is_finite() && snap.virtual_speed_kph >= 0.0); + assert!(snap.virtual_distance_m.is_finite() && snap.virtual_distance_m >= 0.0); + } + } + + // ---- gradient offset ------------------------------------------------- + + #[test] + fn manual_grade_commands_the_trim_directly() { + let mut s = session(); + s.start(); + assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 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); + + s.reset_gradient_offset(); + assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0); + } + + #[test] + fn the_trim_adds_on_top_of_the_profile_gradient() { + let mut s = session(); + s.load_profile(Profile { + name: "flat-then-hill".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 4.0, + extent: Extent::Seconds(600.0), + }], + }); + s.start(); + assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.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. + assert_eq!(snapshot_of(&events).gradient_pct, 2.5); + } + + #[test] + fn the_trim_does_not_disturb_a_power_profile_target() { + let mut s = session(); + s.load_profile(Profile { + name: "erg".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Power, + value: 220.0, + extent: Extent::Seconds(600.0), + }], + }); + s.start(); + s.nudge_gradient(3.0); + let events = s.tick(powered(220), 1.0); + assert_eq!(commands(&events)[0], ControlTarget::Power { watts: 220 }); + // The trim still tilts the virtual road, which is what drives speed. + assert_eq!(snapshot_of(&events).gradient_pct, 3.0); + } + + // ---- safety clamping ------------------------------------------------- + + #[test] + fn out_of_range_gradients_are_clamped_before_transmission() { + let mut s = session(); + s.start(); + s.nudge_gradient(90.0); + let target = commands(&s.tick(powered(0), 1.0))[0]; + assert_eq!(gradient_of(target), s.limits.max_gradient_pct); + + s.reset_gradient_offset(); + s.nudge_gradient(-90.0); + // Two ticks: the first re-emits after the reset. + s.tick(powered(0), 1.0); + assert!(gradient_of(s.last_target().unwrap()) >= s.limits.min_gradient_pct); + assert_eq!( + gradient_of(s.last_target().unwrap()), + s.limits.min_gradient_pct + ); + } + + #[test] + fn an_absurd_profile_cannot_command_an_unsafe_target() { + // SAF-6: parameter errors must be caught by SAF-3, not by the profile. + let mut s = session(); + s.load_profile(Profile { + name: "runaway".into(), + description: None, + looping: false, + blocks: vec![ + Block::Constant { + channel: Channel::Power, + value: 5000.0, + extent: Extent::Seconds(10.0), + }, + Block::Constant { + channel: Channel::Gradient, + value: -400.0, + extent: Extent::Seconds(10.0), + }, + Block::Constant { + channel: Channel::Resistance, + value: 9000.0, + extent: Extent::Seconds(10.0), + }, + ], + }); + s.start(); + let mut seen = Vec::new(); + for _ in 0..29 { + seen.extend(commands(&s.tick(powered(200), 1.0))); + } + assert!(!seen.is_empty()); + for target in seen { + match target { + ControlTarget::Power { watts } => { + assert!((s.limits.min_power_w..=s.limits.max_power_w).contains(&watts)) + } + ControlTarget::Gradient { percent } => assert!((s.limits.min_gradient_pct + ..=s.limits.max_gradient_pct) + .contains(&percent)), + ControlTarget::Resistance { level } => { + assert!((s.limits.min_resistance..=s.limits.max_resistance).contains(&level)) + } + } + } + } + + #[test] + fn custom_limits_are_honoured() { + let mut s = RideSession::new( + RiderConfig::default(), + SafetyLimits { + min_gradient_pct: -2.0, + max_gradient_pct: 3.0, + ..Default::default() + }, + ); + s.start(); + s.nudge_gradient(10.0); + assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 3.0); + } + + // ---- rate limiting --------------------------------------------------- + + #[test] + fn an_unchanged_target_is_not_resent() { + let mut s = session(); + s.start(); + assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1); + 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)" + ); + } + s.nudge_gradient(1.0); + assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1); + } + + #[test] + fn sub_threshold_gradient_drift_is_suppressed() { + let mut s = session(); + s.start(); + s.tick(powered(0), 1.0); + s.nudge_gradient(0.01); + assert!(commands(&s.tick(powered(0), 1.0)).is_empty()); + for _ in 0..10 { + s.nudge_gradient(0.01); + } + assert_eq!(commands(&s.tick(powered(0), 1.0)).len(), 1); + } + + #[test] + fn a_continuously_varying_profile_stays_well_inside_the_write_budget() { + // A 10 Hz tick loop over a gradient ramp must not produce 10 writes a + // second; FR-2.8 caps them at four. + let mut s = session(); + s.load_profile(Profile { + name: "ramp".into(), + description: None, + looping: false, + blocks: vec![Block::Ramp { + channel: Channel::Gradient, + from: 0.0, + to: 6.0, + extent: Extent::Seconds(600.0), + }], + }); + s.start(); + let mut writes = 0; + 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"); + assert!(writes > 100); + } + + // ---- profile lifecycle ---------------------------------------------- + + #[test] + fn a_non_looping_profile_finishes_once() { + let mut s = session(); + s.load_profile(Profile { + name: "short".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 2.0, + extent: Extent::Seconds(5.0), + }], + }); + s.start(); + let mut finishes = 0; + for _ in 0..20 { + finishes += s + .tick(powered(200), 1.0) + .iter() + .filter(|e| matches!(e, SessionEvent::ProfileFinished)) + .count(); + } + assert_eq!(finishes, 1); + assert_eq!(s.status, RideStatus::Finished); + } + + #[test] + fn a_looping_profile_never_finishes() { + let mut s = session(); + s.load_profile(Profile { + name: "loop".into(), + description: None, + looping: true, + blocks: vec![Block::Segments { + segments: vec![ + Segment { + distance_m: 400.0, + gradient_pct: 0.0, + }, + Segment { + distance_m: 400.0, + gradient_pct: 5.0, + }, + ], + }], + }); + s.start(); + for _ in 0..1200 { + let events = s.tick(powered(250), 1.0); + assert!(!events + .iter() + .any(|e| matches!(e, SessionEvent::ProfileFinished))); + } + assert_eq!(s.status, RideStatus::Running); + assert!(s.physics().distance_m > 2000.0, "should have lapped"); + assert_eq!(s.profile_progress(), None); + } + + #[test] + fn progress_advances_from_zero_to_one() { + let mut s = session(); + s.load_profile(Profile { + name: "p".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 0.0, + extent: Extent::Seconds(100.0), + }], + }); + s.start(); + assert_eq!( + snapshot_of(&s.tick(powered(200), 0.0)).profile_progress, + Some(0.0) + ); + for _ in 0..50 { + s.tick(powered(200), 1.0); + } + let mid = snapshot_of(&s.tick(powered(200), 0.0)) + .profile_progress + .unwrap(); + assert!((mid - 0.5).abs() < 0.02, "{mid}"); + for _ in 0..60 { + s.tick(powered(200), 1.0); + } + assert_eq!( + snapshot_of(&s.tick(powered(200), 0.0)).profile_progress, + Some(1.0) + ); + } + + #[test] + fn a_distance_profile_advances_only_as_the_rider_rides() { + let mut s = session(); + s.load_profile(Profile { + name: "hill".into(), + description: None, + looping: false, + blocks: vec![Block::Segments { + segments: vec![ + Segment { + distance_m: 200.0, + gradient_pct: 0.0, + }, + Segment { + distance_m: 200.0, + gradient_pct: 8.0, + }, + ], + }], + }); + s.start(); + // No power, so no distance, so no progress no matter how long it runs. + for _ in 0..600 { + s.tick(Telemetry::default(), 1.0); + } + assert_eq!(s.profile_progress(), Some(0.0)); + assert!(s.status == RideStatus::Running); + + for _ in 0..600 { + s.tick(powered(250), 1.0); + } + assert_eq!(s.status, RideStatus::Finished); + } + + #[test] + fn a_wave_profile_drives_the_power_channel() { + let mut s = session(); + s.load_profile(Profile { + name: "over-unders".into(), + description: None, + looping: false, + blocks: vec![Block::Wave { + channel: Channel::Power, + shape: Waveform::Sine, + midpoint: 240.0, + amplitude: 40.0, + period: Extent::Seconds(120.0), + repeats: 2.0, + phase: 0.0, + }], + }); + s.start(); + let mut watts = Vec::new(); + for _ in 0..240 { + for target in commands(&s.tick(powered(240), 1.0)) { + match target { + ControlTarget::Power { watts: w } => watts.push(w), + other => panic!("unexpected {other:?}"), + } + } + } + assert!(watts.contains(&280), "peak never reached: {watts:?}"); + assert!(watts.contains(&200), "trough never reached"); + assert!(watts.iter().all(|w| (200..=280).contains(w))); + } + + #[test] + fn loading_a_profile_switches_into_profile_mode() { + let mut s = session(); + assert_eq!(s.mode, ControlMode::ManualGrade); + s.load_profile(Profile { + name: "p".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 1.0, + extent: Extent::Seconds(10.0), + }], + }); + assert_eq!(s.mode, ControlMode::Profile); + assert!(s.profile().is_some()); + } + + #[test] + fn a_gradient_profile_accumulates_elevation() { + let mut s = session(); + s.load_profile(Profile { + name: "climb".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Gradient, + value: 6.0, + extent: Extent::Seconds(1200.0), + }], + }); + s.start(); + for _ in 0..600 { + s.tick(powered(250), 1.0); + } + let snap = snapshot_of(&s.tick(powered(250), 0.0)); + let expected = snap.virtual_distance_m as f32 * (0.06f32.atan()).sin(); + assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02); + assert!(snap.elevation_gain_m > 50.0); } } diff --git a/crates/fit/Cargo.toml b/crates/fit/Cargo.toml index b4c1d18..6d9ff3e 100644 --- a/crates/fit/Cargo.toml +++ b/crates/fit/Cargo.toml @@ -8,3 +8,11 @@ license.workspace = true bikecontrol-core = { workspace = true } thiserror = { workspace = true } chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +# Independent third-party FIT *decoder* (MIT). Test-only: we encode with our own +# writer and decode with someone else's parser, which is a far stronger check +# than round-tripping through our own code. +fitparser = "0.11" diff --git a/crates/fit/src/builder.rs b/crates/fit/src/builder.rs new file mode 100644 index 0000000..94d489d --- /dev/null +++ b/crates/fit/src/builder.rs @@ -0,0 +1,1143 @@ +//! Turning a raw log into a FIT activity: aggregation, then assembly. +//! +//! Message order follows what Garmin devices produce, because that is what +//! every uploader has been tested against: +//! +//! ```text +//! file_id, device_info, event(timer/start), +//! [ record × n, lap ] × laps, +//! event(timer/stop_all), session, activity +//! ``` +//! +//! Records carry no `position_lat`/`position_long`: an indoor ride has no GPS, +//! and inventing coordinates is worse than omitting them. Combined with +//! `sub_sport = virtual_activity` this is how Strava is told to treat the file +//! as a Virtual Ride rather than an outdoor ride whose GPS failed. + +use crate::encode::{FitEncoder, Message, Value}; +use crate::profile::{activity, device_info, enums, event, file_id, lap, mesg, record, session}; +use crate::rawlog::{RawLog, Sample}; +use crate::{timestamp, FitError}; + +/// Local message type allocation. FIT allows sixteen; we use seven, so no +/// definition ever has to be evicted and re-emitted. +mod local { + pub const FILE_ID: u8 = 0; + pub const DEVICE_INFO: u8 = 1; + pub const EVENT: u8 = 2; + pub const RECORD: u8 = 3; + pub const LAP: u8 = 4; + pub const SESSION: u8 = 5; + pub const ACTIVITY: u8 = 6; +} + +/// What was written, for logging and for the UI to show after a ride. +#[derive(Debug, Clone, PartialEq)] +pub struct FitSummary { + /// Size of the encoded file in bytes. + pub bytes: usize, + /// Number of `record` messages. + pub records: usize, + /// Number of laps. + pub laps: usize, + /// Wall-clock duration of the session, seconds. + pub total_elapsed_s: f64, + /// Moving/recording time excluding explicit pauses, seconds. + pub total_timer_s: f64, + pub total_distance_m: f64, + pub total_ascent_m: u16, + pub avg_power_w: Option, + pub max_power_w: Option, + pub total_calories: Option, + /// Number of BLE dropouts spanned (FR-8.5). + pub gaps: usize, + /// False when the source log had no clean end marker, i.e. this activity + /// was recovered from a crash. + pub recovered_from_crash: bool, + /// Journal lines that could not be parsed. + pub skipped_log_lines: usize, +} + +/// Per-lap and per-session aggregates (FR-8: session/lap totals). +#[derive(Debug, Clone, Default, PartialEq)] +struct Aggregates { + start_fit: u32, + end_fit: u32, + total_elapsed_ms: u64, + total_timer_ms: u64, + start_distance_m: f64, + end_distance_m: f64, + power_sum: f64, + power_n: u32, + max_power: Option, + cadence_sum: f64, + cadence_n: u32, + max_cadence: Option, + speed_sum: f64, + speed_n: u32, + max_speed_mps: f64, + hr_sum: f64, + hr_n: u32, + max_hr: Option, + ascent_m: f64, + descent_m: f64, + grade_sum: f64, + grade_n: u32, + /// Mechanical work, joules, integrated from power. The basis for calories + /// when the trainer does not report energy directly. + work_j: f64, + /// Trainer-reported cumulative energy at the first and last sample. + energy_start: Option, + energy_end: Option, + records: usize, +} + +impl Aggregates { + fn total_distance_m(&self) -> f64 { + (self.end_distance_m - self.start_distance_m).max(0.0) + } + + fn avg_power(&self) -> Option { + (self.power_n > 0).then(|| clamp_u16(self.power_sum / f64::from(self.power_n))) + } + + fn avg_cadence(&self) -> Option { + (self.cadence_n > 0).then(|| clamp_u8(self.cadence_sum / f64::from(self.cadence_n))) + } + + fn avg_hr(&self) -> Option { + (self.hr_n > 0).then(|| clamp_u8(self.hr_sum / f64::from(self.hr_n))) + } + + /// Average speed in m/s. Computed from distance over timer time rather than + /// by averaging the samples, so that it is consistent with the distance and + /// duration shown alongside it. + fn avg_speed_mps(&self) -> f64 { + if self.total_timer_ms == 0 { + return 0.0; + } + self.total_distance_m() / (self.total_timer_ms as f64 / 1000.0) + } + + /// Calories. + /// + /// Prefers the trainer's own cumulative figure. Otherwise it uses the + /// cycling convention that kilojoules of mechanical work and dietary + /// kilocalories are numerically near-equal — human efficiency of roughly + /// 24% and the 4.184 kJ/kcal conversion very nearly cancel. This is the + /// same approximation Strava and Garmin apply to a power-meter ride. + fn calories(&self) -> Option { + match (self.energy_start, self.energy_end) { + (Some(a), Some(b)) if b >= a && b > 0 => return Some(b - a), + _ => {} + } + (self.work_j > 0.0).then(|| clamp_u16(self.work_j / 1000.0)) + } + + fn avg_grade_pct(&self) -> Option { + (self.grade_n > 0).then(|| self.grade_sum / f64::from(self.grade_n)) + } +} + +/// A sample with its absolute FIT timestamp and altitude resolved. +struct Resolved { + sample: Sample, + fit_time: u32, + altitude_m: f64, +} + +/// Encode a raw log as a FIT activity file. +/// +/// This is the whole encoder: [`crate::Recorder::finish`] and +/// [`crate::build_fit_from_log`] both come through here, so a file rebuilt +/// after a crash is byte-identical to one written by a clean shutdown of the +/// same ride. +pub fn encode_activity(log: &RawLog) -> Result<(Vec, FitSummary), FitError> { + let start_fit = timestamp::from_unix_millis(log.start.start_unix_ms)?; + let resolved = resolve_samples(log, start_fit)?; + if resolved.is_empty() { + return Err(FitError::NoSamples); + } + + let end_ms = resolved.last().map_or(0, |r| r.sample.elapsed_ms); + let lap_bounds = lap_boundaries(log, end_ms); + let paused_ms = log.paused_ms(end_ms); + + // Split samples into laps by elapsed time. A lap owns samples in + // [start, end); the last lap owns everything remaining. + let mut lap_aggs: Vec = Vec::with_capacity(lap_bounds.len()); + let mut lap_slices: Vec<(usize, usize)> = Vec::with_capacity(lap_bounds.len()); + let mut cursor = 0usize; + for (i, &(lap_start_ms, lap_end_ms)) in lap_bounds.iter().enumerate() { + let is_last = i + 1 == lap_bounds.len(); + let begin = cursor; + while cursor < resolved.len() { + let t = resolved[cursor].sample.elapsed_ms; + if !is_last && t >= lap_end_ms { + break; + } + cursor += 1; + } + lap_slices.push((begin, cursor)); + // Pause time attributable to this lap. + let lap_paused = paused_within(log, lap_start_ms, lap_end_ms, end_ms); + lap_aggs.push(aggregate( + &resolved[begin..cursor], + start_fit, + lap_start_ms, + lap_end_ms, + lap_paused, + )); + } + + let session_agg = aggregate(&resolved, start_fit, 0, end_ms, paused_ms); + let bytes = assemble(log, &resolved, &lap_slices, &lap_aggs, &session_agg, start_fit)?; + + let summary = FitSummary { + bytes: bytes.len(), + records: resolved.len(), + laps: lap_aggs.len(), + total_elapsed_s: session_agg.total_elapsed_ms as f64 / 1000.0, + total_timer_s: session_agg.total_timer_ms as f64 / 1000.0, + total_distance_m: session_agg.total_distance_m(), + total_ascent_m: clamp_u16(session_agg.ascent_m), + avg_power_w: session_agg.avg_power(), + max_power_w: session_agg.max_power, + total_calories: session_agg.calories(), + gaps: log.gaps(end_ms).len(), + recovered_from_crash: !log.clean_shutdown, + skipped_log_lines: log.skipped_lines, + }; + Ok((bytes, summary)) +} + +/// Attach absolute timestamps and altitudes to the samples. +/// +/// Altitude: if the sample carries one (from a GPX route) it is used verbatim. +/// Otherwise a profile is synthesised by integrating gradient over distance, +/// which is the only altitude an indoor ride has. Without it Strava draws a +/// flat line for a ride up a simulated climb. +fn resolve_samples(log: &RawLog, start_fit: u32) -> Result, FitError> { + let mut out: Vec = Vec::new(); + let mut altitude = 0.0f64; + let mut prev_distance: Option = None; + let mut prev_time: Option = None; + + for sample in log.samples() { + let fit_time = start_fit + .checked_add(u32::try_from(sample.elapsed_ms / 1000).unwrap_or(u32::MAX)) + .ok_or(FitError::TimestampOutOfRange { + unix_secs: i64::from(u32::MAX), + })?; + + // FIT record timestamps have one-second resolution. Two samples in the + // same second would produce duplicate timestamps, which some parsers + // treat as corruption; keep the later one. + if prev_time == Some(fit_time) { + out.pop(); + } + prev_time = Some(fit_time); + + let delta_d = match prev_distance { + Some(prev) => (sample.distance_m - prev).max(0.0), + None => 0.0, + }; + prev_distance = Some(sample.distance_m); + + let altitude_m = match sample.altitude_m { + Some(a) => { + altitude = f64::from(a); + altitude + } + None => { + altitude += delta_d * f64::from(sample.gradient_pct) / 100.0; + altitude + } + }; + + out.push(Resolved { + sample: sample.clone(), + fit_time, + altitude_m, + }); + } + Ok(out) +} + +/// Lap boundaries as `(start_ms, end_ms)` pairs covering the whole ride. +/// +/// A lap marker at time `t` ends the lap in progress at `t` and starts the next +/// one there. Markers at or beyond the end of the ride, and duplicates, are +/// ignored — a zero-length lap makes some importers unhappy and carries no +/// information. +fn lap_boundaries(log: &RawLog, end_ms: u64) -> Vec<(u64, u64)> { + let mut marks: Vec = log.lap_marks().filter(|&t| t > 0 && t < end_ms).collect(); + marks.sort_unstable(); + marks.dedup(); + + let mut bounds = Vec::with_capacity(marks.len() + 1); + let mut prev = 0u64; + for m in marks { + bounds.push((prev, m)); + prev = m; + } + bounds.push((prev, end_ms)); + bounds +} + +/// Pause time falling inside `[from_ms, to_ms)`. +fn paused_within(log: &RawLog, from_ms: u64, to_ms: u64, fallback_end_ms: u64) -> u64 { + use crate::rawlog::LogEntry; + let mut total = 0u64; + let mut paused_at: Option = None; + for entry in &log.entries { + match entry { + LogEntry::Pause { at_ms } => { + if paused_at.is_none() { + paused_at = Some(*at_ms); + } + } + LogEntry::Resume { at_ms } => { + if let Some(start) = paused_at.take() { + total += overlap(start, *at_ms, from_ms, to_ms); + } + } + _ => {} + } + } + if let Some(start) = paused_at { + total += overlap(start, fallback_end_ms, from_ms, to_ms); + } + total +} + +fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 { + a1.min(b1).saturating_sub(a0.max(b0)) +} + +/// Fold a slice of samples into lap or session aggregates. +fn aggregate( + samples: &[Resolved], + start_fit: u32, + from_ms: u64, + to_ms: u64, + paused_ms: u64, +) -> Aggregates { + let mut agg = Aggregates { + start_fit: start_fit + (from_ms / 1000) as u32, + end_fit: start_fit + (to_ms / 1000) as u32, + total_elapsed_ms: to_ms.saturating_sub(from_ms), + records: samples.len(), + ..Default::default() + }; + agg.total_timer_ms = agg.total_elapsed_ms.saturating_sub(paused_ms); + + let mut prev_alt: Option = None; + let mut prev_ms: Option = None; + + for (i, r) in samples.iter().enumerate() { + let s = &r.sample; + if i == 0 { + agg.start_distance_m = s.distance_m; + agg.energy_start = s.energy_kcal; + } + agg.end_distance_m = s.distance_m; + if s.energy_kcal.is_some() { + agg.energy_end = s.energy_kcal; + } + + // Sample interval, for work integration. Clamped so that a long BLE + // dropout does not silently attribute minutes of work to one sample. + let dt_s = match prev_ms { + Some(prev) => ((s.elapsed_ms.saturating_sub(prev)) as f64 / 1000.0).clamp(0.0, 10.0), + None => 0.0, + }; + prev_ms = Some(s.elapsed_ms); + + if let Some(p) = s.power_w { + let p = f64::from(p).max(0.0); + agg.power_sum += p; + agg.power_n += 1; + let pw = clamp_u16(p); + agg.max_power = Some(agg.max_power.map_or(pw, |m| m.max(pw))); + agg.work_j += p * dt_s; + } + if let Some(c) = s.cadence_rpm { + if c.is_finite() { + agg.cadence_sum += f64::from(c); + agg.cadence_n += 1; + let cu = clamp_u8(f64::from(c)); + agg.max_cadence = Some(agg.max_cadence.map_or(cu, |m| m.max(cu))); + } + } + if s.speed_kph.is_finite() { + let mps = f64::from(s.speed_kph) / 3.6; + agg.speed_sum += mps; + agg.speed_n += 1; + agg.max_speed_mps = agg.max_speed_mps.max(mps); + } + if let Some(h) = s.heart_rate_bpm { + if h > 0 { + agg.hr_sum += f64::from(h); + agg.hr_n += 1; + agg.max_hr = Some(agg.max_hr.map_or(h, |m| m.max(h))); + } + } + if s.gradient_pct.is_finite() { + agg.grade_sum += f64::from(s.gradient_pct); + agg.grade_n += 1; + } + + if let Some(prev) = prev_alt { + let d = r.altitude_m - prev; + if d > 0.0 { + agg.ascent_m += d; + } else { + agg.descent_m -= d; + } + } + prev_alt = Some(r.altitude_m); + } + + agg +} + +/// Emit the message stream. +fn assemble( + log: &RawLog, + resolved: &[Resolved], + lap_slices: &[(usize, usize)], + lap_aggs: &[Aggregates], + session_agg: &Aggregates, + start_fit: u32, +) -> Result, FitError> { + let mut enc = FitEncoder::new(); + let end_fit = session_agg.end_fit; + + // --- file_id ----------------------------------------------------------- + let mut m = Message::new(); + m.set(file_id::TYPE, Value::Enum(enums::FILE_ACTIVITY)); + m.set( + file_id::MANUFACTURER, + Value::Uint16(enums::MANUFACTURER_DEVELOPMENT), + ); + m.set(file_id::PRODUCT, Value::Uint16(1)); + m.set( + file_id::SERIAL_NUMBER, + Value::Uint32z(log.start.serial_number), + ); + m.set(file_id::TIME_CREATED, Value::Uint32(start_fit)); + m.set( + file_id::PRODUCT_NAME, + Value::String(log.start.product_name.clone()), + ); + enc.write_message(local::FILE_ID, mesg::FILE_ID, &m); + + // --- device_info ------------------------------------------------------- + let mut m = Message::new(); + m.set(device_info::TIMESTAMP, Value::Uint32(start_fit)); + m.set( + device_info::DEVICE_INDEX, + Value::Uint8(enums::DEVICE_INDEX_CREATOR), + ); + m.set( + device_info::MANUFACTURER, + Value::Uint16(enums::MANUFACTURER_DEVELOPMENT), + ); + m.set(device_info::PRODUCT, Value::Uint16(1)); + m.set( + device_info::SOFTWARE_VERSION, + Value::Uint16(log.start.software_version), + ); + m.set( + device_info::SOURCE_TYPE, + Value::Enum(enums::SOURCE_TYPE_LOCAL), + ); + m.set( + device_info::PRODUCT_NAME, + Value::String(log.start.product_name.clone()), + ); + enc.write_message(local::DEVICE_INFO, mesg::DEVICE_INFO, &m); + + // --- timer start ------------------------------------------------------- + write_timer_event(&mut enc, start_fit, enums::EVENT_TYPE_START); + + // --- records, laps ----------------------------------------------------- + let field_mask = FieldMask::of(resolved); + for (lap_index, (&(begin, end), agg)) in lap_slices.iter().zip(lap_aggs).enumerate() { + for r in &resolved[begin..end] { + enc.write_message(local::RECORD, mesg::RECORD, &record_message(r, &field_mask)); + } + let is_last = lap_index + 1 == lap_aggs.len(); + enc.write_message( + local::LAP, + mesg::LAP, + &lap_message(lap_index as u16, agg, log.start.sub_sport, is_last), + ); + } + + // --- timer stop, session, activity ------------------------------------- + write_timer_event(&mut enc, end_fit, enums::EVENT_TYPE_STOP_ALL); + enc.write_message( + local::SESSION, + mesg::SESSION, + &session_message(session_agg, log.start.sub_sport, lap_aggs.len() as u16), + ); + + let mut m = Message::new(); + m.set(activity::TIMESTAMP, Value::Uint32(end_fit)); + m.set( + activity::TOTAL_TIMER_TIME, + Value::Uint32(scale_ms_to_millis_u32(session_agg.total_timer_ms)), + ); + m.set(activity::NUM_SESSIONS, Value::Uint16(1)); + m.set(activity::TYPE, Value::Enum(enums::ACTIVITY_MANUAL)); + m.set(activity::EVENT, Value::Enum(enums::EVENT_ACTIVITY)); + m.set(activity::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP)); + m.set( + activity::LOCAL_TIMESTAMP, + Value::Uint32(timestamp::to_local(end_fit, log.start.utc_offset_secs)), + ); + enc.write_message(local::ACTIVITY, mesg::ACTIVITY, &m); + + Ok(enc.finish()) +} + +fn write_timer_event(enc: &mut FitEncoder, at: u32, event_type: u8) { + let mut m = Message::new(); + m.set(event::TIMESTAMP, Value::Uint32(at)); + m.set(event::EVENT, Value::Enum(enums::EVENT_TIMER)); + m.set(event::EVENT_TYPE, Value::Enum(event_type)); + m.set(event::EVENT_GROUP, Value::Uint8(0)); + enc.write_message(local::EVENT, mesg::EVENT, &m); +} + +/// Which optional record fields any sample in the ride actually carries. +/// +/// A definition message is shared by every record, so a field must be either +/// present throughout or absent throughout. Deciding once, up front, means a +/// ride without a heart-rate strap carries no heart-rate field at all rather +/// than an hour of "invalid" bytes that some importers render as a flat zero +/// trace. +struct FieldMask { + power: bool, + cadence: bool, + heart_rate: bool, + resistance: bool, +} + +impl FieldMask { + fn of(resolved: &[Resolved]) -> Self { + Self { + power: resolved.iter().any(|r| r.sample.power_w.is_some()), + cadence: resolved.iter().any(|r| r.sample.cadence_rpm.is_some()), + heart_rate: resolved + .iter() + .any(|r| r.sample.heart_rate_bpm.is_some_and(|h| h > 0)), + resistance: resolved.iter().any(|r| r.sample.resistance.is_some()), + } + } +} + +/// Build one `record` message (FR-8.1 / FR-8.3). +/// +/// Fields the mask includes are always written; a sample missing one gets the +/// base type's invalid value, which is how FIT represents a momentary sensor +/// dropout within an otherwise-present stream. +fn record_message(r: &Resolved, mask: &FieldMask) -> Message { + let s = &r.sample; + let mut m = Message::new(); + m.set(record::TIMESTAMP, Value::Uint32(r.fit_time)); + + // altitude: (metres + 500) * 5, uint16. + m.set( + record::ALTITUDE, + Value::Uint16(clamp_u16((r.altitude_m + 500.0) * 5.0)), + ); + // distance: centimetres, uint32. + m.set( + record::DISTANCE, + Value::Uint32(clamp_u32(s.distance_m * 100.0)), + ); + // speed: mm/s, uint16. + m.set( + record::SPEED, + Value::Uint16(clamp_u16(f64::from(s.speed_kph) / 3.6 * 1000.0)), + ); + // grade: percent * 100, sint16. + m.set( + record::GRADE, + Value::Sint16(clamp_i16(f64::from(s.gradient_pct) * 100.0)), + ); + + if mask.power { + m.set( + record::POWER, + Value::Uint16(match s.power_w { + Some(p) => clamp_u16(f64::from(p).max(0.0)), + None => INVALID_U16, + }), + ); + } + if mask.cadence { + m.set( + record::CADENCE, + Value::Uint8(match s.cadence_rpm { + Some(c) if c.is_finite() => clamp_u8(f64::from(c)), + _ => INVALID_U8, + }), + ); + } + if mask.heart_rate { + m.set( + record::HEART_RATE, + Value::Uint8(match s.heart_rate_bpm { + Some(h) if h > 0 => h, + _ => INVALID_U8, + }), + ); + } + if mask.resistance { + m.set( + record::RESISTANCE, + Value::Uint8(match s.resistance { + Some(v) => clamp_u8(f64::from(v)), + None => INVALID_U8, + }), + ); + } + m +} + +fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Message { + let mut m = Message::new(); + m.set(lap::MESSAGE_INDEX, Value::Uint16(index)); + m.set(lap::TIMESTAMP, Value::Uint32(agg.end_fit)); + m.set(lap::EVENT, Value::Enum(enums::EVENT_LAP)); + m.set(lap::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP)); + m.set(lap::START_TIME, Value::Uint32(agg.start_fit)); + m.set( + lap::TOTAL_ELAPSED_TIME, + Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)), + ); + m.set( + lap::TOTAL_TIMER_TIME, + Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)), + ); + m.set( + lap::TOTAL_DISTANCE, + Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)), + ); + m.set_opt(lap::TOTAL_CALORIES, agg.calories().map(Value::Uint16)); + m.set( + lap::AVG_SPEED, + Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)), + ); + m.set( + lap::MAX_SPEED, + Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)), + ); + m.set_opt(lap::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8)); + m.set_opt(lap::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8)); + m.set_opt(lap::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8)); + m.set_opt(lap::MAX_CADENCE, agg.max_cadence.map(Value::Uint8)); + m.set_opt(lap::AVG_POWER, agg.avg_power().map(Value::Uint16)); + m.set_opt(lap::MAX_POWER, agg.max_power.map(Value::Uint16)); + m.set(lap::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m))); + m.set(lap::TOTAL_DESCENT, Value::Uint16(clamp_u16(agg.descent_m))); + m.set(lap::INTENSITY, Value::Enum(enums::INTENSITY_ACTIVE)); + m.set( + lap::LAP_TRIGGER, + Value::Enum(if is_last { + enums::LAP_TRIGGER_SESSION_END + } else { + enums::LAP_TRIGGER_MANUAL + }), + ); + m.set(lap::SPORT, Value::Enum(enums::SPORT_CYCLING)); + m.set(lap::SUB_SPORT, Value::Enum(sub_sport)); + m.set(lap::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j))); + m.set_opt( + lap::AVG_GRADE, + agg.avg_grade_pct() + .map(|g| Value::Sint16(clamp_i16(g * 100.0))), + ); + m +} + +fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message { + let mut m = Message::new(); + m.set(session::MESSAGE_INDEX, Value::Uint16(0)); + m.set(session::TIMESTAMP, Value::Uint32(agg.end_fit)); + m.set(session::EVENT, Value::Enum(enums::EVENT_SESSION)); + m.set(session::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP)); + m.set(session::START_TIME, Value::Uint32(agg.start_fit)); + m.set(session::SPORT, Value::Enum(enums::SPORT_CYCLING)); + m.set(session::SUB_SPORT, Value::Enum(sub_sport)); + m.set( + session::TOTAL_ELAPSED_TIME, + Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)), + ); + m.set( + session::TOTAL_TIMER_TIME, + Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)), + ); + m.set( + session::TOTAL_DISTANCE, + Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)), + ); + m.set_opt(session::TOTAL_CALORIES, agg.calories().map(Value::Uint16)); + m.set( + session::AVG_SPEED, + Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)), + ); + m.set( + session::MAX_SPEED, + Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)), + ); + m.set_opt(session::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8)); + m.set_opt(session::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8)); + m.set_opt(session::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8)); + m.set_opt(session::MAX_CADENCE, agg.max_cadence.map(Value::Uint8)); + m.set_opt(session::AVG_POWER, agg.avg_power().map(Value::Uint16)); + m.set_opt(session::MAX_POWER, agg.max_power.map(Value::Uint16)); + m.set(session::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m))); + m.set( + session::TOTAL_DESCENT, + Value::Uint16(clamp_u16(agg.descent_m)), + ); + m.set(session::FIRST_LAP_INDEX, Value::Uint16(0)); + m.set(session::NUM_LAPS, Value::Uint16(num_laps.max(1))); + m.set( + session::TRIGGER, + Value::Enum(enums::SESSION_TRIGGER_ACTIVITY_END), + ); + m.set(session::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j))); + m +} + +// --- numeric helpers ------------------------------------------------------- + +/// The `uint16` invalid value. +const INVALID_U16: u16 = 0xFFFF; +/// The `uint8` invalid value. +const INVALID_U8: u8 = 0xFF; + +/// Milliseconds as a FIT `uint32` scaled by 1000 (i.e. milliseconds), saturating. +fn scale_ms_to_millis_u32(ms: u64) -> u32 { + u32::try_from(ms).unwrap_or(u32::MAX - 1) +} + +/// Round and clamp into `u16`, keeping clear of the invalid sentinel so a real +/// measurement is never mistaken for missing data. +fn clamp_u16(v: f64) -> u16 { + if !v.is_finite() || v <= 0.0 { + return 0; + } + v.round().min(f64::from(INVALID_U16 - 1)) as u16 +} + +fn clamp_u32(v: f64) -> u32 { + if !v.is_finite() || v <= 0.0 { + return 0; + } + v.round().min(f64::from(u32::MAX - 1)) as u32 +} + +fn clamp_u8(v: f64) -> u8 { + if !v.is_finite() || v <= 0.0 { + return 0; + } + v.round().min(f64::from(INVALID_U8 - 1)) as u8 +} + +fn clamp_i16(v: f64) -> i16 { + if !v.is_finite() { + return 0; + } + v.round().clamp(f64::from(i16::MIN + 1), f64::from(i16::MAX - 1)) as i16 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rawlog::{LogEntry, SessionStart}; + + fn log_with(entries: Vec) -> RawLog { + RawLog { + start: SessionStart { + start_unix_ms: 1_785_000_000_000, + ..Default::default() + }, + entries, + skipped_lines: 0, + clean_shutdown: true, + path: None, + } + } + + fn ride(seconds: u64) -> RawLog { + let mut entries = Vec::new(); + for i in 0..seconds { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + cadence_rpm: Some(90.0), + speed_kph: 36.0, // 10 m/s + distance_m: (i * 10) as f64, + gradient_pct: 0.0, + ..Default::default() + })); + } + entries.push(LogEntry::End { + at_ms: (seconds - 1) * 1000, + }); + log_with(entries) + } + + #[test] + fn clamps_behave_at_the_edges() { + assert_eq!(clamp_u16(-5.0), 0); + assert_eq!(clamp_u16(f64::NAN), 0); + assert_eq!(clamp_u16(1e30), 0xFFFE, "never reaches the invalid value"); + assert_eq!(clamp_u16(2.5), 3); + assert_eq!(clamp_u8(1e9), 0xFE); + assert_eq!(clamp_u32(1e30), u32::MAX - 1); + assert_eq!(clamp_i16(-1e9), i16::MIN + 1); + assert_eq!(clamp_i16(1e9), i16::MAX - 1); + assert_eq!(clamp_i16(f64::NAN), 0); + assert_eq!(clamp_i16(-250.0), -250); + } + + #[test] + fn a_log_with_no_samples_is_rejected_rather_than_written_empty() { + let log = log_with(vec![LogEntry::End { at_ms: 0 }]); + assert!(matches!(encode_activity(&log), Err(FitError::NoSamples))); + } + + #[test] + fn one_lap_by_default_covering_the_whole_ride() { + let log = ride(10); + assert_eq!(lap_boundaries(&log, 9000), vec![(0, 9000)]); + } + + #[test] + fn lap_markers_split_the_ride() { + let mut log = ride(100); + log.entries.push(LogEntry::Lap { + at_ms: 30_000, + from_controller: true, + }); + log.entries.push(LogEntry::Lap { + at_ms: 60_000, + from_controller: false, + }); + assert_eq!(lap_boundaries(&log, 99_000), vec![ + (0, 30_000), + (30_000, 60_000), + (60_000, 99_000) + ]); + } + + #[test] + fn degenerate_lap_markers_are_ignored() { + let mut log = ride(50); + // At the very start, past the end, and a duplicate. + log.entries.push(LogEntry::Lap { + at_ms: 0, + from_controller: false, + }); + log.entries.push(LogEntry::Lap { + at_ms: 999_999, + from_controller: false, + }); + log.entries.push(LogEntry::Lap { + at_ms: 20_000, + from_controller: false, + }); + log.entries.push(LogEntry::Lap { + at_ms: 20_000, + from_controller: false, + }); + assert_eq!(lap_boundaries(&log, 49_000), vec![(0, 20_000), (20_000, 49_000)]); + } + + #[test] + fn aggregates_match_hand_computed_values() { + let log = ride(11); // samples at 0..10 s, 10 m/s, 200 W, 90 rpm + let (_, summary) = encode_activity(&log).unwrap(); + assert_eq!(summary.records, 11); + assert_eq!(summary.laps, 1); + assert_eq!(summary.total_elapsed_s, 10.0); + assert_eq!(summary.total_timer_s, 10.0); + assert_eq!(summary.total_distance_m, 100.0); + assert_eq!(summary.avg_power_w, Some(200)); + assert_eq!(summary.max_power_w, Some(200)); + // 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal. + assert_eq!(summary.total_calories, Some(2)); + } + + #[test] + fn pauses_reduce_timer_time_but_not_elapsed_time() { + let mut log = ride(101); + log.entries.push(LogEntry::Pause { at_ms: 20_000 }); + log.entries.push(LogEntry::Resume { at_ms: 50_000 }); + let (_, summary) = encode_activity(&log).unwrap(); + assert_eq!(summary.total_elapsed_s, 100.0); + assert_eq!(summary.total_timer_s, 70.0); + } + + #[test] + fn a_ble_dropout_is_a_hole_in_the_records_not_a_failure() { + // Samples 0..5 s, nothing for 30 s, then 35..40 s. + let mut entries = Vec::new(); + for i in 0..6u64 { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + speed_kph: 36.0, + distance_m: (i * 10) as f64, + ..Default::default() + })); + } + entries.push(LogEntry::Gap { + at_ms: 5_000, + until_ms: Some(35_000), + reason: "peripheral disconnected".into(), + }); + for i in 35..41u64 { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + speed_kph: 36.0, + distance_m: (i * 10) as f64, + ..Default::default() + })); + } + entries.push(LogEntry::End { at_ms: 40_000 }); + let log = log_with(entries); + + let (bytes, summary) = encode_activity(&log).unwrap(); + assert!(crate::encode::verify(&bytes).is_ok()); + assert_eq!(summary.records, 12, "only the samples we actually have"); + assert_eq!(summary.gaps, 1); + // The timer keeps running across a dropout: the rider was still riding. + assert_eq!(summary.total_elapsed_s, 40.0); + assert_eq!(summary.total_timer_s, 40.0); + // Work is not inflated by attributing the whole 30 s gap to one sample. + assert!( + summary.total_calories.unwrap() < 8, + "gap must not be integrated as full-power work, got {:?}", + summary.total_calories + ); + } + + #[test] + fn altitude_is_integrated_from_gradient_and_distance() { + // 100 m at 10% should climb 10 m. + let mut entries = Vec::new(); + for i in 0..11u64 { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(250), + speed_kph: 36.0, + distance_m: (i * 10) as f64, + gradient_pct: 10.0, + ..Default::default() + })); + } + entries.push(LogEntry::End { at_ms: 10_000 }); + let log = log_with(entries); + let resolved = resolve_samples(&log, 0).unwrap(); + assert!((resolved.last().unwrap().altitude_m - 10.0).abs() < 1e-9); + + let (_, summary) = encode_activity(&log).unwrap(); + assert_eq!(summary.total_ascent_m, 10); + } + + #[test] + fn an_explicit_altitude_overrides_the_integrated_profile() { + let entries = vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + distance_m: 0.0, + altitude_m: Some(1200.0), + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 1000, + distance_m: 10.0, + gradient_pct: 50.0, + altitude_m: Some(1205.0), + ..Default::default() + }), + LogEntry::End { at_ms: 1000 }, + ]; + let log = log_with(entries); + let resolved = resolve_samples(&log, 0).unwrap(); + assert_eq!(resolved[0].altitude_m, 1200.0); + assert_eq!(resolved[1].altitude_m, 1205.0); + } + + #[test] + fn duplicate_second_samples_are_collapsed() { + // The engine ticks faster than 1 Hz; two samples landing in the same + // second must not produce two records with the same timestamp. + let entries = vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + power_w: Some(100), + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 400, + power_w: Some(150), + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 1000, + power_w: Some(200), + ..Default::default() + }), + LogEntry::End { at_ms: 1000 }, + ]; + let log = log_with(entries); + let resolved = resolve_samples(&log, 100).unwrap(); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].fit_time, 100); + assert_eq!(resolved[0].sample.power_w, Some(150), "later sample wins"); + assert_eq!(resolved[1].fit_time, 101); + } + + #[test] + fn the_record_definition_omits_sensors_the_ride_never_had() { + let log = ride(5); // power and cadence, no heart rate + let resolved = resolve_samples(&log, 0).unwrap(); + let mask = FieldMask::of(&resolved); + assert!(mask.power); + assert!(mask.cadence); + assert!(!mask.heart_rate); + assert!(!mask.resistance); + + let msg = record_message(&resolved[0], &mask); + let fields: Vec = msg.fields().iter().map(|(n, _)| *n).collect(); + assert!(fields.contains(&record::POWER)); + assert!(!fields.contains(&record::HEART_RATE)); + } + + #[test] + fn record_scaling_is_the_profile_scaling() { + let entries = vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + power_w: Some(250), + cadence_rpm: Some(92.4), + speed_kph: 36.0, + distance_m: 1234.56, + gradient_pct: -3.25, + heart_rate_bpm: Some(151), + altitude_m: Some(100.0), + ..Default::default() + }), + LogEntry::End { at_ms: 0 }, + ]; + let log = log_with(entries); + let resolved = resolve_samples(&log, 0).unwrap(); + let msg = record_message(&resolved[0], &FieldMask::of(&resolved)); + let get = |n: u8| msg.fields().iter().find(|(f, _)| *f == n).map(|(_, v)| v.clone()); + + assert_eq!(get(record::POWER), Some(Value::Uint16(250)), "watts, unscaled"); + assert_eq!(get(record::CADENCE), Some(Value::Uint8(92)), "rpm, rounded"); + assert_eq!(get(record::SPEED), Some(Value::Uint16(10_000)), "mm/s"); + assert_eq!(get(record::DISTANCE), Some(Value::Uint32(123_456)), "cm"); + assert_eq!(get(record::GRADE), Some(Value::Sint16(-325)), "percent x100"); + assert_eq!(get(record::HEART_RATE), Some(Value::Uint8(151)), "bpm"); + // (100 m + 500) * 5 + assert_eq!(get(record::ALTITUDE), Some(Value::Uint16(3000))); + } + + #[test] + fn lap_and_session_do_not_share_field_numbers() { + // Guards the single most dangerous transcription error in this crate. + assert_ne!(lap::AVG_POWER, session::AVG_POWER); + assert_eq!(lap::AVG_POWER, 19); + assert_eq!(session::AVG_POWER, 20); + assert_eq!(lap::AVG_SPEED, 13); + assert_eq!(session::AVG_SPEED, 14); + assert_eq!(lap::TOTAL_ASCENT, 21); + assert_eq!(session::TOTAL_ASCENT, 22); + } + + #[test] + fn lap_aggregates_sum_to_the_session() { + let mut log = ride(61); + log.entries.push(LogEntry::Lap { + at_ms: 20_000, + from_controller: true, + }); + log.entries.push(LogEntry::Lap { + at_ms: 40_000, + from_controller: true, + }); + let (bytes, summary) = encode_activity(&log).unwrap(); + assert!(crate::encode::verify(&bytes).is_ok()); + assert_eq!(summary.laps, 3); + assert_eq!(summary.records, 61); + assert_eq!(summary.total_elapsed_s, 60.0); + assert_eq!(summary.total_distance_m, 600.0); + } + + #[test] + fn trainer_reported_energy_is_preferred_for_calories() { + let entries = vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + power_w: Some(200), + energy_kcal: Some(10), + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 60_000, + power_w: Some(200), + energy_kcal: Some(210), + ..Default::default() + }), + LogEntry::End { at_ms: 60_000 }, + ]; + let (_, summary) = encode_activity(&log_with(entries)).unwrap(); + assert_eq!(summary.total_calories, Some(200)); + } + + #[test] + fn a_crashed_log_still_encodes_and_says_so() { + let mut log = ride(30); + log.entries.retain(|e| !matches!(e, LogEntry::End { .. })); + log.clean_shutdown = false; + log.skipped_lines = 1; + let (bytes, summary) = encode_activity(&log).unwrap(); + assert!(crate::encode::verify(&bytes).is_ok()); + assert!(summary.recovered_from_crash); + assert_eq!(summary.skipped_log_lines, 1); + assert_eq!(summary.records, 30); + } + + #[test] + fn a_pre_epoch_start_time_is_rejected() { + let mut log = ride(5); + log.start.start_unix_ms = 0; // 1970 + assert!(matches!( + encode_activity(&log), + Err(FitError::TimestampOutOfRange { .. }) + )); + } + + #[test] + fn encoding_is_deterministic() { + // A file rebuilt from the same log must be byte-identical, which is + // what makes crash recovery trustworthy. + let log = ride(20); + let (a, _) = encode_activity(&log).unwrap(); + let (b, _) = encode_activity(&log).unwrap(); + assert_eq!(a, b); + } +} diff --git a/crates/fit/src/crc.rs b/crates/fit/src/crc.rs new file mode 100644 index 0000000..9a0ae47 --- /dev/null +++ b/crates/fit/src/crc.rs @@ -0,0 +1,120 @@ +//! The FIT CRC-16. +//! +//! The FIT specification defines a nibble-table CRC. It is bit-for-bit +//! CRC-16/ARC (reflected polynomial `0xA001`, init `0x0000`, no final XOR), +//! which gives us published test vectors to check against — see the tests. +//! +//! Two CRCs appear in every FIT file and both must be right or the file is +//! silently rejected on upload: +//! +//! * the *header CRC* — bytes 0..12 of a 14-byte header, stored at bytes 12..14; +//! * the *file CRC* — every byte from the start of the header through the end +//! of the data records, appended as the last two bytes of the file. + +/// Nibble lookup table from the FIT SDK. +const CRC_TABLE: [u16; 16] = [ + 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, + 0x5000, 0x9C01, 0x8801, 0x4400, +]; + +/// Running FIT CRC-16 state. +/// +/// Lets the encoder checksum bytes as they are produced rather than buffering +/// the whole file twice. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Crc16(u16); + +impl Crc16 { + /// A fresh CRC with the FIT initial value (zero). + pub const fn new() -> Self { + Self(0) + } + + /// Fold `data` into the running CRC. + pub fn update(&mut self, data: &[u8]) { + let mut crc = self.0; + for &byte in data { + // Low nibble, then high nibble. + let mut tmp = CRC_TABLE[(crc & 0xF) as usize]; + crc = (crc >> 4) & 0x0FFF; + crc = crc ^ tmp ^ CRC_TABLE[(byte & 0xF) as usize]; + + tmp = CRC_TABLE[(crc & 0xF) as usize]; + crc = (crc >> 4) & 0x0FFF; + crc = crc ^ tmp ^ CRC_TABLE[((byte >> 4) & 0xF) as usize]; + } + self.0 = crc; + } + + /// The current checksum. + pub const fn value(self) -> u16 { + self.0 + } +} + +/// One-shot FIT CRC-16 over `data`. +pub fn crc16(data: &[u8]) -> u16 { + let mut crc = Crc16::new(); + crc.update(data); + crc.value() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The canonical CRC-16/ARC check value: `crc("123456789") == 0xBB3D`. + /// If this fails, every FIT file we produce is rejected. + #[test] + fn known_vector_check_string() { + assert_eq!(crc16(b"123456789"), 0xBB3D); + } + + #[test] + fn known_vector_empty_and_zero() { + assert_eq!(crc16(b""), 0x0000); + // CRC-16/ARC of a single zero byte is 0. + assert_eq!(crc16(&[0x00]), 0x0000); + // Published CRC-16/ARC vectors. + assert_eq!(crc16(b"A"), 0x30C0); + assert_eq!(crc16(&[0x00, 0x00, 0x00, 0x00]), 0x0000); + } + + #[test] + fn matches_reference_bitwise_implementation() { + // Independent, deliberately naive reflected-CRC implementation. + fn reference(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &b in data { + crc ^= b as u16; + for _ in 0..8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0xA001; + } else { + crc >>= 1; + } + } + } + crc + } + + // A deterministic pseudo-random corpus. + let mut data = Vec::new(); + let mut x: u32 = 0x1234_5678; + for _ in 0..1000 { + x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + data.push((x >> 16) as u8); + assert_eq!(crc16(&data), reference(&data), "mismatch at len {}", data.len()); + } + } + + #[test] + fn incremental_equals_one_shot() { + let data: Vec = (0u8..=255).cycle().take(777).collect(); + let mut running = Crc16::new(); + for chunk in data.chunks(13) { + running.update(chunk); + } + assert_eq!(running.value(), crc16(&data)); + } +} diff --git a/crates/fit/src/encode.rs b/crates/fit/src/encode.rs new file mode 100644 index 0000000..de58506 --- /dev/null +++ b/crates/fit/src/encode.rs @@ -0,0 +1,578 @@ +//! The FIT binary container: file header, definition messages, data messages +//! and the trailing CRC. +//! +//! This is deliberately a small, literal implementation of the FIT protocol +//! rather than a wrapper around a generated SDK. The container is about two +//! hundred lines and every byte of it matters for whether an upload is +//! accepted, so it is worth being able to read all of it. +//! +//! # File layout +//! +//! ```text +//! +--------------------------------+ +//! | header (14 bytes) | size, protocol, profile, data size, +//! | | ".FIT", header CRC +//! +--------------------------------+ +//! | data records (data_size bytes) | definition + data messages +//! +--------------------------------+ +//! | file CRC (2 bytes) | over header + data records +//! +--------------------------------+ +//! ``` + +use crate::crc::Crc16; + +/// Header length we emit. The 12-byte variant (no header CRC) is legal but the +/// 14-byte form is universally expected. +pub const HEADER_SIZE: u8 = 14; + +/// Protocol version 2.0, encoded as `major << 4 | minor`. +pub const PROTOCOL_VERSION: u8 = 0x20; + +/// Profile version, `major * 100 + minor`, from FIT SDK 21. +pub const PROFILE_VERSION: u16 = 21_205; + +/// The `.FIT` data type signature at bytes 8..12 of the header. +pub const DATA_TYPE: &[u8; 4] = b".FIT"; + +/// FIT base type identifiers. The high bit marks an endian-sensitive type; the +/// low 5 bits are the type number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum BaseType { + Enum = 0x00, + Sint8 = 0x01, + Uint8 = 0x02, + Sint16 = 0x83, + Uint16 = 0x84, + Sint32 = 0x85, + Uint32 = 0x86, + String = 0x07, + Float32 = 0x88, + Uint8z = 0x0A, + Uint16z = 0x8B, + Uint32z = 0x8C, + Byte = 0x0D, +} + +/// One encoded field value, carrying its own base type and width. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Enum(u8), + Uint8(u8), + Uint8z(u8), + Sint8(i8), + Uint16(u16), + Uint16z(u16), + Sint16(i16), + Uint32(u32), + Uint32z(u32), + Sint32(i32), + Float32(f32), + /// Null-terminated UTF-8. The encoded size includes the terminator. + String(String), +} + +impl Value { + /// The FIT base type of this value. + pub fn base_type(&self) -> BaseType { + match self { + Value::Enum(_) => BaseType::Enum, + Value::Uint8(_) => BaseType::Uint8, + Value::Uint8z(_) => BaseType::Uint8z, + Value::Sint8(_) => BaseType::Sint8, + Value::Uint16(_) => BaseType::Uint16, + Value::Uint16z(_) => BaseType::Uint16z, + Value::Sint16(_) => BaseType::Sint16, + Value::Uint32(_) => BaseType::Uint32, + Value::Uint32z(_) => BaseType::Uint32z, + Value::Sint32(_) => BaseType::Sint32, + Value::Float32(_) => BaseType::Float32, + Value::String(_) => BaseType::String, + } + } + + /// Encoded width in bytes, as it appears in the definition message. + pub fn size(&self) -> u8 { + match self { + Value::Enum(_) | Value::Uint8(_) | Value::Uint8z(_) | Value::Sint8(_) => 1, + Value::Uint16(_) | Value::Uint16z(_) | Value::Sint16(_) => 2, + Value::Uint32(_) | Value::Uint32z(_) | Value::Sint32(_) | Value::Float32(_) => 4, + // UTF-8 bytes plus the null terminator; clamped so a pathological + // name cannot overflow the single-byte size field. + Value::String(s) => (s.len().min(u8::MAX as usize - 1) + 1) as u8, + } + } + + /// Append this value to `out` in little-endian order. + fn write(&self, out: &mut Vec) { + match self { + Value::Enum(v) | Value::Uint8(v) | Value::Uint8z(v) => out.push(*v), + Value::Sint8(v) => out.push(*v as u8), + Value::Uint16(v) | Value::Uint16z(v) => out.extend_from_slice(&v.to_le_bytes()), + Value::Sint16(v) => out.extend_from_slice(&v.to_le_bytes()), + Value::Uint32(v) | Value::Uint32z(v) => out.extend_from_slice(&v.to_le_bytes()), + Value::Sint32(v) => out.extend_from_slice(&v.to_le_bytes()), + Value::Float32(v) => out.extend_from_slice(&v.to_le_bytes()), + Value::String(s) => { + let max = usize::from(self.size()) - 1; + let mut bytes = s.as_bytes(); + if bytes.len() > max { + // Never split a UTF-8 sequence. + let mut end = max; + while end > 0 && (bytes[end] & 0xC0) == 0x80 { + end -= 1; + } + bytes = &bytes[..end]; + } + out.extend_from_slice(bytes); + out.resize(out.len() + (max - bytes.len()) + 1, 0); + } + } + } +} + +/// A message under construction: an ordered set of (field number, value) pairs. +/// +/// Setting the same field twice replaces the value rather than emitting a +/// duplicate, which a definition message may not contain. +#[derive(Debug, Default, Clone)] +pub struct Message { + fields: Vec<(u8, Value)>, +} + +impl Message { + /// An empty message. + pub fn new() -> Self { + Self::default() + } + + /// Set a field. + pub fn set(&mut self, field: u8, value: Value) -> &mut Self { + match self.fields.iter_mut().find(|(n, _)| *n == field) { + Some(slot) => slot.1 = value, + None => self.fields.push((field, value)), + } + self + } + + /// Set a field only when the value is present. Absent optional fields are + /// omitted from the definition entirely rather than written as the base + /// type's "invalid" sentinel, which keeps files small and stops decoders + /// from surfacing phantom all-invalid streams. + pub fn set_opt(&mut self, field: u8, value: Option) -> &mut Self { + if let Some(v) = value { + self.set(field, v); + } + self + } + + /// The fields, in the order they will be written. + pub fn fields(&self) -> &[(u8, Value)] { + &self.fields + } + + /// True when no field has been set. + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// The definition-message shape of this message: (field number, size, base + /// type) per field. Two messages sharing a shape can share a definition. + fn shape(&self) -> Vec<(u8, u8, u8)> { + self.fields + .iter() + .map(|(n, v)| (*n, v.size(), v.base_type() as u8)) + .collect() + } +} + +/// Accumulates data records and emits a complete FIT file. +/// +/// Definitions are cached per local message type, so a definition is re-emitted +/// only when a message's shape changes — which is what lets a thousand `record` +/// messages share a single definition. +#[derive(Debug, Default)] +pub struct FitEncoder { + data: Vec, + /// Cached definition shape per local message type (0..16). + defs: [Option<(u16, Vec<(u8, u8, u8)>)>; 16], + message_count: usize, +} + +impl FitEncoder { + /// A new, empty encoder. + pub fn new() -> Self { + Self::default() + } + + /// Write `msg` as global message `global` using local message type `local`. + /// + /// Emits a definition message first if the shape is not already cached for + /// this local type. `local` must be 0..=15; anything larger is masked, and + /// an empty message is skipped (a zero-field definition is legal but + /// pointless and confuses some parsers). + pub fn write_message(&mut self, local: u8, global: u16, msg: &Message) { + if msg.is_empty() { + return; + } + let local = local & 0x0F; + let shape = msg.shape(); + + let cached = self.defs[local as usize] + .as_ref() + .is_some_and(|(g, s)| *g == global && *s == shape); + + if !cached { + self.write_definition(local, global, &shape); + self.defs[local as usize] = Some((global, shape)); + } + + // Data message header: bit 7 = 0 (normal), bit 6 = 0 (data), + // bits 0..4 = local message type. + self.data.push(local); + for (_, value) in msg.fields() { + value.write(&mut self.data); + } + self.message_count += 1; + } + + fn write_definition(&mut self, local: u8, global: u16, shape: &[(u8, u8, u8)]) { + // Definition message header: bit 7 = 0 (normal), bit 6 = 1 (definition). + self.data.push(0x40 | local); + self.data.push(0); // reserved + self.data.push(0); // architecture: 0 = little endian + self.data.extend_from_slice(&global.to_le_bytes()); + // A definition may describe at most 255 fields. + self.data.push(shape.len().min(u8::MAX as usize) as u8); + for &(num, size, base) in shape.iter().take(u8::MAX as usize) { + self.data.push(num); + self.data.push(size); + self.data.push(base); + } + } + + /// Number of data messages written so far. + pub fn message_count(&self) -> usize { + self.message_count + } + + /// Byte length of the data-records section written so far. + pub fn data_len(&self) -> usize { + self.data.len() + } + + /// Finish the file: prepend the 14-byte header (with its own CRC) and + /// append the file CRC over header plus data. + pub fn finish(self) -> Vec { + let mut out = Vec::with_capacity(self.data.len() + 16); + out.extend_from_slice(&file_header(self.data.len() as u32)); + out.extend_from_slice(&self.data); + + let mut crc = Crc16::new(); + crc.update(&out); + out.extend_from_slice(&crc.value().to_le_bytes()); + out + } +} + +/// Build the 14-byte FIT file header for a given data-records length. +/// +/// `data_size` counts *only* the data records — not the header and not the +/// trailing CRC. Getting that wrong is the second classic way to produce a file +/// that every uploader rejects. +pub fn file_header(data_size: u32) -> [u8; 14] { + let mut h = [0u8; 14]; + h[0] = HEADER_SIZE; + h[1] = PROTOCOL_VERSION; + h[2..4].copy_from_slice(&PROFILE_VERSION.to_le_bytes()); + h[4..8].copy_from_slice(&data_size.to_le_bytes()); + h[8..12].copy_from_slice(DATA_TYPE); + + let mut crc = Crc16::new(); + crc.update(&h[0..12]); + h[12..14].copy_from_slice(&crc.value().to_le_bytes()); + h +} + +/// Structural check on an encoded FIT file: header self-consistency, declared +/// data size against actual length, and both CRCs. +/// +/// Exposed because it is exactly the check an uploader performs before deciding +/// whether to look at the contents, and it is cheap enough to run on every file +/// we write. +pub fn verify(bytes: &[u8]) -> Result<(), VerifyError> { + if bytes.len() < 16 { + return Err(VerifyError::TooShort(bytes.len())); + } + let header_size = bytes[0] as usize; + if header_size != 12 && header_size != 14 { + return Err(VerifyError::BadHeaderSize(bytes[0])); + } + if &bytes[8..12] != DATA_TYPE { + return Err(VerifyError::BadSignature([ + bytes[8], bytes[9], bytes[10], bytes[11], + ])); + } + + let data_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let expected_len = header_size + data_size + 2; + if bytes.len() != expected_len { + return Err(VerifyError::DataSizeMismatch { + declared: data_size, + actual: bytes.len().saturating_sub(header_size + 2), + }); + } + + if header_size == 14 { + let stored = u16::from_le_bytes([bytes[12], bytes[13]]); + // A zero header CRC means "not present", which is legal. + if stored != 0 { + let computed = crate::crc::crc16(&bytes[0..12]); + if stored != computed { + return Err(VerifyError::HeaderCrc { stored, computed }); + } + } + } + + let stored = u16::from_le_bytes([bytes[expected_len - 2], bytes[expected_len - 1]]); + let computed = crate::crc::crc16(&bytes[..expected_len - 2]); + if stored != computed { + return Err(VerifyError::FileCrc { stored, computed }); + } + Ok(()) +} + +/// Why [`verify`] rejected a file. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum VerifyError { + #[error("file is {0} bytes, too short to be a FIT file")] + TooShort(usize), + #[error("header size {0} is neither 12 nor 14")] + BadHeaderSize(u8), + #[error("data type signature is {0:?}, expected \".FIT\"")] + BadSignature([u8; 4]), + #[error("header declares {declared} data bytes but the file carries {actual}")] + DataSizeMismatch { declared: usize, actual: usize }, + #[error("header CRC is {stored:#06x}, computed {computed:#06x}")] + HeaderCrc { stored: u16, computed: u16 }, + #[error("file CRC is {stored:#06x}, computed {computed:#06x}")] + FileCrc { stored: u16, computed: u16 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_layout_is_byte_exact() { + let h = file_header(0x1234); + assert_eq!(h[0], 14, "header size"); + assert_eq!(h[1], 0x20, "protocol version 2.0"); + assert_eq!(&h[2..4], &PROFILE_VERSION.to_le_bytes(), "profile version"); + assert_eq!(&h[4..8], &[0x34, 0x12, 0x00, 0x00], "data size, little endian"); + assert_eq!(&h[8..12], b".FIT", "data type signature"); + + let crc = u16::from_le_bytes([h[12], h[13]]); + assert_eq!(crc, crate::crc::crc16(&h[0..12])); + assert_ne!(crc, 0, "a real header CRC, not the 'absent' sentinel"); + } + + #[test] + fn empty_file_is_header_plus_crc() { + let bytes = FitEncoder::new().finish(); + assert_eq!(bytes.len(), 16); + assert_eq!(&bytes[4..8], &[0, 0, 0, 0]); + assert!(verify(&bytes).is_ok()); + } + + #[test] + fn definition_and_data_bytes_are_exact() { + let mut enc = FitEncoder::new(); + let mut msg = Message::new(); + msg.set(0, Value::Enum(4)); + msg.set(1, Value::Uint16(255)); + enc.write_message(0, 0, &msg); + let bytes = enc.finish(); + + let data = &bytes[14..bytes.len() - 2]; + #[rustfmt::skip] + let expected: &[u8] = &[ + // definition message for global 0, local 0, two fields + 0x40, // header: normal, definition, local 0 + 0x00, // reserved + 0x00, // little endian + 0x00, 0x00, // global message number 0 (file_id) + 0x02, // two fields + 0x00, 0x01, 0x00, // field 0, 1 byte, enum + 0x01, 0x02, 0x84, // field 1, 2 bytes, uint16 + // data message + 0x00, // header: normal, data, local 0 + 0x04, // type = activity + 0xFF, 0x00, // manufacturer = 255, little endian + ]; + assert_eq!(data, expected); + assert_eq!( + u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize, + expected.len() + ); + assert!(verify(&bytes).is_ok()); + } + + #[test] + fn definition_is_reused_for_identical_shapes() { + let mut enc = FitEncoder::new(); + let mut msg = Message::new(); + msg.set(253, Value::Uint32(1)); + for i in 0..5u32 { + msg.set(253, Value::Uint32(i)); + enc.write_message(3, mesg_record(), &msg); + } + // One 3+3+1-byte definition (6 header bytes + 3 per field) plus five + // 5-byte data messages. + assert_eq!(enc.data_len(), (6 + 3) + 5 * (1 + 4)); + assert_eq!(enc.message_count(), 5); + } + + #[test] + fn definition_is_re_emitted_when_the_shape_changes() { + let mut enc = FitEncoder::new(); + let mut a = Message::new(); + a.set(253, Value::Uint32(1)); + enc.write_message(3, mesg_record(), &a); + let after_first = enc.data_len(); + + let mut b = Message::new(); + b.set(253, Value::Uint32(2)); + b.set(7, Value::Uint16(250)); + enc.write_message(3, mesg_record(), &b); + // Second write costs a new 12-byte definition plus a 7-byte data message. + assert_eq!(enc.data_len() - after_first, (6 + 6) + (1 + 4 + 2)); + } + + fn mesg_record() -> u16 { + crate::profile::mesg::RECORD + } + + #[test] + fn strings_are_null_terminated_and_sized_with_the_terminator() { + let v = Value::String("BikeControl".into()); + assert_eq!(v.size(), 12); + let mut out = Vec::new(); + v.write(&mut out); + assert_eq!(out, b"BikeControl\0"); + } + + #[test] + fn empty_string_is_a_single_null() { + let v = Value::String(String::new()); + assert_eq!(v.size(), 1); + let mut out = Vec::new(); + v.write(&mut out); + assert_eq!(out, b"\0"); + } + + #[test] + fn overlong_strings_are_truncated_on_a_char_boundary() { + let v = Value::String("é".repeat(200)); + let size = usize::from(v.size()); + let mut out = Vec::new(); + v.write(&mut out); + assert_eq!(out.len(), size); + assert_eq!(*out.last().unwrap(), 0); + // Truncation must not leave a partial UTF-8 sequence. + assert!(std::str::from_utf8(&out[..out.len() - 1]).is_ok()); + } + + #[test] + fn setting_a_field_twice_replaces_rather_than_duplicates() { + let mut msg = Message::new(); + msg.set(7, Value::Uint16(100)); + msg.set(7, Value::Uint16(200)); + assert_eq!(msg.fields().len(), 1); + assert_eq!(msg.fields()[0].1, Value::Uint16(200)); + } + + #[test] + fn set_opt_skips_none() { + let mut msg = Message::new(); + msg.set_opt(3, None); + msg.set_opt(4, Some(Value::Uint8(90))); + assert_eq!(msg.fields().len(), 1); + assert_eq!(msg.fields()[0].0, 4); + } + + #[test] + fn signed_values_use_twos_complement_little_endian() { + let mut out = Vec::new(); + Value::Sint16(-100).write(&mut out); + assert_eq!(out, vec![0x9C, 0xFF]); + out.clear(); + Value::Sint8(-1).write(&mut out); + assert_eq!(out, vec![0xFF]); + out.clear(); + Value::Sint32(-2).write(&mut out); + assert_eq!(out, vec![0xFE, 0xFF, 0xFF, 0xFF]); + } + + #[test] + fn verify_rejects_a_corrupted_file_crc() { + let mut bytes = FitEncoder::new().finish(); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + assert!(matches!(verify(&bytes), Err(VerifyError::FileCrc { .. }))); + } + + #[test] + fn verify_rejects_a_corrupted_header_crc() { + let mut bytes = FitEncoder::new().finish(); + bytes[12] ^= 0xFF; + assert!(matches!(verify(&bytes), Err(VerifyError::HeaderCrc { .. }))); + } + + #[test] + fn verify_rejects_a_wrong_data_size() { + let mut bytes = FitEncoder::new().finish(); + bytes[4] = 99; + assert!(matches!( + verify(&bytes), + Err(VerifyError::DataSizeMismatch { .. }) + )); + } + + #[test] + fn verify_rejects_a_bad_signature() { + let mut bytes = FitEncoder::new().finish(); + bytes[8] = b'X'; + assert!(matches!(verify(&bytes), Err(VerifyError::BadSignature(_)))); + } + + #[test] + fn verify_rejects_truncation() { + let bytes = FitEncoder::new().finish(); + assert!(matches!( + verify(&bytes[..10]), + Err(VerifyError::TooShort(10)) + )); + } + + #[test] + fn every_single_byte_corruption_is_caught() { + // The strongest statement we can make about the checksums without an + // uploader: no one-byte change to the file survives verification. + let mut enc = FitEncoder::new(); + let mut msg = Message::new(); + msg.set(253, Value::Uint32(1_000_000_000)); + msg.set(7, Value::Uint16(250)); + enc.write_message(3, crate::profile::mesg::RECORD, &msg); + let good = enc.finish(); + + for i in 0..good.len() { + let mut bad = good.clone(); + bad[i] ^= 0x01; + assert!( + verify(&bad).is_err(), + "flipping a bit in byte {i} was not detected" + ); + } + } +} diff --git a/crates/fit/src/lib.rs b/crates/fit/src/lib.rs index c62a281..929ce8b 100644 --- a/crates/fit/src/lib.rs +++ b/crates/fit/src/lib.rs @@ -1 +1,158 @@ -//! FIT activity file encoder. See REQUIREMENTS.md §5.8. +//! FIT activity file encoder (REQUIREMENTS.md §5.8, FR-8). +//! +//! Records a ride to a crash-safe journal and turns it into a FIT activity file +//! that Strava and Garmin Connect will accept. +//! +//! # Why this is hand-rolled +//! +//! RISK-3 in the requirements is accurate: the Rust ecosystem reads FIT far +//! better than it writes it. There *is* a capable encoder on crates.io +//! (`rustyfit`), but it was not the right dependency here: +//! +//! * The value it adds is the generated Garmin profile — several megabytes of +//! message definitions — of which an activity file needs seven messages. The +//! binary container underneath is about two hundred lines and is the part +//! that decides whether an upload is accepted. +//! * We have no Strava to test against, so correctness has to come from tests. +//! Encoding with someone's crate and round-tripping through the same crate's +//! decoder proves only self-consistency. Encoding with our own writer and +//! decoding with an *independent* parser — `fitparser`, a dev-dependency — +//! is a genuinely independent check, and it is the check this crate rests on. +//! * When an upload is rejected, the fix is at the byte level. Owning those +//! bytes is worth more here than saving a few hundred lines. +//! +//! The field numbers and enum values in [`profile`] were transcribed from the +//! Garmin FIT SDK profile and cross-checked against `rustyfit`'s generated +//! tables, so the SDK's knowledge is used — just not its code. +//! +//! # Shape of the crate +//! +//! ```text +//! RideSnapshot --> Recorder --> raw journal (JSON Lines, flushed per sample) +//! | +//! v +//! build_fit_from_log --> .fit +//! ``` +//! +//! The FIT file cannot be written incrementally: its header carries a data size +//! and its last two bytes are a CRC over everything before them, so a +//! half-written FIT is a broken FIT. Crash safety therefore lives one level +//! down, in the journal — see [`rawlog`]. A ride that ends in a crash is +//! recovered by pointing [`build_fit_from_log`] at the journal, and the +//! resulting file is byte-identical to the one a clean shutdown would have +//! produced. +//! +//! # Example +//! +//! ```no_run +//! use bikecontrol_fit::{Recorder, RecorderOptions}; +//! # use bikecontrol_core::RideSnapshot; +//! # fn demo(snapshots: &[RideSnapshot]) -> Result<(), bikecontrol_fit::FitError> { +//! let mut rec = Recorder::create("rides/2026-08-05.jsonl", RecorderOptions::default())?; +//! for snap in snapshots { +//! rec.record(snap)?; +//! } +//! let summary = rec.finish("rides/2026-08-05.fit")?; +//! # Ok(()) +//! # } +//! ``` + +#![warn(missing_docs)] + +use std::path::{Path, PathBuf}; + +pub mod builder; +pub mod crc; +pub mod encode; +pub mod profile; +pub mod rawlog; +pub mod recorder; +pub mod timestamp; + +pub use builder::{encode_activity, FitSummary}; +pub use crc::crc16; +pub use encode::{verify, VerifyError}; +pub use rawlog::{parse_log, read_log, LogEntry, RawLog, Sample, SessionStart}; +pub use recorder::{Recorder, RecorderOptions}; +pub use timestamp::FIT_EPOCH_UNIX_SECS; + +/// Anything that can go wrong recording or encoding a ride. +#[derive(Debug, thiserror::Error)] +pub enum FitError { + /// Reading or writing a file failed. + #[error("i/o error on {path}: {source}")] + Io { + /// The file involved. + path: PathBuf, + /// The underlying error. + #[source] + source: std::io::Error, + }, + + /// A journal line could not be serialised or deserialised. + #[error("journal encoding error: {0}")] + Json(#[from] serde_json::Error), + + /// The journal has no `start` header, so elapsed times cannot be anchored + /// to the wall clock. + #[error("raw log has no session start entry")] + MissingSessionStart, + + /// The journal contains no telemetry. An activity with no records is + /// rejected by every uploader, so it is refused here instead. + #[error("raw log contains no samples; nothing to encode")] + NoSamples, + + /// A timestamp lies outside the FIT `date_time` range — before + /// 1989-12-31 UTC, or beyond 2158. + #[error("timestamp {unix_secs} is outside the FIT date_time range (1989-12-31 onwards)")] + TimestampOutOfRange { + /// The offending Unix timestamp, in seconds. + unix_secs: i64, + }, +} + +/// Build a FIT activity from a raw journal and write it to `fit_path`. +/// +/// This is the crash-recovery entry point (FR-8.4): point it at a journal left +/// behind by a ride that ended badly and it produces the activity that ride +/// should have exported. It is also what [`Recorder::finish`] calls, so the two +/// paths cannot drift apart. +/// +/// The encoded file is verified — header, declared data size, both CRCs — +/// before it is written, so a file that reaches disk is structurally sound. +pub fn build_fit_from_log( + log_path: impl AsRef, + fit_path: impl AsRef, +) -> Result { + let log = read_log(log_path.as_ref())?; + let (bytes, summary) = encode_activity(&log)?; + + debug_assert!( + verify(&bytes).is_ok(), + "encoder produced a structurally invalid FIT file: {:?}", + verify(&bytes) + ); + + let fit_path = fit_path.as_ref(); + if let Some(parent) = fit_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|source| FitError::Io { + path: parent.to_path_buf(), + source, + })?; + } + } + std::fs::write(fit_path, &bytes).map_err(|source| FitError::Io { + path: fit_path.to_path_buf(), + source, + })?; + Ok(summary) +} + +/// Build a FIT activity from a raw journal and return the bytes without +/// touching the filesystem. +pub fn fit_bytes_from_log(log_path: impl AsRef) -> Result<(Vec, FitSummary), FitError> { + let log = read_log(log_path.as_ref())?; + encode_activity(&log) +} diff --git a/crates/fit/src/profile.rs b/crates/fit/src/profile.rs new file mode 100644 index 0000000..797a941 --- /dev/null +++ b/crates/fit/src/profile.rs @@ -0,0 +1,184 @@ +//! FIT global message numbers, field numbers and enum values. +//! +//! These are facts from the Garmin FIT SDK profile (`Profile.xlsx`, SDK 21.x), +//! transcribed for the handful of messages an activity file needs. They were +//! cross-checked against the generated profile in the `rustyfit` crate. +//! +//! **Watch the `lap` / `session` divergence.** The two messages do *not* share +//! field numbers: `session` inserts `total_fat_calories` at 13, which shifts +//! every summary field after it by one relative to `lap`. Writing lap field +//! numbers into a session message produces a file that parses but reports +//! nonsense (average power showing up as maximum heart rate, and so on). + +/// Global message numbers. +pub mod mesg { + pub const FILE_ID: u16 = 0; + pub const SESSION: u16 = 18; + pub const LAP: u16 = 19; + pub const RECORD: u16 = 20; + pub const EVENT: u16 = 21; + pub const DEVICE_INFO: u16 = 23; + pub const ACTIVITY: u16 = 34; +} + +/// `file_id` (global 0) field numbers. +pub mod file_id { + pub const TYPE: u8 = 0; + pub const MANUFACTURER: u8 = 1; + pub const PRODUCT: u8 = 2; + pub const SERIAL_NUMBER: u8 = 3; + pub const TIME_CREATED: u8 = 4; + pub const PRODUCT_NAME: u8 = 8; +} + +/// `device_info` (global 23) field numbers. +pub mod device_info { + pub const DEVICE_INDEX: u8 = 0; + pub const MANUFACTURER: u8 = 2; + pub const PRODUCT: u8 = 4; + pub const SOFTWARE_VERSION: u8 = 5; + pub const SOURCE_TYPE: u8 = 25; + pub const PRODUCT_NAME: u8 = 27; + pub const TIMESTAMP: u8 = 253; +} + +/// `event` (global 21) field numbers. +pub mod event { + pub const EVENT: u8 = 0; + pub const EVENT_TYPE: u8 = 1; + pub const DATA: u8 = 3; + pub const EVENT_GROUP: u8 = 4; + pub const TIMESTAMP: u8 = 253; +} + +/// `record` (global 20) field numbers. +pub mod record { + pub const ALTITUDE: u8 = 2; + pub const HEART_RATE: u8 = 3; + pub const CADENCE: u8 = 4; + pub const DISTANCE: u8 = 5; + pub const SPEED: u8 = 6; + pub const POWER: u8 = 7; + pub const GRADE: u8 = 9; + pub const RESISTANCE: u8 = 10; + pub const CALORIES: u8 = 33; + pub const TIMESTAMP: u8 = 253; +} + +/// `lap` (global 19) field numbers. +pub mod lap { + pub const EVENT: u8 = 0; + pub const EVENT_TYPE: u8 = 1; + pub const START_TIME: u8 = 2; + pub const TOTAL_ELAPSED_TIME: u8 = 7; + pub const TOTAL_TIMER_TIME: u8 = 8; + pub const TOTAL_DISTANCE: u8 = 9; + pub const TOTAL_CALORIES: u8 = 11; + pub const AVG_SPEED: u8 = 13; + pub const MAX_SPEED: u8 = 14; + pub const AVG_HEART_RATE: u8 = 15; + pub const MAX_HEART_RATE: u8 = 16; + pub const AVG_CADENCE: u8 = 17; + pub const MAX_CADENCE: u8 = 18; + pub const AVG_POWER: u8 = 19; + pub const MAX_POWER: u8 = 20; + pub const TOTAL_ASCENT: u8 = 21; + pub const TOTAL_DESCENT: u8 = 22; + pub const INTENSITY: u8 = 23; + pub const LAP_TRIGGER: u8 = 24; + pub const SPORT: u8 = 25; + pub const SUB_SPORT: u8 = 39; + pub const TOTAL_WORK: u8 = 41; + pub const AVG_GRADE: u8 = 45; + pub const TIMESTAMP: u8 = 253; + pub const MESSAGE_INDEX: u8 = 254; +} + +/// `session` (global 18) field numbers. Note the offset relative to [`lap`]. +pub mod session { + pub const EVENT: u8 = 0; + pub const EVENT_TYPE: u8 = 1; + pub const START_TIME: u8 = 2; + pub const SPORT: u8 = 5; + pub const SUB_SPORT: u8 = 6; + pub const TOTAL_ELAPSED_TIME: u8 = 7; + pub const TOTAL_TIMER_TIME: u8 = 8; + pub const TOTAL_DISTANCE: u8 = 9; + pub const TOTAL_CALORIES: u8 = 11; + pub const AVG_SPEED: u8 = 14; + pub const MAX_SPEED: u8 = 15; + pub const AVG_HEART_RATE: u8 = 16; + pub const MAX_HEART_RATE: u8 = 17; + pub const AVG_CADENCE: u8 = 18; + pub const MAX_CADENCE: u8 = 19; + pub const AVG_POWER: u8 = 20; + pub const MAX_POWER: u8 = 21; + pub const TOTAL_ASCENT: u8 = 22; + pub const TOTAL_DESCENT: u8 = 23; + pub const FIRST_LAP_INDEX: u8 = 25; + pub const NUM_LAPS: u8 = 26; + pub const TRIGGER: u8 = 28; + pub const TOTAL_WORK: u8 = 48; + pub const TIMESTAMP: u8 = 253; + pub const MESSAGE_INDEX: u8 = 254; +} + +/// `activity` (global 34) field numbers. +pub mod activity { + pub const TOTAL_TIMER_TIME: u8 = 0; + pub const NUM_SESSIONS: u8 = 1; + pub const TYPE: u8 = 2; + pub const EVENT: u8 = 3; + pub const EVENT_TYPE: u8 = 4; + pub const LOCAL_TIMESTAMP: u8 = 5; + pub const TIMESTAMP: u8 = 253; +} + +/// Enum values used by the messages above. +pub mod enums { + /// `file` — `file_id.type`. + pub const FILE_ACTIVITY: u8 = 4; + + /// `manufacturer` — 255 is the SDK's "development" manufacturer, the + /// correct value for an application that is not a registered Garmin + /// partner. Both Strava and Garmin Connect accept it. + pub const MANUFACTURER_DEVELOPMENT: u16 = 255; + + /// `sport`. + pub const SPORT_CYCLING: u8 = 2; + + /// `sub_sport`. `VIRTUAL_ACTIVITY` is what makes Strava file the upload as + /// a *Virtual Ride* rather than an outdoor ride with no GPS. + pub const SUB_SPORT_INDOOR_CYCLING: u8 = 6; + pub const SUB_SPORT_VIRTUAL_ACTIVITY: u8 = 58; + + /// `event`. + pub const EVENT_TIMER: u8 = 0; + pub const EVENT_LAP: u8 = 9; + pub const EVENT_SESSION: u8 = 8; + pub const EVENT_ACTIVITY: u8 = 26; + + /// `event_type`. + pub const EVENT_TYPE_START: u8 = 0; + pub const EVENT_TYPE_STOP: u8 = 1; + pub const EVENT_TYPE_STOP_ALL: u8 = 4; + + /// `lap_trigger`. + pub const LAP_TRIGGER_MANUAL: u8 = 0; + pub const LAP_TRIGGER_SESSION_END: u8 = 7; + + /// `session_trigger`. + pub const SESSION_TRIGGER_ACTIVITY_END: u8 = 0; + + /// `activity` — `activity.type`. + pub const ACTIVITY_MANUAL: u8 = 0; + + /// `intensity`. + pub const INTENSITY_ACTIVE: u8 = 0; + + /// `source_type`. + pub const SOURCE_TYPE_LOCAL: u8 = 5; + + /// `device_index` — 0 is reserved for the device that created the file. + pub const DEVICE_INDEX_CREATOR: u8 = 0; +} diff --git a/crates/fit/src/rawlog.rs b/crates/fit/src/rawlog.rs new file mode 100644 index 0000000..4867eff --- /dev/null +++ b/crates/fit/src/rawlog.rs @@ -0,0 +1,554 @@ +//! The raw ride log: an append-only, line-delimited JSON journal (FR-8.4, +//! FR-8.6). +//! +//! The FIT file cannot be written incrementally in any useful sense — the +//! header carries a data size and the file ends with a CRC over everything, so +//! a partially written FIT is simply a broken FIT. The session is therefore +//! made crash-safe a level below: every sample is appended to this journal as a +//! complete line and flushed, and the FIT is assembled from the journal at the +//! end of the ride. If the app dies mid-ride the journal survives and +//! [`crate::build_fit_from_log`] regenerates the activity. +//! +//! JSON Lines was chosen over a packed binary format for one reason: torn +//! writes are recoverable. A crash during the final `write` leaves a truncated +//! last line, which the reader drops; every earlier line is intact and +//! self-describing. A binary format with a length prefix would need its own +//! framing and resync logic to reach the same place. + +use std::path::PathBuf; + +use bikecontrol_core::{ControlMode, RideSnapshot}; +use serde::{Deserialize, Serialize}; + +/// Version of the on-disk log format, written into the session header so a +/// future reader can tell what it is looking at. +pub const LOG_FORMAT_VERSION: u16 = 1; + +/// One line of the journal. +/// +/// The tag is short because these are written at 1 Hz for hours; the field +/// names cost real bytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "t")] +pub enum LogEntry { + /// Always the first line. Anchors elapsed time to the wall clock. + #[serde(rename = "start")] + Start(SessionStart), + /// A 1 Hz telemetry sample. + #[serde(rename = "s")] + Sample(Sample), + /// A stretch with no telemetry — a BLE dropout (FR-8.5). Recorded so the + /// hole in the record stream is explained rather than mysterious. The + /// timer keeps running across a gap: the rider was still pedalling, we just + /// stopped hearing about it. + #[serde(rename = "gap")] + Gap { + /// Elapsed time at which telemetry stopped, ms. + at_ms: u64, + /// Elapsed time at which it resumed, ms. `None` if the ride ended + /// during the dropout. + #[serde(default, skip_serializing_if = "Option::is_none")] + until_ms: Option, + /// Human-readable cause, e.g. the disconnect reason. + #[serde(default, skip_serializing_if = "String::is_empty")] + reason: String, + }, + /// A lap marker (FR-8.7). Ends the lap in progress and starts a new one. + #[serde(rename = "lap")] + Lap { + at_ms: u64, + /// True if triggered by the controller rather than the UI. + #[serde(default, skip_serializing_if = "is_false")] + from_controller: bool, + }, + /// The rider paused. Time between a pause and the next resume is excluded + /// from timer time but still counted in elapsed time. + #[serde(rename = "pause")] + Pause { at_ms: u64 }, + /// The rider resumed. + #[serde(rename = "resume")] + Resume { at_ms: u64 }, + /// Clean end of ride. Its absence is how a recovered log is recognised as + /// the product of a crash. + #[serde(rename = "end")] + End { at_ms: u64 }, +} + +/// Session metadata, written as the first line of the journal. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionStart { + /// Wall-clock start of the ride, Unix milliseconds UTC. Every sample's + /// `elapsed_ms` is an offset from this. + pub start_unix_ms: i64, + /// The rider's UTC offset in seconds at the start of the ride, used for + /// `activity.local_timestamp`. + #[serde(default)] + pub utc_offset_secs: i32, + /// FIT `sub_sport`. Defaults to `virtual_activity`, which is what makes + /// Strava file the upload as a Virtual Ride. + #[serde(default = "default_sub_sport")] + pub sub_sport: u8, + /// Name written into `file_id.product_name`. + #[serde(default = "default_product_name")] + pub product_name: String, + /// Application version, scaled by 100 (1.20 is written as 120). + #[serde(default = "default_software_version")] + pub software_version: u16, + /// Device serial. Zero means "unset" (the FIT base type is `uint32z`). + #[serde(default)] + pub serial_number: u32, + /// Format version of this log. + #[serde(default)] + pub log_format: u16, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires this signature +fn is_false(b: &bool) -> bool { + !*b +} + +fn default_sub_sport() -> u8 { + crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY +} + +fn default_product_name() -> String { + "BikeControl".to_string() +} + +fn default_software_version() -> u16 { + 100 +} + +impl Default for SessionStart { + fn default() -> Self { + Self { + start_unix_ms: 0, + utc_offset_secs: 0, + sub_sport: default_sub_sport(), + product_name: default_product_name(), + software_version: default_software_version(), + serial_number: 0, + log_format: LOG_FORMAT_VERSION, + } + } +} + +/// One recorded sample (FR-8.1). +/// +/// This is deliberately not [`RideSnapshot`] itself: the snapshot is a UI +/// contract that will keep changing, whereas a journal on disk has to stay +/// readable by a later version of the app. Fields are optional and skipped when +/// absent so that a log of a ride without a heart-rate strap does not carry +/// thousands of nulls. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Sample { + /// Milliseconds since the start of the ride. + #[serde(rename = "e")] + pub elapsed_ms: u64, + #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")] + pub power_w: Option, + #[serde(rename = "c", default, skip_serializing_if = "Option::is_none")] + pub cadence_rpm: Option, + /// Virtual speed from the physics engine, km/h. + #[serde(rename = "v", default)] + pub speed_kph: f32, + /// Virtual distance, metres. + #[serde(rename = "d", default)] + pub distance_m: f64, + /// Commanded gradient, percent. + #[serde(rename = "g", default)] + pub gradient_pct: f32, + /// Cumulative elevation gain, metres. + #[serde(rename = "eg", default)] + pub elevation_gain_m: f32, + /// Absolute altitude if a route supplies one; otherwise the encoder + /// integrates gradient over distance to synthesise a profile. + #[serde(rename = "a", default, skip_serializing_if = "Option::is_none")] + pub altitude_m: Option, + #[serde(rename = "h", default, skip_serializing_if = "Option::is_none")] + pub heart_rate_bpm: Option, + /// Trainer-reported cumulative energy, kcal. + #[serde(rename = "k", default, skip_serializing_if = "Option::is_none")] + pub energy_kcal: Option, + /// Trainer resistance level, if the trainer reports one. + #[serde(rename = "r", default, skip_serializing_if = "Option::is_none")] + pub resistance: Option, + /// Virtual gear (FR-8.1). No FIT record field carries this, so it lives in + /// the journal only. + #[serde(rename = "gear", default, skip_serializing_if = "Option::is_none")] + pub gear: Option, + /// Control mode in force at this sample (FR-8.1). Journal only. + #[serde(rename = "m", default, skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +impl Sample { + /// Build a sample from a ride snapshot. + pub fn from_snapshot(snap: &RideSnapshot) -> Self { + Self { + elapsed_ms: snap.elapsed_ms, + power_w: snap.telemetry.power_w, + cadence_rpm: snap.telemetry.cadence_rpm, + speed_kph: snap.virtual_speed_kph, + distance_m: snap.virtual_distance_m, + gradient_pct: snap.gradient_pct, + elevation_gain_m: snap.elevation_gain_m, + altitude_m: None, + heart_rate_bpm: snap.telemetry.heart_rate_bpm, + energy_kcal: snap.telemetry.total_energy_kcal, + resistance: snap.telemetry.resistance_level, + gear: None, + mode: Some(snap.mode), + } + } + + /// Attach a virtual gear number. + pub fn with_gear(mut self, gear: u8) -> Self { + self.gear = Some(gear); + self + } + + /// Attach an absolute altitude, overriding the integrated profile. + pub fn with_altitude(mut self, altitude_m: f32) -> Self { + self.altitude_m = Some(altitude_m); + self + } +} + +/// A parsed journal. +#[derive(Debug, Clone, PartialEq)] +pub struct RawLog { + /// Session metadata from the `start` line. + pub start: SessionStart, + /// Every entry after the header, in file order. + pub entries: Vec, + /// Lines that failed to parse and were skipped. A count of 1 on the final + /// line is the normal signature of a crash mid-write; anything more + /// suggests real corruption. + pub skipped_lines: usize, + /// Whether the log ended with an `end` entry. False means the ride was + /// recovered from a crash. + pub clean_shutdown: bool, + /// Where the log came from, when it came from a file. + pub path: Option, +} + +impl RawLog { + /// Every sample, in order. + pub fn samples(&self) -> impl Iterator { + self.entries.iter().filter_map(|e| match e { + LogEntry::Sample(s) => Some(s), + _ => None, + }) + } + + /// Elapsed times at which laps were marked. + pub fn lap_marks(&self) -> impl Iterator + '_ { + self.entries.iter().filter_map(|e| match e { + LogEntry::Lap { at_ms, .. } => Some(*at_ms), + _ => None, + }) + } + + /// Recorded BLE dropouts as `(start_ms, end_ms)`. An unterminated gap is + /// closed at `fallback_end_ms`. + pub fn gaps(&self, fallback_end_ms: u64) -> Vec<(u64, u64)> { + self.entries + .iter() + .filter_map(|e| match e { + LogEntry::Gap { at_ms, until_ms, .. } => { + Some((*at_ms, until_ms.unwrap_or(fallback_end_ms).max(*at_ms))) + } + _ => None, + }) + .collect() + } + + /// Total time excluded from timer time by explicit pauses, in ms. + /// + /// A pause with no matching resume is closed at `fallback_end_ms`. + /// Nested or repeated pauses are tolerated: only the outermost counts. + pub fn paused_ms(&self, fallback_end_ms: u64) -> u64 { + let mut total = 0u64; + let mut paused_at: Option = None; + for entry in &self.entries { + match entry { + LogEntry::Pause { at_ms } => { + if paused_at.is_none() { + paused_at = Some(*at_ms); + } + } + LogEntry::Resume { at_ms } => { + if let Some(start) = paused_at.take() { + total += at_ms.saturating_sub(start); + } + } + _ => {} + } + } + if let Some(start) = paused_at { + total += fallback_end_ms.saturating_sub(start); + } + total + } +} + +/// Parse a journal from anything line-oriented. +/// +/// Malformed lines are skipped rather than fatal — the entire point of this +/// format is that a half-written tail costs one sample, not the ride. +pub fn parse_log(text: &str) -> Result { + let mut start: Option = None; + let mut entries = Vec::new(); + let mut skipped = 0usize; + let mut clean = false; + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(LogEntry::Start(s)) => { + if start.is_none() { + start = Some(s); + } else { + // A second header means two rides in one file; ignore it + // rather than silently merging them. + skipped += 1; + } + } + Ok(entry) => { + if matches!(entry, LogEntry::End { .. }) { + clean = true; + } + entries.push(entry); + } + Err(_) => skipped += 1, + } + } + + let start = start.ok_or(crate::FitError::MissingSessionStart)?; + Ok(RawLog { + start, + entries, + skipped_lines: skipped, + clean_shutdown: clean, + path: None, + }) +} + +/// Read and parse a journal from disk. +pub fn read_log(path: impl Into) -> Result { + let path = path.into(); + let text = std::fs::read_to_string(&path).map_err(|source| crate::FitError::Io { + path: path.clone(), + source, + })?; + let mut log = parse_log(&text)?; + log.path = Some(path); + Ok(log) +} + +/// Serialise one entry as a journal line, terminator included. +pub fn entry_to_line(entry: &LogEntry) -> Result { + let mut s = serde_json::to_string(entry)?; + s.push('\n'); + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header_line() -> String { + entry_to_line(&LogEntry::Start(SessionStart { + start_unix_ms: 1_785_000_000_000, + ..Default::default() + })) + .unwrap() + } + + #[test] + fn entries_round_trip_through_json() { + let entries = vec![ + LogEntry::Start(SessionStart::default()), + LogEntry::Sample(Sample { + elapsed_ms: 1000, + power_w: Some(250), + cadence_rpm: Some(88.5), + speed_kph: 32.4, + distance_m: 9.0, + gradient_pct: 2.5, + elevation_gain_m: 0.2, + heart_rate_bpm: Some(145), + ..Default::default() + }), + LogEntry::Gap { + at_ms: 5000, + until_ms: Some(9000), + reason: "peripheral disconnected".into(), + }, + LogEntry::Lap { + at_ms: 60_000, + from_controller: true, + }, + LogEntry::Pause { at_ms: 70_000 }, + LogEntry::Resume { at_ms: 80_000 }, + LogEntry::End { at_ms: 90_000 }, + ]; + for e in entries { + let line = entry_to_line(&e).unwrap(); + assert!(line.ends_with('\n')); + assert!(!line[..line.len() - 1].contains('\n'), "one entry, one line"); + let back: LogEntry = serde_json::from_str(&line).unwrap(); + assert_eq!(back, e); + } + } + + #[test] + fn a_truncated_final_line_costs_one_sample_not_the_ride() { + let mut text = header_line(); + for i in 1..=5u64 { + text.push_str( + &entry_to_line(&LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + ..Default::default() + })) + .unwrap(), + ); + } + // Simulate a crash part-way through writing the sixth line. + text.push_str("{\"t\":\"s\",\"e\":6000,\"p\":2"); + + let log = parse_log(&text).unwrap(); + assert_eq!(log.samples().count(), 5); + assert_eq!(log.skipped_lines, 1); + assert!(!log.clean_shutdown, "no end entry means crash recovery"); + } + + #[test] + fn a_log_with_no_header_is_an_error() { + let text = entry_to_line(&LogEntry::Sample(Sample::default())).unwrap(); + assert!(matches!( + parse_log(&text), + Err(crate::FitError::MissingSessionStart) + )); + } + + #[test] + fn clean_shutdown_is_detected() { + let text = header_line() + &entry_to_line(&LogEntry::End { at_ms: 10 }).unwrap(); + assert!(parse_log(&text).unwrap().clean_shutdown); + } + + #[test] + fn paused_time_sums_intervals() { + let text = header_line() + + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap() + + &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap() + + &entry_to_line(&LogEntry::Pause { at_ms: 20_000 }).unwrap() + + &entry_to_line(&LogEntry::Resume { at_ms: 23_000 }).unwrap(); + assert_eq!(parse_log(&text).unwrap().paused_ms(30_000), 8_000); + } + + #[test] + fn an_unclosed_pause_runs_to_the_end_of_the_ride() { + let text = header_line() + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap(); + assert_eq!(parse_log(&text).unwrap().paused_ms(25_000), 15_000); + } + + #[test] + fn repeated_pauses_do_not_double_count() { + let text = header_line() + + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap() + + &entry_to_line(&LogEntry::Pause { at_ms: 12_000 }).unwrap() + + &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap(); + assert_eq!(parse_log(&text).unwrap().paused_ms(20_000), 5_000); + } + + #[test] + fn gaps_are_reported_with_unterminated_ones_closed() { + let text = header_line() + + &entry_to_line(&LogEntry::Gap { + at_ms: 1000, + until_ms: Some(4000), + reason: String::new(), + }) + .unwrap() + + &entry_to_line(&LogEntry::Gap { + at_ms: 9000, + until_ms: None, + reason: "lost".into(), + }) + .unwrap(); + assert_eq!(parse_log(&text).unwrap().gaps(12_000), vec![ + (1000, 4000), + (9000, 12_000) + ]); + } + + #[test] + fn blank_lines_and_whitespace_are_tolerated() { + let text = format!("\n{}\n\n \n", header_line().trim()); + let log = parse_log(&text).unwrap(); + assert_eq!(log.skipped_lines, 0); + assert_eq!(log.entries.len(), 0); + } + + #[test] + fn a_second_header_is_skipped_not_merged() { + let text = header_line() + &header_line(); + let log = parse_log(&text).unwrap(); + assert_eq!(log.skipped_lines, 1); + } + + #[test] + fn from_snapshot_carries_the_fields_the_contract_provides() { + use bikecontrol_core::{ControlMode, Telemetry}; + let snap = RideSnapshot { + elapsed_ms: 12_000, + telemetry: Telemetry { + elapsed_ms: 12_000, + power_w: Some(233), + cadence_rpm: Some(91.0), + heart_rate_bpm: Some(150), + total_energy_kcal: Some(42), + resistance_level: Some(7), + ..Default::default() + }, + virtual_speed_kph: 31.5, + virtual_distance_m: 105.0, + gradient_pct: 3.5, + elevation_gain_m: 3.6, + mode: ControlMode::Profile, + target: None, + profile_progress: Some(0.1), + }; + let s = Sample::from_snapshot(&snap); + assert_eq!(s.elapsed_ms, 12_000); + assert_eq!(s.power_w, Some(233)); + assert_eq!(s.cadence_rpm, Some(91.0)); + assert_eq!(s.heart_rate_bpm, Some(150)); + assert_eq!(s.speed_kph, 31.5); + assert_eq!(s.distance_m, 105.0); + assert_eq!(s.gradient_pct, 3.5); + assert_eq!(s.energy_kcal, Some(42)); + assert_eq!(s.resistance, Some(7)); + assert_eq!(s.mode, Some(ControlMode::Profile)); + // Speed comes from the physics engine, never from the trainer. + assert_eq!(s.speed_kph, snap.virtual_speed_kph); + } + + #[test] + fn absent_optionals_do_not_appear_on_the_wire() { + let line = entry_to_line(&LogEntry::Sample(Sample { + elapsed_ms: 1000, + ..Default::default() + })) + .unwrap(); + assert!(!line.contains("null"), "no null padding: {line}"); + assert!(!line.contains("\"h\"")); + } +} diff --git a/crates/fit/src/recorder.rs b/crates/fit/src/recorder.rs new file mode 100644 index 0000000..66fbbe2 --- /dev/null +++ b/crates/fit/src/recorder.rs @@ -0,0 +1,634 @@ +//! [`Recorder`] — the ride-time half of the crate. +//! +//! The recorder owns the raw journal. It is fed snapshots at whatever rate the +//! ride engine ticks, throttles them to 1 Hz (FR-8.1), and appends each one as +//! a complete line that is flushed immediately (FR-8.6). Nothing about the FIT +//! file is decided until [`Recorder::finish`]. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use bikecontrol_core::RideSnapshot; +use chrono::{DateTime, Local, Utc}; + +use crate::builder::{encode_activity, FitSummary}; +use crate::rawlog::{entry_to_line, read_log, LogEntry, Sample, SessionStart, LOG_FORMAT_VERSION}; +use crate::FitError; + +/// Tuning for [`Recorder`]. +#[derive(Debug, Clone, PartialEq)] +pub struct RecorderOptions { + /// Minimum spacing between recorded samples, ms. Snapshots arriving sooner + /// are dropped. The FIT `record` timestamp has one-second resolution, so + /// there is nothing to gain from a faster journal. + pub sample_interval_ms: u64, + /// Force the journal to stable storage every N samples. `None` relies on + /// the OS page cache, which is fast but loses the tail on a hard power cut. + /// The default trades roughly ten seconds of exposure for one `fsync` per + /// ten samples. + pub fsync_every: Option, + /// A silence longer than this is recorded as a BLE dropout (FR-8.5). + /// `None` disables automatic gap detection; gaps can still be marked + /// explicitly with [`Recorder::mark_gap`]. + pub auto_gap_after_ms: Option, + /// FIT `sub_sport`. `virtual_activity` makes Strava file the ride as a + /// Virtual Ride; `indoor_cycling` is the alternative for a plain + /// trainer session with no simulated course. + pub sub_sport: u8, + /// Written to `file_id.product_name` and `device_info.product_name`. + pub product_name: String, + /// Application version scaled by 100 — 1.20 is `120`. + pub software_version: u16, + /// Device serial number. Zero means unset. + pub serial_number: u32, +} + +impl Default for RecorderOptions { + fn default() -> Self { + Self { + sample_interval_ms: 1000, + fsync_every: Some(10), + auto_gap_after_ms: Some(5_000), + sub_sport: crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY, + product_name: "BikeControl".to_string(), + software_version: 100, + serial_number: 0, + } + } +} + +/// Records a ride to a crash-safe journal and finalises it to a FIT activity. +/// +/// ```no_run +/// # use bikecontrol_fit::{Recorder, RecorderOptions}; +/// # use bikecontrol_core::RideSnapshot; +/// # fn demo(snapshots: Vec) -> Result<(), Box> { +/// let mut rec = Recorder::create("/tmp/ride.jsonl", RecorderOptions::default())?; +/// for snap in &snapshots { +/// rec.record(snap)?; // throttled to 1 Hz internally +/// } +/// rec.mark_lap(60_000, true)?; // controller pressed lap +/// let summary = rec.finish("/tmp/ride.fit")?; +/// println!("{} records, {:.1} km", summary.records, summary.total_distance_m / 1000.0); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct Recorder { + file: File, + log_path: PathBuf, + opts: RecorderOptions, + start: SessionStart, + samples_written: usize, + since_sync: usize, + last_sample_ms: Option, + last_elapsed_ms: u64, + /// Elapsed time at which an open (unterminated) gap began. + open_gap_at: Option, + finished: bool, +} + +impl Recorder { + /// Start recording, creating the journal at `log_path`. + /// + /// The ride's wall-clock start is taken as "now", and the local UTC offset + /// is captured with it so the activity shows the right time of day. + pub fn create(log_path: impl AsRef, opts: RecorderOptions) -> Result { + Self::create_at(log_path, opts, Utc::now(), local_utc_offset_secs()) + } + + /// Start recording with an explicit wall-clock start and UTC offset. + /// Used by tests, and by anything that needs a reproducible file. + pub fn create_at( + log_path: impl AsRef, + opts: RecorderOptions, + started_at: DateTime, + utc_offset_secs: i32, + ) -> Result { + let log_path = log_path.as_ref().to_path_buf(); + if let Some(parent) = log_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|source| FitError::Io { + path: parent.to_path_buf(), + source, + })?; + } + } + // Truncate rather than append: a journal holds exactly one ride, and + // silently concatenating two would produce a nonsense activity. + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&log_path) + .map_err(|source| FitError::Io { + path: log_path.clone(), + source, + })?; + + let start = SessionStart { + start_unix_ms: started_at.timestamp_millis(), + utc_offset_secs, + sub_sport: opts.sub_sport, + product_name: opts.product_name.clone(), + software_version: opts.software_version, + serial_number: opts.serial_number, + log_format: LOG_FORMAT_VERSION, + }; + + let mut rec = Self { + file, + log_path, + opts, + start: start.clone(), + samples_written: 0, + since_sync: 0, + last_sample_ms: None, + last_elapsed_ms: 0, + open_gap_at: None, + finished: false, + }; + rec.append(&LogEntry::Start(start))?; + rec.sync()?; + Ok(rec) + } + + /// Record a snapshot, subject to the 1 Hz throttle. + /// + /// Returns `true` if the sample was written, `false` if it was throttled + /// away. Safe to call on every engine tick. + pub fn record(&mut self, snapshot: &RideSnapshot) -> Result { + self.record_sample(Sample::from_snapshot(snapshot)) + } + + /// Record a fully-formed sample, subject to the same throttle. Use this + /// when there is more to record than the snapshot carries — a virtual gear, + /// or an absolute altitude from a loaded route. + pub fn record_sample(&mut self, sample: Sample) -> Result { + let t = sample.elapsed_ms; + self.last_elapsed_ms = self.last_elapsed_ms.max(t); + + if let Some(prev) = self.last_sample_ms { + if t < prev.saturating_add(self.opts.sample_interval_ms) { + return Ok(false); + } + // Telemetry has been silent long enough to call it a dropout. + if let Some(threshold) = self.opts.auto_gap_after_ms { + if t.saturating_sub(prev) >= threshold && self.open_gap_at.is_none() { + self.append(&LogEntry::Gap { + at_ms: prev, + until_ms: Some(t), + reason: "no telemetry".to_string(), + })?; + } + } + } + + // An explicitly opened gap closes as soon as telemetry returns. + if let Some(at_ms) = self.open_gap_at.take() { + self.append(&LogEntry::Gap { + at_ms, + until_ms: Some(t), + reason: "telemetry resumed".to_string(), + })?; + } + + self.last_sample_ms = Some(t); + self.append(&LogEntry::Sample(sample))?; + self.samples_written += 1; + + self.since_sync += 1; + if self + .opts + .fsync_every + .is_some_and(|n| n > 0 && self.since_sync >= n) + { + self.sync()?; + } + Ok(true) + } + + /// Mark the start of a BLE dropout (FR-8.5). Recording continues; the gap + /// is closed automatically by the next sample, or at the end of the ride. + /// + /// Calling this is optional — `auto_gap_after_ms` catches dropouts on its + /// own — but a caller that *knows* the peripheral disconnected can record a + /// reason and the exact moment. + pub fn mark_gap(&mut self, at_ms: u64, reason: impl Into) -> Result<(), FitError> { + if self.open_gap_at.is_some() { + return Ok(()); // already inside a dropout + } + self.open_gap_at = Some(at_ms); + self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms); + // Written now, unterminated, so it survives a crash during the dropout. + self.append(&LogEntry::Gap { + at_ms, + until_ms: None, + reason: reason.into(), + })?; + self.sync() + } + + /// Mark a lap boundary (FR-8.7). `from_controller` distinguishes a Click + /// button press from an on-screen tap. + pub fn mark_lap(&mut self, at_ms: u64, from_controller: bool) -> Result<(), FitError> { + self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms); + self.append(&LogEntry::Lap { + at_ms, + from_controller, + })?; + self.sync() + } + + /// Pause the ride timer. Time until [`Recorder::resume`] counts towards + /// elapsed time but not timer time. + pub fn pause(&mut self, at_ms: u64) -> Result<(), FitError> { + self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms); + self.append(&LogEntry::Pause { at_ms })?; + self.sync() + } + + /// Resume the ride timer. + pub fn resume(&mut self, at_ms: u64) -> Result<(), FitError> { + self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms); + self.append(&LogEntry::Resume { at_ms })?; + self.sync() + } + + /// Close the journal and write the FIT activity to `fit_path`. + /// + /// The FIT is built from the journal on disk, by the same code path crash + /// recovery uses — so the file a rider gets after a clean ride and the file + /// they get after a crash are produced identically. + pub fn finish(mut self, fit_path: impl AsRef) -> Result { + let end_ms = self.last_elapsed_ms; + // Close any dropout that was still open when the ride ended. + if let Some(at_ms) = self.open_gap_at.take() { + self.append(&LogEntry::Gap { + at_ms, + until_ms: Some(end_ms), + reason: "ride ended during dropout".to_string(), + })?; + } + self.append(&LogEntry::End { at_ms: end_ms })?; + self.sync()?; + self.finished = true; + + let log_path = self.log_path.clone(); + drop(self); + crate::build_fit_from_log(&log_path, fit_path) + } + + /// Close the journal without producing a FIT file. The journal remains on + /// disk and can be turned into an activity later. + pub fn abandon(mut self) -> PathBuf { + self.finished = true; + self.log_path.clone() + } + + /// Where the journal is being written. + pub fn log_path(&self) -> &Path { + &self.log_path + } + + /// How many samples have been committed to the journal. + pub fn samples_written(&self) -> usize { + self.samples_written + } + + /// The session header written at the top of the journal. + pub fn session_start(&self) -> &SessionStart { + &self.start + } + + /// Build a FIT from the journal *as it currently stands*, without ending + /// the ride. Useful for a mid-ride preview or export, and the cheapest way + /// to convince yourself the recording is sound before the ride ends. + pub fn snapshot_fit(&mut self) -> Result<(Vec, FitSummary), FitError> { + self.sync()?; + let log = read_log(&self.log_path)?; + encode_activity(&log) + } + + fn append(&mut self, entry: &LogEntry) -> Result<(), FitError> { + let line = entry_to_line(entry)?; + // One `write_all` per entry: a torn write can only ever damage the + // final line, which the reader drops. + self.file + .write_all(line.as_bytes()) + .map_err(|source| FitError::Io { + path: self.log_path.clone(), + source, + }) + } + + fn sync(&mut self) -> Result<(), FitError> { + self.since_sync = 0; + self.file.flush().map_err(|source| FitError::Io { + path: self.log_path.clone(), + source, + })?; + self.file.sync_data().map_err(|source| FitError::Io { + path: self.log_path.clone(), + source, + }) + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + if !self.finished { + // Best effort: get whatever is buffered onto disk. A ride + // interrupted by a panic is still recoverable from the journal. + let _ = self.file.flush(); + let _ = self.file.sync_data(); + } + } +} + +/// The machine's current UTC offset in seconds. +fn local_utc_offset_secs() -> i32 { + Local::now().offset().local_minus_utc() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rawlog::parse_log; + use bikecontrol_core::{ControlMode, Telemetry}; + use chrono::TimeZone; + + fn tmpdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bikecontrol-fit-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn started_at() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap() + } + + fn snapshot(elapsed_ms: u64) -> RideSnapshot { + RideSnapshot { + elapsed_ms, + telemetry: Telemetry { + elapsed_ms, + power_w: Some(210), + cadence_rpm: Some(88.0), + ..Default::default() + }, + virtual_speed_kph: 32.4, + virtual_distance_m: elapsed_ms as f64 * 0.009, + gradient_pct: 1.5, + elevation_gain_m: elapsed_ms as f32 * 0.000_135, + mode: ControlMode::ManualGrade, + target: None, + profile_progress: None, + } + } + + fn recorder(name: &str, opts: RecorderOptions) -> (Recorder, PathBuf) { + let dir = tmpdir(name); + let log = dir.join("ride.jsonl"); + let rec = Recorder::create_at(&log, opts, started_at(), 7200).unwrap(); + (rec, dir) + } + + #[test] + fn samples_are_throttled_to_one_hertz() { + let (mut rec, dir) = recorder("throttle", RecorderOptions::default()); + // 10 Hz input for 3 seconds. + let mut accepted = 0; + for i in 0..30u64 { + if rec.record(&snapshot(i * 100)).unwrap() { + accepted += 1; + } + } + assert_eq!(accepted, 3, "0 ms, 1000 ms, 2000 ms"); + assert_eq!(rec.samples_written(), 3); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_throttle_can_be_turned_off() { + let (mut rec, dir) = recorder("nothrottle", RecorderOptions { + sample_interval_ms: 0, + auto_gap_after_ms: None, + ..Default::default() + }); + for i in 0..10u64 { + assert!(rec.record(&snapshot(i * 100)).unwrap()); + } + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn every_sample_is_on_disk_before_the_call_returns() { + // The crash-safety claim, tested directly: read the journal back with + // the recorder still open and still holding the file. + let (mut rec, dir) = recorder("durable", RecorderOptions::default()); + for i in 0..5u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + let text = std::fs::read_to_string(rec.log_path()).unwrap(); + let log = parse_log(&text).unwrap(); + assert_eq!( + log.samples().count(), + (i + 1) as usize, + "sample {i} was not durable when record() returned" + ); + } + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn an_abandoned_journal_still_makes_a_fit() { + // Simulates a crash: the process dies, nothing calls finish(), and the + // journal is later handed to build_fit_from_log. + let (mut rec, dir) = recorder("crash", RecorderOptions::default()); + for i in 0..20u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + let log_path = rec.abandon(); + + let fit_path = dir.join("recovered.fit"); + let summary = crate::build_fit_from_log(&log_path, &fit_path).unwrap(); + assert_eq!(summary.records, 20); + assert!(summary.recovered_from_crash); + assert!(crate::encode::verify(&std::fs::read(&fit_path).unwrap()).is_ok()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn finish_writes_a_verifiable_fit_and_a_clean_journal() { + let (mut rec, dir) = recorder("finish", RecorderOptions::default()); + for i in 0..30u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + rec.mark_lap(10_000, true).unwrap(); + let log_path = rec.log_path().to_path_buf(); + let fit_path = dir.join("ride.fit"); + let summary = rec.finish(&fit_path).unwrap(); + + assert_eq!(summary.records, 30); + assert_eq!(summary.laps, 2); + assert!(!summary.recovered_from_crash); + assert_eq!(summary.skipped_log_lines, 0); + + let bytes = std::fs::read(&fit_path).unwrap(); + assert_eq!(bytes.len(), summary.bytes); + assert!(crate::encode::verify(&bytes).is_ok()); + + // The journal is still there and still describes the same ride. + let log = crate::read_log(&log_path).unwrap(); + assert!(log.clean_shutdown); + assert_eq!(log.samples().count(), 30); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_recovered_file_is_identical_to_the_clean_one() { + // The strongest form of FR-8.4: recovery is not a degraded path, it is + // the same path. + let (mut rec, dir) = recorder("identical", RecorderOptions::default()); + for i in 0..15u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + let log_path = rec.log_path().to_path_buf(); + let clean = dir.join("clean.fit"); + rec.finish(&clean).unwrap(); + + let rebuilt = dir.join("rebuilt.fit"); + crate::build_fit_from_log(&log_path, &rebuilt).unwrap(); + + assert_eq!( + std::fs::read(&clean).unwrap(), + std::fs::read(&rebuilt).unwrap() + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_dropout_is_detected_automatically() { + let (mut rec, dir) = recorder("autogap", RecorderOptions::default()); + rec.record(&snapshot(0)).unwrap(); + rec.record(&snapshot(1000)).unwrap(); + // Ten seconds of silence, then telemetry returns. + rec.record(&snapshot(11_000)).unwrap(); + let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap(); + assert_eq!(log.gaps(11_000), vec![(1000, 11_000)]); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn an_explicit_gap_is_closed_when_telemetry_returns() { + let (mut rec, dir) = recorder("explicitgap", RecorderOptions { + auto_gap_after_ms: None, + ..Default::default() + }); + rec.record(&snapshot(0)).unwrap(); + rec.mark_gap(1000, "peripheral disconnected").unwrap(); + // A second mark while already in a dropout is a no-op. + rec.mark_gap(2000, "still gone").unwrap(); + rec.record(&snapshot(9000)).unwrap(); + + let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap(); + let gaps = log.gaps(9000); + // The unterminated marker written at 1000 ms, plus its closure. + assert!(gaps.contains(&(1000, 9000))); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_dropout_open_at_the_end_of_the_ride_is_closed_by_finish() { + let (mut rec, dir) = recorder("opengap", RecorderOptions { + auto_gap_after_ms: None, + ..Default::default() + }); + rec.record(&snapshot(0)).unwrap(); + rec.record(&snapshot(5000)).unwrap(); + rec.mark_gap(6000, "trainer lost").unwrap(); + let fit = dir.join("ride.fit"); + let summary = rec.finish(&fit).unwrap(); + assert!(summary.gaps >= 1); + assert!(crate::encode::verify(&std::fs::read(&fit).unwrap()).is_ok()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn pause_and_resume_are_journalled() { + let (mut rec, dir) = recorder("pause", RecorderOptions::default()); + for i in 0..5u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + rec.pause(5000).unwrap(); + rec.resume(20_000).unwrap(); + for i in 20..25u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + let fit = dir.join("ride.fit"); + let summary = rec.finish(&fit).unwrap(); + assert_eq!(summary.total_elapsed_s, 24.0); + assert_eq!(summary.total_timer_s, 9.0, "24 s elapsed less 15 s paused"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_mid_ride_snapshot_is_a_valid_fit() { + let (mut rec, dir) = recorder("midride", RecorderOptions::default()); + for i in 0..8u64 { + rec.record(&snapshot(i * 1000)).unwrap(); + } + let (bytes, summary) = rec.snapshot_fit().unwrap(); + assert!(crate::encode::verify(&bytes).is_ok()); + assert_eq!(summary.records, 8); + assert!(summary.recovered_from_crash, "no end marker yet"); + // Recording continues afterwards. + assert!(rec.record(&snapshot(8000)).unwrap()); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn creating_a_recorder_makes_missing_directories() { + let dir = tmpdir("mkdir").join("a").join("b"); + let log = dir.join("ride.jsonl"); + let rec = Recorder::create_at(&log, RecorderOptions::default(), started_at(), 0).unwrap(); + assert!(log.exists()); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(tmpdir("mkdir")); + } + + #[test] + fn the_session_header_captures_the_start_and_offset() { + let (rec, dir) = recorder("header", RecorderOptions::default()); + let start = rec.session_start(); + assert_eq!(start.start_unix_ms, started_at().timestamp_millis()); + assert_eq!(start.utc_offset_secs, 7200); + assert_eq!(start.log_format, LOG_FORMAT_VERSION); + assert_eq!( + start.sub_sport, + crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY + ); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_gear_can_be_recorded_alongside_the_snapshot() { + let (mut rec, dir) = recorder("gear", RecorderOptions::default()); + rec.record_sample(Sample::from_snapshot(&snapshot(0)).with_gear(11)) + .unwrap(); + let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap(); + let s = log.samples().next().unwrap(); + assert_eq!(s.gear, Some(11)); + assert_eq!(s.mode, Some(ControlMode::ManualGrade)); + let _ = rec.abandon(); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/fit/src/timestamp.rs b/crates/fit/src/timestamp.rs new file mode 100644 index 0000000..888d072 --- /dev/null +++ b/crates/fit/src/timestamp.rs @@ -0,0 +1,150 @@ +//! FIT `date_time` conversion. +//! +//! FIT counts seconds since **1989-12-31 00:00:00 UTC**, not the Unix epoch. +//! Feeding a Unix timestamp straight into a FIT file lands the activity in +//! 1989, which is one of the more common ways a hand-rolled encoder produces a +//! file that parses fine and is still useless. + +use chrono::{DateTime, TimeZone, Utc}; + +use crate::FitError; + +/// The FIT epoch expressed as a Unix timestamp: 1989-12-31T00:00:00Z. +pub const FIT_EPOCH_UNIX_SECS: i64 = 631_065_600; + +/// The `date_time` invalid value. Also the boundary below which a raw value is +/// interpreted as a system time rather than a UTC timestamp. +pub const DATE_TIME_INVALID: u32 = 0xFFFF_FFFF; + +/// `date_time` values below this are "system time" (seconds since power-on), +/// not wall-clock. We never emit one, but the check keeps us honest. +pub const DATE_TIME_MIN: u32 = 0x1000_0000; + +/// Convert a UTC instant to a FIT `date_time`. +/// +/// Fails for instants before the FIT epoch or beyond the `u32` range. +pub fn to_fit(dt: DateTime) -> Result { + from_unix_secs(dt.timestamp()) +} + +/// Convert Unix seconds to a FIT `date_time`. +pub fn from_unix_secs(unix_secs: i64) -> Result { + let secs = unix_secs - FIT_EPOCH_UNIX_SECS; + if secs < 0 { + return Err(FitError::TimestampOutOfRange { unix_secs }); + } + u32::try_from(secs).map_err(|_| FitError::TimestampOutOfRange { unix_secs }) +} + +/// Convert Unix milliseconds to a FIT `date_time`, rounding to the nearest +/// second. Sub-second resolution has no representation in `date_time`. +pub fn from_unix_millis(unix_millis: i64) -> Result { + from_unix_secs(unix_millis.div_euclid(1000) + i64::from(unix_millis.rem_euclid(1000) >= 500)) +} + +/// Convert a FIT `date_time` back to a UTC instant. The inverse of [`to_fit`]. +pub fn to_utc(fit: u32) -> DateTime { + Utc.timestamp_opt(i64::from(fit) + FIT_EPOCH_UNIX_SECS, 0) + .single() + .expect("every u32 offset from the FIT epoch is a representable instant") +} + +/// Build a `local_timestamp`: the same instant expressed in the rider's local +/// time zone, still counted from the FIT epoch. Garmin Connect uses this to +/// show the ride at the time of day it actually happened. +pub fn to_local(fit_utc: u32, utc_offset_secs: i32) -> u32 { + fit_utc.saturating_add_signed(utc_offset_secs) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn epoch_constant_is_1989_12_31_utc() { + let epoch = Utc.with_ymd_and_hms(1989, 12, 31, 0, 0, 0).unwrap(); + assert_eq!(epoch.timestamp(), FIT_EPOCH_UNIX_SECS); + assert_eq!(to_fit(epoch).unwrap(), 0); + } + + #[test] + fn is_not_the_unix_epoch() { + // The whole point: a Unix timestamp is ~631 million seconds larger + // than the FIT value for the same instant. + let dt = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap(); + let fit = to_fit(dt).unwrap(); + assert_eq!(i64::from(fit), dt.timestamp() - FIT_EPOCH_UNIX_SECS); + assert_ne!(i64::from(fit), dt.timestamp()); + } + + #[test] + fn known_values() { + // 1990-01-01T00:00:00Z is exactly one day after the FIT epoch. + assert_eq!( + to_fit(Utc.with_ymd_and_hms(1990, 1, 1, 0, 0, 0).unwrap()).unwrap(), + 86_400 + ); + // 2020-01-01T00:00:00Z, computed independently: + // (2020-01-01 unix 1577836800) - 631065600 = 946771200. + assert_eq!( + to_fit(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()).unwrap(), + 946_771_200 + ); + } + + #[test] + fn round_trips_over_a_wide_range() { + for &unix in &[ + FIT_EPOCH_UNIX_SECS, + FIT_EPOCH_UNIX_SECS + 1, + 946_684_800, // 2000-01-01 + 1_600_000_000, // 2020-09 + 1_785_000_000, // 2026-07 + 4_000_000_000, // 2096 + ] { + let fit = from_unix_secs(unix).unwrap(); + assert_eq!(to_utc(fit).timestamp(), unix, "round trip failed for {unix}"); + } + } + + #[test] + fn round_trips_through_datetime() { + let dt = Utc.with_ymd_and_hms(2026, 8, 5, 9, 41, 17).unwrap(); + assert_eq!(to_utc(to_fit(dt).unwrap()), dt); + } + + #[test] + fn a_modern_timestamp_is_above_the_system_time_boundary() { + // Decoders treat date_time < 0x10000000 as system (uptime) time. Any + // ride recorded this decade must be well above it. + let fit = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 0, 0, 0).unwrap()).unwrap(); + assert!(fit > DATE_TIME_MIN); + assert!(fit < DATE_TIME_INVALID); + } + + #[test] + fn rejects_pre_epoch_and_reports_the_offending_value() { + let err = to_fit(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()).unwrap_err(); + assert!(matches!(err, FitError::TimestampOutOfRange { unix_secs: 0 })); + assert!(from_unix_secs(FIT_EPOCH_UNIX_SECS - 1).is_err()); + } + + #[test] + fn millis_round_to_nearest_second() { + let base = FIT_EPOCH_UNIX_SECS * 1000; + assert_eq!(from_unix_millis(base).unwrap(), 0); + assert_eq!(from_unix_millis(base + 499).unwrap(), 0); + assert_eq!(from_unix_millis(base + 500).unwrap(), 1); + assert_eq!(from_unix_millis(base + 1499).unwrap(), 1); + assert_eq!(from_unix_millis(base + 1500).unwrap(), 2); + } + + #[test] + fn local_timestamp_applies_the_offset() { + let utc = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()).unwrap(); + assert_eq!(to_local(utc, 7200), utc + 7200); // CEST + assert_eq!(to_local(utc, -18000), utc - 18000); // EST + assert_eq!(to_local(utc, 0), utc); + } +} diff --git a/crates/probe/src/cli.rs b/crates/probe/src/cli.rs new file mode 100644 index 0000000..f624476 --- /dev/null +++ b/crates/probe/src/cli.rs @@ -0,0 +1,365 @@ +//! Hand-rolled argument parsing. +//! +//! Deliberately dependency-free: the probe is a Phase 0 diagnostic tool that +//! has to build and run on whatever machine is next to the trainer, and it does +//! not need an argument parser to do four subcommands. + +use std::time::Duration; + +use anyhow::{anyhow, bail, Result}; +use bikecontrol_core::types::ControlTarget; + +pub const USAGE: &str = "\ +probe — Van Rysel D100 / FTMS protocol discovery (REQUIREMENTS.md Phase 0) + +USAGE: + probe [OPTIONS] + +SUBCOMMANDS: + scan List BLE peripherals: name, address, RSSI, advertised services + inspect Connect and dump every service, characteristic and capability + monitor Stream Indoor Bike Data as raw hex alongside decoded fields + set Take control and apply a target, then reset the trainer to zero + +TARGET (for `set`): + gradient= SetTargetInclination (0x03), e.g. gradient=4.5 + sim= SetIndoorBikeSimulation (0x11) — this is what answers A-1 + resistance= SetTargetResistanceLevel (0x04), e.g. resistance=30 + power= SetTargetPower (0x05), e.g. power=200 + +OPTIONS: + --secs scan/monitor duration, or how long `set` holds the target (default: + scan 6, monitor 30, set 15) + --all `scan`: list every peripheral, not just fitness machines + --name use in place of to match on advertised name + -v, --verbose debug-level logging, including every raw BLE frame (NFR-8) + -h, --help this text + +ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF). + +SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the +trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C. +"; + +#[derive(Debug, PartialEq)] +pub enum Command { + Help, + Scan { + duration: Duration, + all: bool, + }, + Inspect { + device: Device, + }, + Monitor { + device: Device, + duration: Duration, + }, + Set { + device: Device, + target: ControlTarget, + /// True for `sim=`, which forces op code `0x11`. + simulation: bool, + hold: Duration, + }, +} + +/// How the user identified the trainer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Device { + Address(String), + Name(String), +} + +#[derive(Debug, PartialEq)] +pub struct Args { + pub command: Command, + pub verbose: bool, +} + +pub fn parse>(argv: I) -> Result { + let mut args: Vec = argv.into_iter().collect(); + + let mut verbose = false; + let mut secs: Option = None; + let mut all = false; + let mut name: Option = None; + let mut help = false; + let mut positional: Vec = Vec::new(); + + let mut i = 0; + while i < args.len() { + let arg = std::mem::take(&mut args[i]); + match arg.as_str() { + "-h" | "--help" | "help" => help = true, + "-v" | "--verbose" => verbose = true, + "--all" => all = true, + "--secs" | "--seconds" => { + i += 1; + let v = args + .get(i) + .ok_or_else(|| anyhow!("--secs needs a value"))? + .clone(); + secs = Some( + v.parse() + .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?, + ); + } + "--name" => { + i += 1; + name = Some( + args.get(i) + .ok_or_else(|| anyhow!("--name needs a value"))? + .clone(), + ); + } + other if other.starts_with('-') => bail!("unknown option {other:?}"), + other => positional.push(other.to_string()), + } + i += 1; + } + + if help || positional.is_empty() { + return Ok(Args { + command: Command::Help, + verbose, + }); + } + + let device = |positional: &[String], index: usize| -> Result { + if let Some(n) = &name { + return Ok(Device::Name(n.clone())); + } + positional + .get(index) + .map(|a| Device::Address(a.clone())) + .ok_or_else(|| anyhow!("this subcommand needs an address (or --name )")) + }; + + let command = match positional[0].as_str() { + "scan" => Command::Scan { + duration: Duration::from_secs(secs.unwrap_or(6)), + all, + }, + "inspect" => Command::Inspect { + device: device(&positional, 1)?, + }, + "monitor" => Command::Monitor { + device: device(&positional, 1)?, + duration: Duration::from_secs(secs.unwrap_or(30)), + }, + "set" => { + // With --name the address slot is absent, so the target may be at + // index 1 or 2. + let target_arg = if name.is_some() && positional.len() == 2 { + positional[1].clone() + } else { + positional + .get(2) + .cloned() + .ok_or_else(|| anyhow!("`set` needs a target, e.g. gradient=4.5"))? + }; + let (target, simulation) = parse_target(&target_arg)?; + Command::Set { + device: device(&positional, 1)?, + target, + simulation, + hold: Duration::from_secs(secs.unwrap_or(15)), + } + } + other => bail!("unknown subcommand {other:?} — run `probe --help`"), + }; + + Ok(Args { command, verbose }) +} + +/// Parse `gradient=4.5`, `resistance=30`, `power=200` or `sim=4.5`. +/// +/// Returns the target and whether simulation mode (`0x11`) was requested. +pub fn parse_target(s: &str) -> Result<(ControlTarget, bool)> { + let (key, value) = s + .split_once('=') + .or_else(|| s.split_once(':')) + .ok_or_else(|| anyhow!("target must look like `gradient=4.5`, got {s:?}"))?; + + let key = key.trim().to_lowercase(); + let value = value.trim(); + + match key.as_str() { + "gradient" | "grade" | "incline" | "inclination" => { + let pct: f32 = value + .parse() + .map_err(|_| anyhow!("gradient must be a number of percent, got {value:?}"))?; + Ok((ControlTarget::Gradient { percent: pct }, false)) + } + "sim" | "simulation" | "simgrade" => { + let pct: f32 = value + .parse() + .map_err(|_| anyhow!("sim grade must be a number of percent, got {value:?}"))?; + Ok((ControlTarget::Gradient { percent: pct }, true)) + } + "resistance" | "res" | "level" => { + let level: i16 = value + .parse() + .map_err(|_| anyhow!("resistance must be a whole number, got {value:?}"))?; + Ok((ControlTarget::Resistance { level }, false)) + } + "power" | "watts" | "erg" => { + let watts: u16 = value + .parse() + .map_err(|_| anyhow!("power must be a whole number of watts, got {value:?}"))?; + Ok((ControlTarget::Power { watts }, false)) + } + other => bail!("unknown target channel {other:?} — use gradient, sim, resistance or power"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(v: &[&str]) -> Result { + parse(v.iter().map(|s| s.to_string())) + } + + #[test] + fn no_arguments_prints_help() { + assert_eq!(args(&[]).unwrap().command, Command::Help); + assert_eq!(args(&["--help"]).unwrap().command, Command::Help); + assert_eq!(args(&["scan", "-h"]).unwrap().command, Command::Help); + } + + #[test] + fn scan_defaults_and_flags() { + assert_eq!( + args(&["scan"]).unwrap().command, + Command::Scan { + duration: Duration::from_secs(6), + all: false + } + ); + assert_eq!( + args(&["scan", "--all", "--secs", "12"]).unwrap().command, + Command::Scan { + duration: Duration::from_secs(12), + all: true + } + ); + } + + #[test] + fn verbose_is_recognised_anywhere() { + assert!(args(&["-v", "scan"]).unwrap().verbose); + assert!(args(&["scan", "--verbose"]).unwrap().verbose); + assert!(!args(&["scan"]).unwrap().verbose); + } + + #[test] + fn inspect_and_monitor_take_an_address() { + assert_eq!( + args(&["inspect", "AA:BB:CC:DD:EE:FF"]).unwrap().command, + Command::Inspect { + device: Device::Address("AA:BB:CC:DD:EE:FF".into()) + } + ); + assert_eq!( + args(&["monitor", "AA:BB:CC:DD:EE:FF", "--secs", "5"]) + .unwrap() + .command, + Command::Monitor { + device: Device::Address("AA:BB:CC:DD:EE:FF".into()), + duration: Duration::from_secs(5) + } + ); + } + + #[test] + fn name_substitutes_for_an_address() { + assert_eq!( + args(&["inspect", "--name", "D100"]).unwrap().command, + Command::Inspect { + device: Device::Name("D100".into()) + } + ); + assert_eq!( + args(&["set", "--name", "D100", "power=150"]).unwrap().command, + Command::Set { + device: Device::Name("D100".into()), + target: ControlTarget::Power { watts: 150 }, + simulation: false, + hold: Duration::from_secs(15), + } + ); + } + + #[test] + fn set_parses_every_channel() { + let cmd = args(&["set", "aa:bb", "gradient=4.5"]).unwrap().command; + assert_eq!( + cmd, + Command::Set { + device: Device::Address("aa:bb".into()), + target: ControlTarget::Gradient { percent: 4.5 }, + simulation: false, + hold: Duration::from_secs(15), + } + ); + + let cmd = args(&["set", "aa:bb", "sim=-3.0", "--secs", "4"]) + .unwrap() + .command; + assert_eq!( + cmd, + Command::Set { + device: Device::Address("aa:bb".into()), + target: ControlTarget::Gradient { percent: -3.0 }, + simulation: true, + hold: Duration::from_secs(4), + } + ); + } + + #[test] + fn target_parsing_covers_aliases_and_signs() { + assert_eq!( + parse_target("grade=-7.5").unwrap(), + (ControlTarget::Gradient { percent: -7.5 }, false) + ); + assert_eq!( + parse_target("res=30").unwrap(), + (ControlTarget::Resistance { level: 30 }, false) + ); + assert_eq!( + parse_target("watts=250").unwrap(), + (ControlTarget::Power { watts: 250 }, false) + ); + assert_eq!( + parse_target("SIM=6").unwrap(), + (ControlTarget::Gradient { percent: 6.0 }, true) + ); + // Colon works too, for shells that dislike `=`. + assert_eq!( + parse_target("power:100").unwrap(), + (ControlTarget::Power { watts: 100 }, false) + ); + } + + #[test] + fn target_parsing_rejects_nonsense() { + assert!(parse_target("gradient").is_err()); + assert!(parse_target("gradient=uphill").is_err()); + assert!(parse_target("torque=5").is_err()); + assert!(parse_target("power=-50").is_err(), "power is unsigned"); + assert!(parse_target("resistance=1.5").is_err(), "resistance is integral"); + } + + #[test] + fn missing_and_unknown_arguments_are_errors() { + assert!(args(&["inspect"]).is_err()); + assert!(args(&["set", "aa:bb"]).is_err()); + assert!(args(&["scan", "--secs"]).is_err()); + assert!(args(&["scan", "--secs", "soon"]).is_err()); + assert!(args(&["frobnicate"]).is_err()); + assert!(args(&["scan", "--wat"]).is_err()); + } +} diff --git a/crates/probe/src/commands.rs b/crates/probe/src/commands.rs new file mode 100644 index 0000000..647a10f --- /dev/null +++ b/crates/probe/src/commands.rs @@ -0,0 +1,745 @@ +//! The four probe subcommands. +//! +//! `scan`, `inspect` and `monitor` are read-only and talk to `btleplug` +//! directly, so they never take FTMS control and can be run safely while +//! poking at an unfamiliar device. `set` goes through [`FtmsClient`], which +//! means it exercises the same rate limiting, clamping, acknowledgement +//! handling and SAF-2 shutdown that the app will use. + +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; +use bikecontrol_ble::capabilities::{ + FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, +}; +use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent}; +use bikecontrol_ble::control_point::ResultCode; +use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData}; +use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector}; +use bikecontrol_ble::{uuids, FtmsError}; +use bikecontrol_core::types::ControlTarget; +use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _}; +use btleplug::platform::Peripheral; +use futures::StreamExt; +use uuid::Uuid; + +use crate::cli::Device; + +impl Device { + fn selector(&self) -> TrainerSelector { + match self { + Device::Address(a) => TrainerSelector::Address(a.clone()), + Device::Name(n) => TrainerSelector::NameContains(n.clone()), + } + } +} + +// --------------------------------------------------------------------------- +// scan +// --------------------------------------------------------------------------- + +/// FR-1.1: list peripherals with name, address, RSSI and advertised services. +pub async fn scan_cmd(duration: Duration, all: bool) -> Result<()> { + let adapter = scan::default_adapter() + .await + .context("no Bluetooth adapter — is the radio on?")?; + let kind = if all { + ScanKind::All + } else { + ScanKind::FitnessMachines + }; + + println!( + "Scanning for {} s ({})...", + duration.as_secs(), + if all { + "everything" + } else { + "fitness machines only — pass --all to see every peripheral" + } + ); + + let devices = scan::scan(&adapter, duration, kind).await?; + + if devices.is_empty() { + println!("\nNothing found."); + println!( + "The trainer only advertises once it is awake (A-4): pedal it for a few seconds\n\ + and scan again. A Zwift Click wakes on a button press." + ); + return Ok(()); + } + + println!("\n{} device(s):\n", devices.len()); + for d in &devices { + print_device(d); + } + Ok(()) +} + +fn print_device(d: &DiscoveredDevice) { + let kind = if d.is_fitness_machine() { + " [FTMS trainer]" + } else if d.is_zwift_device() { + " [Zwift device]" + } else { + "" + }; + println!("{} {}{}", d.address, d.label(), kind); + println!( + " rssi: {} tx power: {}", + d.rssi.map(|v| format!("{v} dBm")).unwrap_or("?".into()), + d.tx_power.map(|v| format!("{v} dBm")).unwrap_or("?".into()) + ); + if d.services.is_empty() { + println!(" services: (none advertised)"); + } else { + println!(" services:"); + for s in &d.services { + println!(" {}{}", s, named(*s)); + } + } + for (id, data) in &d.manufacturer_data { + println!(" manufacturer 0x{id:04x} ({id}): {}", hex(data)); + } + for (uuid, data) in &d.service_data { + println!(" service data {uuid}: {}", hex(data)); + } + println!(); +} + +fn named(uuid: Uuid) -> String { + uuids::well_known_name(uuid) + .map(|n| format!(" ({n})")) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// inspect +// --------------------------------------------------------------------------- + +/// TASK-1: enumerate everything, and decode the capability characteristics. +pub async fn inspect(device: &Device, scan_timeout: Duration) -> Result<()> { + let peripheral = connect(device, scan_timeout).await?; + + if let Some(d) = scan::describe(&peripheral).await { + println!("Connected to {} ({})\n", d.address, d.label()); + } + + println!("=== Services and characteristics ===\n"); + let mut has_ftms = false; + for service in peripheral.services() { + if service.uuid == uuids::FITNESS_MACHINE_SERVICE { + has_ftms = true; + } + println!( + "service {}{}{}", + service.uuid, + named(service.uuid), + if service.primary { " [primary]" } else { "" } + ); + for ch in &service.characteristics { + println!( + " char {}{}\n properties: {}", + ch.uuid, + named(ch.uuid), + properties(ch.properties) + ); + // Reading is safe: every readable characteristic here is + // informational, and it is exactly what Phase 0 needs to see. + if ch.properties.contains(CharPropFlags::READ) { + match peripheral.read(ch).await { + Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)), + Err(e) => println!(" value: "), + } + } + } + println!(); + } + + if !has_ftms { + println!( + "!! This peripheral does not expose the Fitness Machine Service (0x1826).\n\ + !! It is not an FTMS trainer, or it needs waking.\n" + ); + } + + println!("=== Fitness Machine Feature (0x2ACC) ===\n"); + match read_char(&peripheral, uuids::FITNESS_MACHINE_FEATURE).await { + Some(raw) => match FitnessMachineFeature::decode(&raw) { + Ok(f) => print_feature(&raw, f), + Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)), + }, + None => println!(" not present or unreadable\n"), + } + + println!("=== Supported Resistance Level Range (0x2AD6) ===\n"); + match read_char(&peripheral, uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE).await { + Some(raw) => match ResistanceLevelRange::decode(&raw) { + Ok(r) => { + let (lo, hi, inc) = r.scaled(); + println!(" raw bytes: {}", hex(&raw)); + println!(" minimum: {}", r.min); + println!(" maximum: {}", r.max); + println!(" increment: {}", r.increment); + println!( + " if the spec's 0.1 resolution applies: {lo} .. {hi} step {inc}" + ); + println!( + "\n NOTE: resistance level is a trainer-specific unit. Whether the D100\n\ + means raw integers or tenths is TASK-1/TASK-3 — compare these numbers\n\ + with what `set resistance=` actually does, and with the resistance\n\ + level reported back in Indoor Bike Data.\n" + ); + } + Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)), + }, + None => println!(" not present or unreadable\n"), + } + + println!("=== Supported Power Range (0x2AD8) ===\n"); + match read_char(&peripheral, uuids::SUPPORTED_POWER_RANGE).await { + Some(raw) => match PowerRange::decode(&raw) { + Ok(p) => println!( + " raw bytes: {}\n {} .. {} W, step {} W\n", + hex(&raw), + p.min_w, + p.max_w, + p.increment_w + ), + Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)), + }, + None => println!(" not present or unreadable\n"), + } + + println!("=== Supported Inclination Range (0x2AD5) ===\n"); + match read_char(&peripheral, uuids::SUPPORTED_INCLINATION_RANGE).await { + Some(raw) => match InclinationRange::decode(&raw) { + Ok(i) => println!( + " raw bytes: {}\n {} .. {} %, step {} %\n", + hex(&raw), + i.min_percent(), + i.max_percent(), + i.increment_percent() + ), + Err(e) => println!(" raw {}: could not decode ({e})\n", hex(&raw)), + }, + None => println!(" not present or unreadable\n"), + } + + disconnect(&peripheral).await; + Ok(()) +} + +fn print_feature(raw: &[u8], f: FitnessMachineFeature) { + println!(" raw bytes: {}", hex(raw)); + println!(" machine field: 0x{:08x}", f.machine); + println!(" target field: 0x{:08x}\n", f.target); + + println!(" Measures:"); + let m = f.machine_feature_names(); + if m.is_empty() { + println!(" (none)"); + } + for name in m { + println!(" - {name}"); + } + + println!("\n Accepts as targets:"); + let t = f.target_feature_names(); + if t.is_empty() { + println!(" (none)"); + } + for name in t { + println!(" - {name}"); + } + + println!("\n Answers to the questions Phase 0 is asking:"); + println!( + " SetTargetInclination (0x03): {}", + yes_no(f.supports_inclination_target()) + ); + println!( + " SetTargetResistanceLevel (0x04): {}", + yes_no(f.supports_resistance_target()) + ); + println!( + " SetTargetPower (0x05): {}", + yes_no(f.supports_power_target()) + ); + println!( + " SetIndoorBikeSimulationParameters (0x11): {} <-- A-1", + yes_no(f.supports_simulation()) + ); + println!( + "\n The 0x11 bit is only what the trainer *claims*. Confirm it with\n\ + `probe set sim=4.0`, which writes the op code regardless.\n" + ); +} + +fn yes_no(b: bool) -> &'static str { + if b { + "advertised" + } else { + "NOT advertised" + } +} + +// --------------------------------------------------------------------------- +// monitor +// --------------------------------------------------------------------------- + +/// TASK-1: raw hex next to decoded fields, so a decoder bug is obvious. +pub async fn monitor(device: &Device, duration: Duration, scan_timeout: Duration) -> Result<()> { + let peripheral = connect(device, scan_timeout).await?; + + let bike_data = find_characteristic(&peripheral, uuids::INDOOR_BIKE_DATA).ok_or_else(|| { + anyhow!("this peripheral has no Indoor Bike Data characteristic (0x2AD2)") + })?; + + let mut notifications = peripheral.notifications().await?; + peripheral.subscribe(&bike_data).await?; + + println!( + "Subscribed to Indoor Bike Data (0x2AD2) for {} s.\n\ + Pedal the trainer — most trainers send nothing at all when stationary.\n\ + Press Ctrl-C to stop early.\n", + duration.as_secs() + ); + + let start = Instant::now(); + let mut count: u64 = 0; + let mut failures: u64 = 0; + + let deadline = tokio::time::sleep(duration); + tokio::pin!(deadline); + + loop { + tokio::select! { + _ = &mut deadline => break, + _ = tokio::signal::ctrl_c() => { + println!("\nInterrupted."); + break; + } + n = notifications.next() => { + let Some(n) = n else { + println!("\nNotification stream ended (the trainer disconnected)."); + break; + }; + if n.uuid != uuids::INDOOR_BIKE_DATA { + continue; + } + count += 1; + let t = start.elapsed().as_secs_f32(); + println!("[{t:7.2}s] #{count} raw 2ad2: {}", hex(&n.value)); + match indoor_bike_data::decode(&n.value) { + Ok(d) => print_decoded(&d, n.value.len()), + Err(e) => { + failures += 1; + println!(" DECODE FAILED: {e}"); + } + } + println!(); + } + } + } + + println!( + "\n{count} packet(s) in {:.1} s ({:.2} Hz), {failures} decode failure(s).", + start.elapsed().as_secs_f32(), + count as f32 / start.elapsed().as_secs_f32().max(0.001) + ); + if count > 0 && failures == 0 { + println!("Decoder agrees with the trainer on every packet."); + } + + let _ = peripheral.unsubscribe(&bike_data).await; + disconnect(&peripheral).await; + Ok(()) +} + +fn print_decoded(d: &IndoorBikeData, len: usize) { + println!( + " flags: 0x{:04x} ({})", + d.flags, + flag_names(d.flags) + ); + let row = |label: &str, value: Option| { + if let Some(v) = value { + println!(" {label:<10} {v}"); + } + }; + row("speed:", d.instant_speed_kph.map(|v| format!("{v:.2} km/h"))); + row("avg speed:", d.average_speed_kph.map(|v| format!("{v:.2} km/h"))); + row("cadence:", d.instant_cadence_rpm.map(|v| format!("{v:.1} rpm"))); + row("avg cad:", d.average_cadence_rpm.map(|v| format!("{v:.1} rpm"))); + row("distance:", d.total_distance_m.map(|v| format!("{v} m"))); + row("resist:", d.resistance_level.map(|v| v.to_string())); + row("power:", d.instant_power_w.map(|v| format!("{v} W"))); + row("avg power:", d.average_power_w.map(|v| format!("{v} W"))); + row("energy:", d.total_energy_kcal.map(|v| format!("{v} kcal"))); + row("kcal/h:", d.energy_per_hour_kcal.map(|v| v.to_string())); + row("kcal/min:", d.energy_per_minute_kcal.map(|v| v.to_string())); + row("hr:", d.heart_rate_bpm.map(|v| format!("{v} bpm"))); + row("met:", d.metabolic_equivalent.map(|v| format!("{v:.1}"))); + row("elapsed:", d.elapsed_time_s.map(|v| format!("{v} s"))); + row("remaining:", d.remaining_time_s.map(|v| format!("{v} s"))); + + if d.consumed != len { + println!( + " !! consumed {} of {len} bytes — {} trailing byte(s) unaccounted for", + d.consumed, + len - d.consumed + ); + } +} + +fn flag_names(flags: u16) -> String { + use indoor_bike_data::flag as f; + let mut names = Vec::new(); + // Bit 0 is inverted: speed is present when it is CLEAR. + if flags & f::MORE_DATA == 0 { + names.push("InstantaneousSpeed(bit0 clear)"); + } else { + names.push("MoreData(bit0 set: no speed)"); + } + for (bit, name) in [ + (f::AVERAGE_SPEED, "AvgSpeed"), + (f::INSTANTANEOUS_CADENCE, "Cadence"), + (f::AVERAGE_CADENCE, "AvgCadence"), + (f::TOTAL_DISTANCE, "TotalDistance"), + (f::RESISTANCE_LEVEL, "Resistance"), + (f::INSTANTANEOUS_POWER, "Power"), + (f::AVERAGE_POWER, "AvgPower"), + (f::EXPENDED_ENERGY, "Energy"), + (f::HEART_RATE, "HeartRate"), + (f::METABOLIC_EQUIVALENT, "MET"), + (f::ELAPSED_TIME, "ElapsedTime"), + (f::REMAINING_TIME, "RemainingTime"), + ] { + if flags & bit != 0 { + names.push(name); + } + } + if flags & 0xE000 != 0 { + names.push(""); + } + names.join(" | ") +} + +// --------------------------------------------------------------------------- +// set +// --------------------------------------------------------------------------- + +/// TASK-2, and the experiment that answers A-1. +pub async fn set( + device: &Device, + target: ControlTarget, + simulation: bool, + hold: Duration, + scan_timeout: Duration, +) -> Result<()> { + let config = FtmsConfig { + use_simulation_mode: simulation, + scan_timeout, + // Discovery: write the op code even when the feature bit is clear, so + // the trainer's own response settles the question rather than our + // reading of its advertisement. + ignore_advertised_features: true, + ..FtmsConfig::default() + }; + + println!("Connecting and requesting FTMS control..."); + let client = FtmsClient::connect(device.selector(), config).await?; + println!( + "Control acquired on {} ({}).\n", + client.address(), + client.name().unwrap_or("no name") + ); + + let caps = client.capabilities(); + if let Some(f) = caps.feature { + println!("Trainer advertises target support: {:?}\n", f.target_feature_names()); + } + + let mut events = client.events(); + let mut telemetry = client.telemetry(); + + let (op, note) = describe_write(&target, simulation); + println!("Writing {op} ({note})..."); + + let outcome = client.set_target(target).await; + report_outcome(&outcome, simulation); + + // Drain the indication that came back, so the raw result code is visible + // even when the write succeeded. + while let Ok(event) = events.try_recv() { + if let FtmsEvent::ControlResponse { op, result } = event { + println!( + " indication: op {:?}, result {} (0x{:02x})", + op, + result, + result.as_u8() + ); + } + } + + if outcome.is_ok() { + println!( + "\nHolding for {} s — check whether the resistance actually changed at the pedals.\n\ + (TASK-2's exit criterion is a *felt* change, not an acknowledged write.)\n\ + Ctrl-C to stop early.\n", + hold.as_secs() + ); + + let deadline = tokio::time::sleep(hold); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = &mut deadline => break, + _ = tokio::signal::ctrl_c() => { + println!("\nInterrupted."); + break; + } + sample = telemetry.recv() => { + if let Ok(s) = sample { + println!( + " {:6.1}s power {:>5} cadence {:>6} speed {:>7} resistance {:>5}", + s.elapsed_ms as f32 / 1000.0, + s.power_w.map(|v| format!("{v} W")).unwrap_or("-".into()), + s.cadence_rpm.map(|v| format!("{v:.0} rpm")).unwrap_or("-".into()), + s.speed_kph.map(|v| format!("{v:.1} kph")).unwrap_or("-".into()), + s.resistance_level.map(|v| v.to_string()).unwrap_or("-".into()), + ); + } + } + } + } + } + + println!("\nResetting the trainer to zero gradient / minimum resistance (SAF-2)..."); + client.shutdown().await?; + println!("Done."); + Ok(()) +} + +fn describe_write(target: &ControlTarget, simulation: bool) -> (&'static str, String) { + match target { + ControlTarget::Gradient { percent } if simulation => ( + "SetIndoorBikeSimulationParameters (0x11)", + format!("grade {percent} %"), + ), + ControlTarget::Gradient { percent } => ( + "SetTargetInclination (0x03)", + format!("inclination {percent} %"), + ), + ControlTarget::Resistance { level } => ( + "SetTargetResistanceLevel (0x04)", + format!("level {level}"), + ), + ControlTarget::Power { watts } => ("SetTargetPower (0x05)", format!("{watts} W")), + } +} + +fn report_outcome(outcome: &Result, simulation: bool) { + match outcome { + Ok(ControlOutcome::Acknowledged { sent }) => { + println!(" ACCEPTED. Trainer acknowledged with Success."); + println!(" value actually transmitted (post-clamp): {sent:?}"); + if simulation { + println!( + "\n >>> A-1 RESOLVED: the D100 ACCEPTS op code 0x11 (sim mode).\n\ + >>> FR-2.3 may use 0x11 for gradient." + ); + } + } + Ok(ControlOutcome::Superseded) => { + println!(" superseded before transmission (should not happen for a single write)"); + } + Err(FtmsError::Rejected { op, result }) => { + println!(" REJECTED. Trainer answered {op} with: {result}"); + if simulation && *result == ResultCode::OpCodeNotSupported { + println!( + "\n >>> A-1 RESOLVED: the D100 does NOT support op code 0x11.\n\ + >>> FR-2.3 must drive gradient via SetTargetInclination (0x03),\n\ + >>> exactly as the MIT reference implementation does. Low impact —\n\ + >>> the app owns the physics (FR-7.1)." + ); + } + if *result == ResultCode::ControlNotPermitted { + println!( + " (RequestControl succeeded but the trainer withdrew control — another\n\ + app may be connected. Only one BLE host may hold the trainer, per A-3.)" + ); + } + } + Err(FtmsError::Unacknowledged { op, timeout_ms }) => { + println!(" NO ANSWER. {op} was written but no indication arrived in {timeout_ms} ms."); + println!(" This is the silent-failure mode FR-2.7 exists to catch."); + } + Err(FtmsError::Unsupported(e)) => { + println!(" BLOCKED BEFORE TRANSMISSION: {e}"); + } + Err(e) => println!(" FAILED: {e}"), + } +} + +// --------------------------------------------------------------------------- +// Shared plumbing +// --------------------------------------------------------------------------- + +async fn connect(device: &Device, scan_timeout: Duration) -> Result { + let adapter = scan::default_adapter() + .await + .context("no Bluetooth adapter — is the radio on?")?; + let selector = device.selector(); + + println!("Looking for {}...", selector.describe()); + let peripheral = scan::find_peripheral(&adapter, &selector, scan_timeout) + .await + .with_context(|| { + format!( + "could not find {}. The trainer may be asleep — pedal it and try again (A-4)", + selector.describe() + ) + })?; + + if !peripheral.is_connected().await.unwrap_or(false) { + peripheral.connect().await.context("connect failed")?; + } + peripheral + .discover_services() + .await + .context("service discovery failed")?; + Ok(peripheral) +} + +async fn disconnect(peripheral: &Peripheral) { + if let Err(e) = peripheral.disconnect().await { + tracing::debug!(error = %e, "disconnect failed"); + } +} + +fn find_characteristic(peripheral: &Peripheral, uuid: Uuid) -> Option { + peripheral.characteristics().into_iter().find(|c| c.uuid == uuid) +} + +async fn read_char(peripheral: &Peripheral, uuid: Uuid) -> Option> { + let ch = find_characteristic(peripheral, uuid)?; + peripheral.read(&ch).await.ok() +} + +/// Render a characteristic's bytes as text when they look like a string — +/// Device Information holds model and firmware numbers this way. +fn as_text(v: &[u8]) -> String { + if !v.is_empty() + && v.iter() + .all(|b| (0x20..0x7f).contains(b) || *b == b'\n' || *b == b'\r') + { + format!("\"{}\"", String::from_utf8_lossy(v).trim()) + } else { + String::new() + } +} + +fn properties(p: CharPropFlags) -> String { + let mut out = Vec::new(); + for (flag, name) in [ + (CharPropFlags::BROADCAST, "broadcast"), + (CharPropFlags::READ, "read"), + (CharPropFlags::WRITE_WITHOUT_RESPONSE, "write-without-response"), + (CharPropFlags::WRITE, "write"), + (CharPropFlags::NOTIFY, "notify"), + (CharPropFlags::INDICATE, "indicate"), + ( + CharPropFlags::AUTHENTICATED_SIGNED_WRITES, + "authenticated-signed-writes", + ), + (CharPropFlags::EXTENDED_PROPERTIES, "extended-properties"), + ] { + if p.contains(flag) { + out.push(name); + } + } + if out.is_empty() { + "(none)".to_string() + } else { + out.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bikecontrol_ble::indoor_bike_data::flag; + + #[test] + fn flag_names_call_out_the_inverted_bit_zero() { + // Bit 0 clear means speed IS present. + assert!(flag_names(0x0000).contains("InstantaneousSpeed(bit0 clear)")); + // Bit 0 set means it is not. + assert!(flag_names(flag::MORE_DATA).contains("MoreData(bit0 set: no speed)")); + } + + #[test] + fn flag_names_list_every_present_field() { + let names = flag_names(flag::INSTANTANEOUS_CADENCE | flag::INSTANTANEOUS_POWER); + assert!(names.contains("Cadence")); + assert!(names.contains("Power")); + assert!(!names.contains("HeartRate")); + } + + #[test] + fn flag_names_flag_reserved_bits() { + assert!(flag_names(0x8000).contains("")); + assert!(!flag_names(0x0001).contains("")); + } + + #[test] + fn device_selector_mapping() { + assert_eq!( + Device::Address("AA:BB".into()).selector(), + TrainerSelector::Address("AA:BB".into()) + ); + assert_eq!( + Device::Name("D100".into()).selector(), + TrainerSelector::NameContains("D100".into()) + ); + } + + #[test] + fn describe_write_names_the_op_code() { + assert_eq!( + describe_write(&ControlTarget::Gradient { percent: 4.0 }, false).0, + "SetTargetInclination (0x03)" + ); + assert_eq!( + describe_write(&ControlTarget::Gradient { percent: 4.0 }, true).0, + "SetIndoorBikeSimulationParameters (0x11)" + ); + assert_eq!( + describe_write(&ControlTarget::Resistance { level: 10 }, false).0, + "SetTargetResistanceLevel (0x04)" + ); + assert_eq!( + describe_write(&ControlTarget::Power { watts: 100 }, false).0, + "SetTargetPower (0x05)" + ); + } + + #[test] + fn as_text_only_renders_printable_payloads() { + assert_eq!(as_text(b"D100"), "\"D100\""); + assert_eq!(as_text(&[0x00, 0x01, 0xff]), ""); + assert_eq!(as_text(&[]), ""); + } + + #[test] + fn properties_are_listed_in_order() { + assert_eq!( + properties(CharPropFlags::READ | CharPropFlags::INDICATE), + "read, indicate" + ); + assert_eq!(properties(CharPropFlags::empty()), "(none)"); + } +} diff --git a/crates/probe/src/main.rs b/crates/probe/src/main.rs index ae4a540..5517071 100644 --- a/crates/probe/src/main.rs +++ b/crates/probe/src/main.rs @@ -1 +1,68 @@ -fn main() { println!("probe: not yet implemented"); } +//! `probe` — BLE protocol discovery against real hardware. +//! +//! This is the Phase 0 tool from REQUIREMENTS.md §9: TASK-1 (enumerate the +//! D100's services, dump its capability characteristics, log decoded Indoor +//! Bike Data, resolve whether op code `0x11` works) and TASK-2 (write a control +//! command and confirm a physical resistance change). +//! +//! It is intentionally separate from the app: it prints raw bytes next to +//! decoded values (NFR-8) so that a decoder bug shows up as a disagreement on +//! screen rather than as a strange number in a chart. + +mod cli; +mod commands; + +use std::time::Duration; + +use anyhow::Result; +use tracing_subscriber::EnvFilter; + +/// How long to look for the device before giving up. +const SCAN_TIMEOUT: Duration = Duration::from_secs(20); + +#[tokio::main] +async fn main() -> Result<()> { + let args = match cli::parse(std::env::args().skip(1)) { + Ok(args) => args, + Err(e) => { + eprintln!("error: {e}\n"); + eprint!("{}", cli::USAGE); + std::process::exit(2); + } + }; + + init_logging(args.verbose); + + match args.command { + cli::Command::Help => { + print!("{}", cli::USAGE); + Ok(()) + } + cli::Command::Scan { duration, all } => commands::scan_cmd(duration, all).await, + cli::Command::Inspect { device } => commands::inspect(&device, SCAN_TIMEOUT).await, + cli::Command::Monitor { device, duration } => { + commands::monitor(&device, duration, SCAN_TIMEOUT).await + } + cli::Command::Set { + device, + target, + simulation, + hold, + } => commands::set(&device, target, simulation, hold, SCAN_TIMEOUT).await, + } +} + +fn init_logging(verbose: bool) { + // `-v` turns on the raw-frame logging required by NFR-8. RUST_LOG still + // wins, so `RUST_LOG=trace` gets every notification. + let default = if verbose { + "bikecontrol_ble=debug,probe=debug,info" + } else { + "warn" + }; + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default.into())) + .with_target(false) + .without_time() + .init(); +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 07599d7..4b13354 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-dialog = "2" serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } +roxmltree = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..b49215c Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..ea5902e Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..52ee7f5 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..52cb3c6 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..819c0d5 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs new file mode 100644 index 0000000..3747d39 --- /dev/null +++ b/src-tauri/src/backend.rs @@ -0,0 +1,67 @@ +//! The seam between the Tauri shell and whatever is actually riding. +//! +//! Today that is [`crate::mock::MockBackend`], a synthetic rider. Tomorrow it +//! is `bikecontrol_core::RideSession` fed by FTMS telemetry from +//! `bikecontrol_ble`. 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). + +use std::sync::Arc; + +use bikecontrol_core::profile::Profile; +use bikecontrol_core::types::{ + ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, +}; + +use crate::events::RideStatus; + +/// Rider intent, owned by the Tauri layer and read by the backend each tick. +#[derive(Debug, Clone)] +pub struct RideInputs { + pub status: RideStatus, + pub mode: ControlMode, + /// Base gradient in `ManualGrade` mode. + pub manual_gradient_pct: f32, + /// Trim applied on top of whatever the base gradient is (FR-4.2). + pub gradient_offset_pct: f32, + pub resistance_level: i16, + pub power_target_w: u16, + pub profile: Option>, + pub rider: RiderConfig, + pub limits: SafetyLimits, +} + +impl Default for RideInputs { + fn default() -> Self { + Self { + status: RideStatus::Idle, + mode: ControlMode::ManualGrade, + manual_gradient_pct: 0.0, + gradient_offset_pct: 0.0, + resistance_level: 20, + power_target_w: 200, + profile: None, + rider: RiderConfig::default(), + limits: SafetyLimits::default(), + } + } +} + +/// What a tick produced. +pub struct Tick { + pub snapshot: RideSnapshot, + /// Post-clamp target to transmit (SAF-3). `None` when the ride is not + /// running, so a paused ride never pushes a new load. + pub command: Option, +} + +pub trait RideBackend: Send + 'static { + /// Advance the ride by `dt_s`. + 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 new file mode 100644 index 0000000..b331778 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,423 @@ +//! Every intent the rider can express, as a Tauri command. +//! +//! Commands are *intents*, not state changes the frontend has already made: +//! 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 bikecontrol_core::gpx::{self, SmoothingConfig}; +use bikecontrol_core::profile::Profile; +use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits}; +use tauri::{AppHandle, State}; + +use crate::devices::DeviceInfo; +use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus}; +use crate::profile_view::{self, ProfileView}; +use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState}; + +type Cmd = Result; + +// --------------------------------------------------------------------------- +// Ride state +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn ride_state(state: State<'_, AppState>) -> RideState { + state.lock().ride_state() +} + +#[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; + } + ack(&app, "start", None); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { + state.lock().inputs.status = RideStatus::Paused; + ack(&app, "pause", None); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { + state.lock().inputs.status = RideStatus::Running; + 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. +#[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 + }; + ack(&app, "toggle-pause", Some(format!("{status:?}"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance +/// before the session closes. +#[tauri::command] +pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { + state.lock().inputs.status = RideStatus::Finished; + crate::state::release_trainer(&app); + ack(&app, "stop", None); + emit_ride_state(&app); + notify(&app, Notice::info("Ride ended — trainer released to 0%")); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { + state.lock().reset_ride(); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +// --------------------------------------------------------------------------- +// Control modes and targets (§5.4) +// --------------------------------------------------------------------------- + +/// Mode cycle order, matching the on-screen control and Click face button A. +const MODE_CYCLE: [ControlMode; 4] = [ + ControlMode::ManualGrade, + ControlMode::Profile, + ControlMode::Resistance, + ControlMode::Erg, +]; + +#[tauri::command] +pub fn set_control_mode( + app: AppHandle, + state: State<'_, AppState>, + mode: ControlMode, +) -> Cmd { + { + let mut inner = state.lock(); + if mode == ControlMode::Profile && inner.inputs.profile.is_none() { + return Err("No profile loaded — load a GPX or YAML profile first".into()); + } + inner.inputs.mode = mode; + } + ack(&app, "mode", Some(format!("{mode:?}"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd { + let mode = { + let mut inner = state.lock(); + let has_profile = inner.inputs.profile.is_some(); + let current = inner.inputs.mode; + let start = MODE_CYCLE.iter().position(|m| *m == current).unwrap_or(0); + let mut chosen = current; + for step in 1..=MODE_CYCLE.len() { + let candidate = MODE_CYCLE[(start + step) % MODE_CYCLE.len()]; + if candidate == ControlMode::Profile && !has_profile { + continue; + } + chosen = candidate; + break; + } + inner.inputs.mode = chosen; + chosen + }; + ack(&app, "mode", Some(format!("{mode:?}"))); + 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( + app: AppHandle, + state: State<'_, AppState>, + delta_pct: f32, +) -> Cmd { + let step = delta_pct.clamp(-2.0, 2.0); + { + let mut inner = state.lock(); + match inner.inputs.mode { + ControlMode::ManualGrade => inner.inputs.manual_gradient_pct += step, + // In profile mode the nudge trims on top of the profile's gradient. + _ => inner.inputs.gradient_offset_pct += step, + } + let limits = inner.inputs.limits; + inner.inputs.manual_gradient_pct = inner + .inputs + .manual_gradient_pct + .clamp(limits.min_gradient_pct, limits.max_gradient_pct); + inner.inputs.gradient_offset_pct = inner.inputs.gradient_offset_pct.clamp(-10.0, 10.0); + } + ack(&app, "gradient", Some(format!("{step:+.1}%"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn set_gradient(app: AppHandle, state: State<'_, AppState>, percent: f32) -> Cmd { + { + let mut inner = state.lock(); + let limits = inner.inputs.limits; + inner.inputs.manual_gradient_pct = + percent.clamp(limits.min_gradient_pct, limits.max_gradient_pct); + } + ack(&app, "gradient", Some(format!("{percent:.1}%"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn reset_gradient(app: AppHandle, state: State<'_, AppState>) -> Cmd { + { + let mut inner = state.lock(); + inner.inputs.gradient_offset_pct = 0.0; + inner.inputs.manual_gradient_pct = 0.0; + } + ack(&app, "gradient-reset", None); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn set_target_resistance( + app: AppHandle, + state: State<'_, AppState>, + level: i16, +) -> Cmd { + { + let mut inner = state.lock(); + let limits = inner.inputs.limits; + inner.inputs.resistance_level = level.clamp(limits.min_resistance, limits.max_resistance); + } + ack(&app, "resistance", Some(format!("{level}"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16) -> Cmd { + { + let mut inner = state.lock(); + let limits = inner.inputs.limits; + inner.inputs.power_target_w = watts.clamp(limits.min_power_w, limits.max_power_w); + } + ack(&app, "power", Some(format!("{watts} W"))); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[tauri::command] +pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd { + let lap = state.lock().mark_lap(); + let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap); + ack(&app, "lap", Some(format!("Lap {}", lap.index))); + emit_ride_state(&app); + Ok(lap) +} + +// --------------------------------------------------------------------------- +// Rider and safety configuration +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn rider_config(state: State<'_, AppState>) -> RiderConfig { + state.lock().inputs.rider +} + +#[tauri::command] +pub fn set_rider_config( + app: AppHandle, + state: State<'_, AppState>, + config: RiderConfig, +) -> Cmd { + if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 { + return Err("Rider and bike mass must be positive and realistic".into()); + } + state.lock().inputs.rider = config; + emit_ride_state(&app); + Ok(config) +} + +#[tauri::command] +pub fn safety_limits(state: State<'_, AppState>) -> SafetyLimits { + state.lock().inputs.limits +} + +#[tauri::command] +pub fn set_safety_limits( + app: AppHandle, + state: State<'_, AppState>, + limits: SafetyLimits, +) -> Cmd { + if limits.min_gradient_pct >= limits.max_gradient_pct { + return Err("Gradient limits are inverted".into()); + } + state.lock().inputs.limits = limits; + emit_ride_state(&app); + Ok(limits) +} + +// --------------------------------------------------------------------------- +// Profiles (§5.5, §5.6) +// --------------------------------------------------------------------------- + +fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result { + if is_gpx { + // FR-5.2/5.3: core smooths the elevation before differentiating and + // clamps the result. The defaults are the spec's defaults. + gpx::import(text, name, &SmoothingConfig::default()).map_err(|e| e.to_string()) + } else { + Profile::from_yaml(text).map_err(|e| e.to_string()) + } +} + +/// Load a profile from a path on disk. GPX is detected by extension, everything +/// else is treated as the YAML profile format. +#[tauri::command] +pub fn load_profile_from_path( + app: AppHandle, + state: State<'_, AppState>, + path: String, +) -> Cmd { + let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; + let stem = std::path::Path::new(&path) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Profile".into()); + let is_gpx = path.to_ascii_lowercase().ends_with(".gpx"); + let profile = parse_profile(&text, &stem, is_gpx)?; + let (view, geom) = profile_view::build(&profile, path); + state.lock().set_profile(profile, view.clone(), geom); + emit_ride_state(&app); + notify(&app, Notice::info(format!("Loaded profile “{}”", view.name))); + Ok(view) +} + +/// Load from text the frontend already has — used by the drop target, the +/// built-in samples and the profile editor. +#[tauri::command] +pub fn load_profile_from_text( + app: AppHandle, + state: State<'_, AppState>, + name: String, + text: String, + is_gpx: bool, +) -> Cmd { + let profile = parse_profile(&text, &name, is_gpx)?; + 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))); + Ok(view) +} + +/// Parse and preview without loading — the editor calls this on every keystroke +/// so errors surface as you type rather than when you press Ride. +#[tauri::command] +pub fn preview_profile_yaml(yaml: String) -> Cmd { + let profile = Profile::from_yaml(&yaml).map_err(|e| e.to_string())?; + Ok(profile_view::build(&profile, "editor").0) +} + +#[tauri::command] +pub fn clear_profile(app: AppHandle, state: State<'_, AppState>) -> Cmd { + state.lock().clear_profile(); + emit_ride_state(&app); + Ok(state.lock().ride_state()) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SampleProfile { + pub name: String, + pub summary: String, + /// YAML profile source, or GPX XML when `is_gpx`. + pub text: String, + pub is_gpx: bool, +} + +/// Profiles shipped with the app, so there is always something to ride. +#[tauri::command] +pub fn sample_profiles() -> Vec { + crate::samples::all() +} + +// --------------------------------------------------------------------------- +// Devices (FR-1, FR-9.1–9.3) +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn device_list(state: State<'_, AppState>) -> DeviceList { + let inner = state.lock(); + DeviceList { scanning: inner.devices.scanning, devices: inner.devices.list() } +} + +#[tauri::command] +pub fn start_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> { + state.lock().devices.start_scan(); + emit_devices(&app); + Ok(()) +} + +#[tauri::command] +pub fn stop_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> { + state.lock().devices.stop_scan(); + emit_devices(&app); + Ok(()) +} + +#[tauri::command] +pub fn connect_device( + app: AppHandle, + state: State<'_, AppState>, + device_id: String, +) -> Cmd { + let info = state.lock().devices.connect(&device_id)?; + emit_devices(&app); + Ok(info) +} + +#[tauri::command] +pub fn disconnect_device( + app: AppHandle, + state: State<'_, AppState>, + device_id: String, +) -> Cmd { + let info = state.lock().devices.disconnect(&device_id)?; + emit_devices(&app); + notify(&app, Notice::info(format!("Disconnected {}", info.name))); + Ok(info) +} + +#[tauri::command] +pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: String) -> Cmd<()> { + state.lock().devices.forget(&device_id)?; + emit_devices(&app); + Ok(()) +} + +/// True once a trainer has FTMS control. The ride screen uses this to warn that +/// it is showing simulated data (FR-9.3). +#[tauri::command] +pub fn trainer_controllable(state: State<'_, AppState>) -> bool { + state.lock().devices.trainer_controllable() +} diff --git a/src-tauri/src/derive.rs b/src-tauri/src/derive.rs new file mode 100644 index 0000000..72a21d2 --- /dev/null +++ b/src-tauri/src/derive.rs @@ -0,0 +1,291 @@ +//! Derived ride figures: ETA, distance remaining, rolling averages. +//! +//! All of this is computed in Rust, not the frontend (§4.3). `RideSnapshot` is +//! a frozen contract in `crates/core` and does not carry any of it, so it rides +//! alongside the snapshot in a [`RideFrame`]. +//! +//! **ETA (FR-9.15).** The rule that matters: never derive it from +//! instantaneous speed. Trainer speed swings several km/h between samples and +//! an ETA computed from it flickers uselessly. Three cases: +//! +//! * **Time-based profile** — remaining time is *known*. No estimation. +//! * **Distance-based profile** — remaining distance over a 45-second rolling +//! mean speed. When the rider stops, the last good ETA is *held* rather than +//! diverging to infinity, and flagged as held so the UI can dim it. +//! * **Looping profile** — no finish exists. Report lap position instead of a +//! number that would be a lie. + +use std::collections::VecDeque; + +use bikecontrol_core::profile::Profile; +use bikecontrol_core::types::RideSnapshot; +use serde::Serialize; + +use crate::profile_view::{self, ProfileGeometry, XUnit}; + +/// Rolling mean window for the speed that feeds ETA. Long enough to survive a +/// soft-pedal over a rise, short enough to react to a real change of pace. +const SPEED_WINDOW_S: f64 = 45.0; +/// Rolling mean window for the displayed power (FR-9.11). +pub const POWER_WINDOW_S: f64 = 10.0; +/// Window for the normalised-power rolling mean (§12 glossary). +const NP_WINDOW_S: f64 = 30.0; +/// Below this the rider is not really moving; hold the last ETA. +const MIN_ETA_SPEED_KPH: f32 = 2.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EtaKind { + /// Time-based profile: the remaining time is exact, not estimated. + Exact, + /// Distance-based: remaining distance over smoothed speed. + Estimated, + /// Rider has stopped; showing the last good estimate. + Held, + /// Looping profile — there is no finish. + Looping, + /// No profile, or one with no finite extent. + Unavailable, +} + +/// Everything the ride screen shows that is not in the frozen `RideSnapshot`. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Derived { + // --- route (the primary readouts) --------------------------------------- + pub eta_kind: EtaKind, + /// Seconds to the finish. `None` when unavailable or looping. + pub time_remaining_s: Option, + pub distance_total_m: Option, + pub distance_remaining_m: Option, + /// Current altitude on the route, metres. + pub elevation_m: Option, + pub ascent_remaining_m: Option, + /// Position on the profile's own axis (seconds or metres). + pub position_x: f64, + pub axis_unit: XUnit, + pub axis_total: f64, + /// Which lap of a looping profile, 1-based. + pub loop_index: Option, + + // --- motion -------------------------------------------------------------- + /// 45-second rolling mean. This is what drives ETA; it is also the honest + /// number to show a rider, because instantaneous speed is noise. + pub smoothed_speed_kph: f32, + + // --- effort (secondary) --------------------------------------------------- + /// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11). + pub rolling_power_w: f32, + pub rolling_power_window_s: f64, + pub avg_power_w: f32, + pub max_power_w: i16, + pub normalised_power_w: Option, + pub avg_cadence_rpm: f32, + pub energy_kj: f32, +} + +/// Rolling windows. One instance lives in the app state for the whole ride. +pub struct Deriver { + speed: VecDeque<(f64, f32)>, + power: VecDeque<(f64, f32)>, + np: VecDeque<(f64, f32)>, + np_fourth_sum: f64, + np_n: u64, + power_sum: f64, + power_n: u64, + cadence_sum: f64, + cadence_n: u64, + max_power_w: i16, + energy_kj: f32, + last_elapsed_s: f64, + /// Last ETA that was computed from real movement (FR-9.15, hold-on-stop). + last_eta_s: Option, +} + +impl Default for Deriver { + fn default() -> Self { + Self { + speed: VecDeque::new(), + power: VecDeque::new(), + np: VecDeque::new(), + np_fourth_sum: 0.0, + np_n: 0, + power_sum: 0.0, + power_n: 0, + cadence_sum: 0.0, + cadence_n: 0, + max_power_w: 0, + energy_kj: 0.0, + last_elapsed_s: 0.0, + last_eta_s: None, + } + } +} + +fn push_window(window: &mut VecDeque<(f64, f32)>, t: f64, v: f32, span: f64) { + window.push_back((t, v)); + while let Some((t0, _)) = window.front() { + if t - t0 > span { + window.pop_front(); + } else { + break; + } + } +} + +fn mean(window: &VecDeque<(f64, f32)>) -> f32 { + if window.is_empty() { + return 0.0; + } + window.iter().map(|(_, v)| *v as f64).sum::() as f32 / window.len() as f32 +} + +impl Deriver { + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Fold one snapshot in and produce the derived figures. + pub fn update( + &mut self, + snapshot: &RideSnapshot, + running: bool, + profile: Option<&Profile>, + geom: Option<&ProfileGeometry>, + ) -> Derived { + let t = snapshot.elapsed_ms as f64 / 1000.0; + let dt = (t - self.last_elapsed_s).max(0.0); + self.last_elapsed_s = t; + + let power = snapshot.telemetry.power_w.unwrap_or(0) as f32; + let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0); + + push_window(&mut self.speed, t, snapshot.virtual_speed_kph, SPEED_WINDOW_S); + push_window(&mut self.power, t, power, POWER_WINDOW_S); + push_window(&mut self.np, t, power, NP_WINDOW_S); + + if running { + self.power_sum += power as f64; + self.power_n += 1; + self.max_power_w = self.max_power_w.max(power as i16); + if cadence > 1.0 { + self.cadence_sum += cadence as f64; + self.cadence_n += 1; + } + self.energy_kj += power * dt as f32 / 1000.0; + // Normalised power: 30 s rolling mean, raised to the fourth, + // averaged, fourth root. + let rolling = mean(&self.np) as f64; + self.np_fourth_sum += rolling.powi(4); + self.np_n += 1; + } + + let smoothed_speed_kph = mean(&self.speed); + + // ---- route position and ETA ---------------------------------------- + let mut eta_kind = EtaKind::Unavailable; + let mut time_remaining_s = None; + let mut distance_total_m = None; + let mut distance_remaining_m = None; + let mut elevation_m = None; + let mut ascent_remaining_m = None; + let mut loop_index = None; + let mut position_x = 0.0; + let mut axis_unit = XUnit::Seconds; + let mut axis_total = 0.0; + + if let (Some(profile), Some(geom)) = (profile, geom) { + let elapsed_s = t; + let distance_m = snapshot.virtual_distance_m; + position_x = profile_view::position_x(geom, elapsed_s, distance_m); + axis_unit = geom.x_unit; + axis_total = geom.total_x; + elevation_m = geom.elevation_at(position_x); + ascent_remaining_m = geom.ascent_remaining(position_x); + distance_total_m = geom.total_metres; + + if profile.looping { + eta_kind = EtaKind::Looping; + if geom.total_x > 0.0 { + let laps = match geom.x_unit { + XUnit::Metres => distance_m / geom.total_x, + XUnit::Seconds => elapsed_s / geom.total_x, + }; + loop_index = Some(laps.floor() as u32 + 1); + } + if let Some(total) = geom.total_metres { + distance_remaining_m = Some((total - position_x).max(0.0)); + } + } else { + match (geom.total_seconds, geom.total_metres) { + // Time-based: remaining time is known exactly. + (Some(total_s), None) => { + eta_kind = EtaKind::Exact; + time_remaining_s = Some((total_s - elapsed_s).max(0.0)); + } + // Distance-based (or mixed): estimate from smoothed speed. + (_, Some(total_m)) => { + let remaining = (total_m - distance_m).max(0.0); + distance_remaining_m = Some(remaining); + if smoothed_speed_kph >= MIN_ETA_SPEED_KPH { + let eta = remaining / (smoothed_speed_kph as f64 * 1000.0 / 3600.0); + self.last_eta_s = Some(eta); + eta_kind = EtaKind::Estimated; + time_remaining_s = Some(eta); + } else { + eta_kind = EtaKind::Held; + time_remaining_s = self.last_eta_s; + } + // A mixed profile also has a hard time limit; take + // whichever finishes first. + if let Some(total_s) = geom.total_seconds { + let by_time = (total_s - elapsed_s).max(0.0); + time_remaining_s = + Some(time_remaining_s.map_or(by_time, |e: f64| e.min(by_time))); + } + } + (None, None) => {} + } + } + } + + Derived { + eta_kind, + time_remaining_s, + distance_total_m, + distance_remaining_m, + elevation_m, + ascent_remaining_m, + position_x, + axis_unit, + axis_total, + loop_index, + smoothed_speed_kph, + rolling_power_w: mean(&self.power), + rolling_power_window_s: POWER_WINDOW_S, + avg_power_w: if self.power_n == 0 { + 0.0 + } else { + (self.power_sum / self.power_n as f64) as f32 + }, + max_power_w: self.max_power_w, + normalised_power_w: (self.np_n > 30) + .then(|| (self.np_fourth_sum / self.np_n as f64).powf(0.25) as f32), + avg_cadence_rpm: if self.cadence_n == 0 { + 0.0 + } else { + (self.cadence_sum / self.cadence_n as f64) as f32 + }, + energy_kj: self.energy_kj, + } + } +} + +/// What lands on the `ride://snapshot` channel: the frozen core snapshot plus +/// the derived view data that cannot live in it. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RideFrame { + pub snapshot: RideSnapshot, + pub derived: Derived, +} diff --git a/src-tauri/src/devices.rs b/src-tauri/src/devices.rs new file mode 100644 index 0000000..a73679a --- /dev/null +++ b/src-tauri/src/devices.rs @@ -0,0 +1,333 @@ +//! Device discovery and connection state (FR-1, FR-9.1–9.3). +//! +//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted +//! set of peripherals that appear over a few seconds, with RSSI that drifts and +//! connection state machines that take realistic time to settle. It exists so +//! the connection screen can be built and judged today. +//! +//! The important behaviour it models — and the reason it is not just a static +//! list — is that **BLE connection and FTMS control acquisition are separate +//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and +//! it can sit at `Connected` indefinitely if the control point is refused. +//! +//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and +//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI +//! renders does not change. + +use std::collections::HashSet; + +use bikecontrol_core::types::ConnectionState; +use serde::{Deserialize, Serialize}; + +/// What we think a peripheral is, from its advertised services and +/// manufacturer data (FR-1.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DeviceKind { + /// Advertises FTMS (`0x1826`). + Trainer, + /// Zwift custom service, manufacturer type byte identifying the left pod. + ClickLeft, + ClickRight, + HeartRate, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + pub id: String, + pub name: String, + pub address: String, + /// dBm. Roughly −40 (touching) to −95 (barely there). + pub rssi: i16, + pub kind: DeviceKind, + pub state: ConnectionState, + /// FTMS control point acquired (FR-2.1). **Connected ≠ controllable** + /// (FR-9.3) — this is deliberately a separate field, not a state. + pub control_acquired: bool, + pub services: Vec, + /// Previously paired, so it would auto-connect on launch (FR-1.5). + pub remembered: bool, + pub battery_pct: Option, + /// Zwift unlock validity for Click pods (FR-3.9). `None` for other kinds. + pub unlock_expires_in_s: Option, + /// Human-readable failure, shown verbatim in the UI (FR-9.2). + pub error: Option, +} + +/// Outcome of one registry tick. +pub struct PollResult { + pub changed: bool, + /// Devices whose connection state settled this tick. + pub transitions: Vec, +} + +/// A scripted peripheral in the mock environment. +struct Simulated { + info: DeviceInfo, + /// Ticks after scan start before it shows up. Models A-4: the trainer only + /// advertises once you pedal, the Click once you press a button. + appears_after: u32, + /// Ticks remaining in the current transition, and where it lands. + pending: Option<(u32, ConnectionState, bool)>, + visible: bool, +} + +pub struct DeviceRegistry { + devices: Vec, + forgotten: HashSet, + pub scanning: bool, + ticks: u32, + rng: u64, +} + +/// How long each mock transition takes, in registry ticks (2 Hz). +const CONNECT_TICKS: u32 = 3; +const CONTROL_TICKS: u32 = 3; + +impl Default for DeviceRegistry { + fn default() -> Self { + Self::new() + } +} + +impl DeviceRegistry { + pub fn new() -> Self { + Self { + devices: catalogue(), + forgotten: HashSet::new(), + scanning: false, + ticks: 0, + rng: 0xDEAD_BEEF_CAFE_F00D, + } + } + + fn rand(&mut self) -> f32 { + let mut x = self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.rng = x; + ((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32 + } + + pub fn start_scan(&mut self) { + self.scanning = true; + self.ticks = 0; + for d in &mut self.devices { + if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) { + d.info.state = ConnectionState::Scanning; + } + } + } + + pub fn stop_scan(&mut self) { + self.scanning = false; + for d in &mut self.devices { + if d.info.state == ConnectionState::Scanning { + d.info.state = ConnectionState::Idle; + } + } + } + + /// Advance the mock. Reports whether the list changed at all, and which + /// devices crossed a connection-state boundary this tick. + pub fn poll(&mut self) -> PollResult { + let mut changed = false; + let mut transitions = Vec::new(); + if self.scanning { + self.ticks += 1; + for i in 0..self.devices.len() { + let appears = self.devices[i].appears_after; + if !self.devices[i].visible && self.ticks >= appears { + self.devices[i].visible = true; + changed = true; + } + if self.devices[i].visible { + let jitter = (self.rand() * 6.0) as i16 - 3; + let base = self.devices[i].info.rssi; + let next = (base + jitter).clamp(-95, -38); + if next != base { + self.devices[i].info.rssi = next; + changed = true; + } + } + } + } + + for d in &mut self.devices { + if let Some((remaining, target, control)) = d.pending.take() { + if remaining <= 1 { + d.info.state = target.clone(); + d.info.control_acquired = control; + if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer { + // Connected, now go after the FTMS control point. + d.pending = + Some((CONTROL_TICKS, ConnectionState::Controlling, true)); + } + transitions.push(d.info.clone()); + changed = true; + } else { + d.pending = Some((remaining - 1, target, control)); + } + } + } + PollResult { changed, transitions } + } + + pub fn list(&self) -> Vec { + self.devices + .iter() + .filter(|d| d.visible && !self.forgotten.contains(&d.info.id)) + .map(|d| d.info.clone()) + .collect() + } + + pub fn get(&self, id: &str) -> Option { + self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone()) + } + + pub fn connect(&mut self, id: &str) -> Result { + let device = self + .devices + .iter_mut() + .find(|d| d.info.id == id) + .ok_or_else(|| format!("no such device: {id}"))?; + if device.info.state == ConnectionState::Controlling { + return Err(format!("{} is already connected", device.info.name)); + } + device.info.error = None; + device.info.state = ConnectionState::Connecting; + device.info.remembered = true; + device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false)); + Ok(device.info.clone()) + } + + pub fn disconnect(&mut self, id: &str) -> Result { + let device = self + .devices + .iter_mut() + .find(|d| d.info.id == id) + .ok_or_else(|| format!("no such device: {id}"))?; + device.pending = None; + device.info.control_acquired = false; + device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle }; + Ok(device.info.clone()) + } + + pub fn forget(&mut self, id: &str) -> Result<(), String> { + let device = self + .devices + .iter_mut() + .find(|d| d.info.id == id) + .ok_or_else(|| format!("no such device: {id}"))?; + device.pending = None; + device.info.remembered = false; + device.info.control_acquired = false; + device.info.state = ConnectionState::Idle; + device.visible = false; + self.forgotten.insert(id.to_string()); + Ok(()) + } + + /// True once a trainer is connected *and* controllable — the precondition + /// for a real ride (FR-2.1). + pub fn trainer_controllable(&self) -> bool { + self.devices + .iter() + .any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired) + } +} + +fn device( + id: &str, + name: &str, + address: &str, + rssi: i16, + kind: DeviceKind, + services: &[&str], + appears_after: u32, +) -> Simulated { + Simulated { + info: DeviceInfo { + id: id.into(), + name: name.into(), + address: address.into(), + rssi, + kind, + state: ConnectionState::Idle, + control_acquired: false, + services: services.iter().map(|s| s.to_string()).collect(), + remembered: false, + battery_pct: match kind { + DeviceKind::ClickLeft => Some(78), + DeviceKind::ClickRight => Some(64), + DeviceKind::HeartRate => Some(91), + _ => None, + }, + unlock_expires_in_s: match kind { + DeviceKind::ClickLeft => Some(0), + DeviceKind::ClickRight => Some(41_400), + _ => None, + }, + error: None, + }, + appears_after, + pending: None, + visible: false, + } +} + +/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer +/// takes ~2 s to appear and the pods ~4–6 s — long enough that the "wake it by +/// pedalling" prompt (FR-1.8) is actually visible. +fn catalogue() -> Vec { + vec![ + device( + "d100-1", + "Van Rysel D100", + "E4:2B:11:9A:03:7C", + -54, + DeviceKind::Trainer, + &["0x1826 Fitness Machine", "0x180A Device Information"], + 4, + ), + device( + "click-l", + "Zwift Click (left)", + "C0:1A:77:12:4E:01", + -63, + DeviceKind::ClickLeft, + &["00000001-19CA-4651-86E5-FA29DCDD09D1"], + 9, + ), + device( + "click-r", + "Zwift Click (right)", + "C0:1A:77:12:4E:02", + -61, + DeviceKind::ClickRight, + &["00000001-19CA-4651-86E5-FA29DCDD09D1"], + 11, + ), + device( + "hrm-1", + "Wahoo TICKR", + "D9:44:0B:31:88:2A", + -71, + DeviceKind::HeartRate, + &["0x180D Heart Rate"], + 14, + ), + device( + "unknown-1", + "(unnamed peripheral)", + "7F:22:C4:08:19:E3", + -88, + DeviceKind::Unknown, + &[], + 17, + ), + ] +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..e7760ea --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,119 @@ +//! Event channel from Rust to the webview. +//! +//! The frontend is a *view* (§4.3): it never computes ride state, it renders +//! what arrives here. Every event name is declared once, in this module, and +//! mirrored in `ui/src/lib/events.ts`. + +use bikecontrol_core::types::{ConnectionState, ControlMode, ControlTarget}; +use serde::Serialize; + +use crate::devices::DeviceInfo; +use crate::profile_view::ProfileView; + +/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`]. +pub const RIDE_SNAPSHOT: &str = "ride://snapshot"; +/// Low-frequency ride state: status, mode, targets, laps, loaded profile. +pub const RIDE_STATE: &str = "ride://state"; +/// A lap marker was inserted (FR-3.19 / FR-8.7). +pub const RIDE_LAP: &str = "ride://lap"; +/// The full device list changed (FR-9.1). +pub const DEVICES_UPDATED: &str = "devices://updated"; +/// One device changed connection or control state (FR-1.7, FR-9.3). +pub const DEVICE_CONNECTION: &str = "devices://connection"; +/// User-facing message: confirmation, warning or error (FR-9.2). +pub const APP_NOTICE: &str = "app://notice"; +/// Acknowledgement that an input registered, so the UI can flash (FR-9.9). +pub const INPUT_ACK: &str = "app://input-ack"; + +/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but +/// serialisable across the IPC boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RideStatus { + Idle, + Running, + Paused, + Finished, +} + +/// Everything the ride screen needs that is *not* in a `RideSnapshot`. +/// Emitted on change, not on a timer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RideState { + pub status: RideStatus, + pub mode: ControlMode, + pub target: Option, + /// Manual gradient trim on top of the profile's base gradient (FR-4.2). + pub gradient_offset_pct: f32, + pub manual_gradient_pct: f32, + pub resistance_level: i16, + pub power_target_w: u16, + pub lap: u32, + pub laps: Vec, + pub profile: Option, + /// Which backend is driving the ride — `"mock"` until `crates/ble` lands. + pub source: &'static str, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LapSummary { + pub index: u32, + pub elapsed_ms: u64, + pub distance_m: f64, + pub avg_power_w: f32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionEvent { + pub device_id: String, + pub state: ConnectionState, + /// FTMS control point acquired. Connected is *not* controllable (FR-9.3). + pub control_acquired: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum NoticeLevel { + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Notice { + pub level: NoticeLevel, + pub message: String, +} + +impl Notice { + pub fn info(message: impl Into) -> Self { + Self { level: NoticeLevel::Info, message: message.into() } + } + pub fn warn(message: impl Into) -> Self { + Self { level: NoticeLevel::Warn, message: message.into() } + } + pub fn error(message: impl Into) -> Self { + Self { level: NoticeLevel::Error, message: message.into() } + } +} + +/// Confirms an intent was accepted, so the UI can flash the control (FR-9.9). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InputAck { + pub action: String, + pub detail: Option, +} + +/// The full device list. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceList { + pub scanning: bool, + pub devices: Vec, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..8328f17 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,93 @@ +//! BikeControl desktop shell. +//! +//! This crate is *only* wiring: it owns the ride loop, exposes intents as Tauri +//! commands, and pushes state to the webview as events. The ride logic proper +//! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` — the +//! webview reaches neither directly (§4.3). + +pub mod backend; +pub mod commands; +pub mod derive; +pub mod devices; +pub mod events; +pub mod mock; +pub mod profile_view; +pub mod samples; +pub mod session_backend; +pub mod state; + +use tauri::{Manager, RunEvent, WindowEvent}; + +use crate::state::AppState; + +pub fn run() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into()), + ) + .init(); + + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(AppState::new()) + .invoke_handler(tauri::generate_handler![ + // ride lifecycle + commands::ride_state, + commands::start_ride, + commands::pause_ride, + commands::resume_ride, + commands::toggle_pause, + commands::stop_ride, + commands::reset_ride, + // control modes and targets + commands::set_control_mode, + commands::cycle_control_mode, + commands::nudge_gradient, + commands::set_gradient, + commands::reset_gradient, + commands::set_target_resistance, + commands::set_target_power, + commands::mark_lap, + // configuration + commands::rider_config, + commands::set_rider_config, + commands::safety_limits, + commands::set_safety_limits, + // profiles + commands::load_profile_from_path, + commands::load_profile_from_text, + commands::preview_profile_yaml, + commands::clear_profile, + commands::sample_profiles, + // devices + commands::device_list, + commands::start_scan, + commands::stop_scan, + commands::connect_device, + commands::disconnect_device, + commands::forget_device, + commands::trainer_controllable, + ]) + .setup(|app| { + let handle = app.handle().clone(); + // NFR-7: scanning starts immediately, not on a user click. + handle.state::().lock().devices.start_scan(); + state::spawn_ride_loop(handle.clone()); + state::spawn_device_loop(handle.clone()); + state::emit_devices(&handle); + state::emit_ride_state(&handle); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("failed to start BikeControl") + .run(|app, event| { + // SAF-2 — on any exit path, hand the trainer back at zero load. + if let RunEvent::ExitRequested { .. } = &event { + state::release_trainer(app); + } + if let RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } = &event { + state::release_trainer(app); + } + }); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..8a8fc34 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Hide the console window on Windows release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + bikecontrol_app_lib::run(); +} diff --git a/src-tauri/src/mock.rs b/src-tauri/src/mock.rs new file mode 100644 index 0000000..f61a570 --- /dev/null +++ b/src-tauri/src/mock.rs @@ -0,0 +1,222 @@ +//! A synthetic rider, so the UI can be built and judged before `crates/ble` +//! and `crates/core` are finished. +//! +//! It fabricates plausible power and cadence, then runs them through the §5.7 +//! physics equations to get virtual speed, distance and elevation gain. The +//! numbers are fake; their *shape* is not — power is deliberately noisy so the +//! rolling average (FR-9.11) has something to smooth, and speed responds to +//! gradient with inertia rather than snapping (FR-7.3). +//! +//! Replaced wholesale by a `RideSession`-backed implementation; see +//! [`crate::backend::RideBackend`]. + +use bikecontrol_core::profile::Position; +use bikecontrol_core::types::{ControlMode, ControlTarget, RideSnapshot, Telemetry}; + +use crate::backend::{RideBackend, RideInputs, Tick}; +use crate::events::RideStatus; + +/// Deterministic, dependency-free noise source. +struct Rng(u64); + +impl Rng { + fn next_f32(&mut self) -> f32 { + // xorshift64* + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + ((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32 + } + /// Symmetric noise in `[-1, 1]`. + fn bipolar(&mut self) -> f32 { + self.next_f32() * 2.0 - 1.0 + } +} + +pub struct MockBackend { + rng: Rng, + t_s: f64, + elapsed_ms: u64, + speed_ms: f32, + distance_m: f64, + elevation_gain_m: f32, + energy_kj: f32, + power_w: f32, + cadence: f32, + /// Slow effort wander, so the rider drifts rather than jitters. + effort: f32, + last_target: Option, +} + +impl Default for MockBackend { + fn default() -> Self { + Self { + rng: Rng(0x9E37_79B9_7F4A_7C15), + t_s: 0.0, + elapsed_ms: 0, + speed_ms: 0.0, + distance_m: 0.0, + elevation_gain_m: 0.0, + energy_kj: 0.0, + power_w: 0.0, + cadence: 0.0, + effort: 1.0, + last_target: None, + } + } +} + +impl MockBackend { + /// Base gradient before the rider's manual trim. + fn base_gradient(&self, inputs: &RideInputs) -> f32 { + match inputs.mode { + ControlMode::Profile => inputs + .profile + .as_deref() + .and_then(|p| p.sample(self.position())) + .and_then(|t| match t { + ControlTarget::Gradient { percent } => Some(percent), + _ => None, + }) + .unwrap_or(0.0), + _ => inputs.manual_gradient_pct, + } + } + + /// What the profile wants right now, whatever channel it drives. + fn profile_target(&self, inputs: &RideInputs) -> Option { + inputs.profile.as_deref().and_then(|p| p.sample(self.position())) + } + + fn position(&self) -> Position { + Position { elapsed_s: self.t_s, distance_m: self.distance_m } + } +} + +impl RideBackend for MockBackend { + fn source(&self) -> &'static str { + "mock" + } + + fn reset(&mut self) { + let rng = Rng(self.rng.0); + *self = Self { rng, ..Self::default() }; + } + + fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick { + let running = inputs.status == RideStatus::Running; + if running { + self.t_s += dt_s as f64; + self.elapsed_ms += (dt_s * 1000.0).round() as u64; + } + + let gradient_pct = self.base_gradient(inputs) + inputs.gradient_offset_pct; + + // ---- what we would send to the trainer ----------------------------- + let raw_target = match inputs.mode { + ControlMode::ManualGrade => ControlTarget::Gradient { percent: gradient_pct }, + ControlMode::Resistance => ControlTarget::Resistance { level: inputs.resistance_level }, + ControlMode::Erg => ControlTarget::Power { watts: inputs.power_target_w }, + ControlMode::Profile => match self.profile_target(inputs) { + Some(ControlTarget::Gradient { .. }) | None => { + ControlTarget::Gradient { percent: gradient_pct } + } + Some(other) => other, + }, + }; + // SAF-3: clamped at the point of transmission, whatever the source. + let target = inputs.limits.clamp(raw_target); + + // ---- synthesise a rider -------------------------------------------- + if running { + // Slow wander in effort plus a breathing cycle. + self.effort += (self.rng.bipolar() * 0.02 - (self.effort - 1.0) * 0.02) * dt_s; + self.effort = self.effort.clamp(0.75, 1.3); + let breathing = 1.0 + 0.06 * (self.t_s as f32 / 23.0).sin(); + + let demand = match target { + ControlTarget::Power { watts } => watts as f32, + ControlTarget::Resistance { level } => 90.0 + level as f32 * 3.2, + ControlTarget::Gradient { percent } => 165.0 + percent * 13.0, + }; + let wanted = (demand * self.effort * breathing).clamp(0.0, 800.0); + // First-order lag: legs do not step. + let tau = 2.5; + self.power_w += (wanted - self.power_w) * (dt_s / tau).min(1.0); + let noisy = (self.power_w + self.rng.bipolar() * 14.0).max(0.0); + + let cadence_wanted = (78.0 + 14.0 * self.effort - gradient_pct * 1.1).clamp(55.0, 105.0); + self.cadence += (cadence_wanted - self.cadence) * (dt_s / 1.8).min(1.0); + + self.energy_kj += noisy * dt_s / 1000.0; + + // ---- §5.7 physics ---------------------------------------------- + let cfg = inputs.rider; + let m = cfg.total_mass_kg(); + let g = 9.80665f32; + let theta = (gradient_pct / 100.0).atan(); + let v = self.speed_ms.max(0.5); + let f_prop = (noisy * cfg.drivetrain_efficiency) / v; + let f_grav = m * g * theta.sin(); + let f_roll = m * g * cfg.crr * theta.cos(); + let f_aero = 0.5 * cfg.air_density * cfg.cda * self.speed_ms * self.speed_ms; + let a = (f_prop - f_grav - f_roll - f_aero) / m; + self.speed_ms = (self.speed_ms + a * dt_s).max(0.0); + + let step = self.speed_ms as f64 * dt_s as f64; + self.distance_m += step; + if gradient_pct > 0.0 { + self.elevation_gain_m += (step * (gradient_pct as f64 / 100.0)) as f32; + } + } else { + // Coast down when paused so the readouts settle rather than freeze. + self.power_w *= 1.0 - (dt_s * 2.0).min(1.0); + self.cadence *= 1.0 - (dt_s * 2.0).min(1.0); + self.speed_ms *= 1.0 - (dt_s * 0.6).min(1.0); + } + + let power_out = if running { (self.power_w + self.rng.bipolar() * 12.0).max(0.0) } else { 0.0 }; + let telemetry = Telemetry { + elapsed_ms: self.elapsed_ms, + power_w: Some(power_out.round() as i16), + cadence_rpm: Some(if self.cadence < 2.0 { 0.0 } else { self.cadence }), + // Trainer-reported speed is deliberately a little off the virtual + // speed — it is diagnostic only (FR-7.5). + speed_kph: Some(self.speed_ms * 3.6 * 0.98), + resistance_level: match target { + ControlTarget::Resistance { level } => Some(level), + _ => None, + }, + heart_rate_bpm: Some((118.0 + power_out * 0.13).clamp(60.0, 195.0) as u8), + total_distance_m: Some(self.distance_m as u32), + total_energy_kcal: Some((self.energy_kj / 4.184) as u16), + }; + + let snapshot = RideSnapshot { + elapsed_ms: self.elapsed_ms, + telemetry, + virtual_speed_kph: self.speed_ms * 3.6, + virtual_distance_m: self.distance_m, + gradient_pct, + elevation_gain_m: self.elevation_gain_m, + mode: inputs.mode, + target: Some(target), + profile_progress: inputs + .profile + .as_deref() + .and_then(|p| p.total_extent().progress(self.position())), + }; + + let changed = self.last_target != Some(target); + self.last_target = Some(target); + + Tick { + snapshot, + // SAF-1/SAF-8: only transmit while running, and only on change — + // the real backend rate-limits to ≤4 Hz here too (FR-2.8). + command: (running && changed).then_some(target), + } + } +} diff --git a/src-tauri/src/profile_view.rs b/src-tauri/src/profile_view.rs new file mode 100644 index 0000000..a876aa1 --- /dev/null +++ b/src-tauri/src/profile_view.rs @@ -0,0 +1,330 @@ +//! The route, as the ride screen needs to draw it. +//! +//! `crates/core` owns profile *semantics* — `Profile::sample`, +//! `Profile::preview`, `Profile::total_extent`. This module owns the *view +//! model*: the elevation trace, the block breakdown, and the geometry needed to +//! place the current-position marker and answer "how much climbing is left". +//! +//! The route is the hero element of the ride screen, so this is the payload +//! that matters most. + +use bikecontrol_core::profile::{ + Block, Channel, Extent, Position, Profile, Waveform, +}; +use serde::Serialize; + +/// Which axis the profile is drawn against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum XUnit { + #[default] + Seconds, + Metres, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockSummary { + pub index: usize, + /// `constant` | `ramp` | `wave` | `segments` | `terrain` + pub kind: &'static str, + pub channel: Channel, + pub label: String, + pub start_x: f64, + pub end_x: f64, + pub unit: XUnit, +} + +/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileView { + pub name: String, + pub description: Option, + pub looping: bool, + /// Where it came from: a file path, a sample name, or `"editor"`. + pub source: String, + /// The channel the value series plots. + pub channel: Channel, + pub x_unit: XUnit, + pub total_x: f64, + /// Total ride duration, if the profile is measured in time. + pub total_seconds: Option, + /// Total ride distance, if the profile is measured in distance. + pub total_metres: Option, + /// `[x, value]` along the axis — gradient %, watts or resistance level. + pub series: Vec<[f64; 2]>, + /// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain, + /// integrated from gradient otherwise. This is the hero chart. + pub elevation: Option>, + pub elevation_min_m: Option, + pub elevation_max_m: Option, + pub total_ascent_m: Option, + pub blocks: Vec, + /// The profile as YAML, for the in-app editor. + pub yaml: String, +} + +/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never +/// serialised — the frontend gets answers, not arrays to search. +#[derive(Debug, Clone, Default)] +pub struct ProfileGeometry { + pub xs: Vec, + pub elevation: Vec, + /// Cumulative ascent at each sample, so "climbing remaining" is a + /// subtraction rather than a scan. + pub cum_ascent: Vec, + pub total_x: f64, + pub x_unit: XUnit, + pub looping: bool, + pub total_seconds: Option, + pub total_metres: Option, +} + +impl ProfileGeometry { + /// Elevation at a position on the axis, linearly interpolated. + pub fn elevation_at(&self, x: f64) -> Option { + interp(&self.xs, &self.elevation, x) + } + + /// Metres of climbing still to come from `x` to the end. + pub fn ascent_remaining(&self, x: f64) -> Option { + let total = *self.cum_ascent.last()?; + let done = interp(&self.xs, &self.cum_ascent, x)?; + Some((total - done).max(0.0)) + } + + pub fn total_ascent(&self) -> Option { + self.cum_ascent.last().copied() + } +} + +fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option { + if xs.is_empty() || xs.len() != ys.len() { + return None; + } + if x <= xs[0] { + return Some(ys[0]); + } + let last = xs.len() - 1; + if x >= xs[last] { + return Some(ys[last]); + } + let i = xs.partition_point(|v| *v <= x).clamp(1, last); + let (x0, x1) = (xs[i - 1], xs[i]); + let (y0, y1) = (ys[i - 1], ys[i]); + let span = x1 - x0; + Some(if span.abs() < f64::EPSILON { y1 } else { y0 + (y1 - y0) * ((x - x0) / span) as f32 }) +} + +const PREVIEW_SAMPLES: usize = 1400; + +fn extent_parts(extent: Extent) -> (f64, XUnit) { + match extent { + Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds), + Extent::Metres(m) => (m.max(0.0), XUnit::Metres), + } +} + +/// Build the view model and the geometry that goes with it. +pub fn build(profile: &Profile, source: impl Into) -> (ProfileView, ProfileGeometry) { + let extent = profile.total_extent(); + let x_unit = match (extent.metres, extent.seconds) { + (Some(m), Some(s)) => { + if m >= s { + XUnit::Metres + } else { + XUnit::Seconds + } + } + (Some(_), None) => XUnit::Metres, + _ => XUnit::Seconds, + }; + + let preview = profile.preview(PREVIEW_SAMPLES); + let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect(); + let total_x = series.last().map(|p| p[0]).unwrap_or(0.0); + + let channel = profile.blocks.first().map(|b| b.channel()).unwrap_or(Channel::Gradient); + + // Elevation. Prefer the real thing: a GPX import lands as a `Terrain` + // block that already carries surveyed elevation. Otherwise integrate the + // gradient, which is what a hand-authored segment profile implies anyway. + let mut geom = ProfileGeometry { + total_x, + x_unit, + looping: profile.looping, + total_seconds: extent.seconds, + total_metres: extent.metres, + ..Default::default() + }; + + let elevation: Option> = if channel == Channel::Gradient { + let surveyed = surveyed_elevation(profile); + let pairs = match surveyed { + Some(points) => points, + None if x_unit == XUnit::Metres => integrate_gradient(&series), + None => Vec::new(), + }; + if pairs.len() < 2 { + None + } else { + geom.xs = pairs.iter().map(|p| p[0]).collect(); + geom.elevation = pairs.iter().map(|p| p[1] as f32).collect(); + let mut cum = Vec::with_capacity(geom.elevation.len()); + let mut acc = 0.0f32; + let mut prev = geom.elevation[0]; + for e in &geom.elevation { + acc += (e - prev).max(0.0); + prev = *e; + cum.push(acc); + } + geom.cum_ascent = cum; + Some(pairs) + } + } else { + None + }; + + let (elevation_min_m, elevation_max_m) = match &geom.elevation { + e if e.is_empty() => (None, None), + e => ( + Some(e.iter().copied().fold(f32::INFINITY, f32::min)), + Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)), + ), + }; + + let mut blocks = Vec::with_capacity(profile.blocks.len()); + let mut cursor = 0.0f64; + for (index, block) in profile.blocks.iter().enumerate() { + let (span, unit) = extent_parts(block.extent()); + blocks.push(BlockSummary { + index, + kind: block_kind(block), + channel: block.channel(), + label: block_label(block), + start_x: cursor, + end_x: cursor + span, + unit, + }); + cursor += span; + } + + let view = ProfileView { + name: profile.name.clone(), + description: profile.description.clone(), + looping: profile.looping, + source: source.into(), + channel, + x_unit, + total_x, + total_seconds: extent.seconds, + total_metres: extent.metres, + series, + elevation, + elevation_min_m, + elevation_max_m, + total_ascent_m: geom.total_ascent(), + blocks, + yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(), + }; + (view, geom) +} + +/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks +/// join up rather than each restarting at zero distance. +fn surveyed_elevation(profile: &Profile) -> Option> { + let mut out: Vec<[f64; 2]> = Vec::new(); + let mut offset = 0.0f64; + let mut any = false; + for block in &profile.blocks { + let (span, _) = extent_parts(block.extent()); + if let Block::Terrain { points } = block { + any = true; + let base = points.first().map(|p| p.distance_m).unwrap_or(0.0); + for p in points { + out.push([offset + (p.distance_m - base), p.elevation_m as f64]); + } + } + offset += span; + } + any.then_some(out) +} + +/// Integrate gradient over distance to get a relative elevation trace. +fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> { + let mut elev = 0.0f64; + let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0); + series + .iter() + .map(|[x, grade]| { + elev += (x - prev_x).max(0.0) * (grade / 100.0); + prev_x = *x; + [*x, elev] + }) + .collect() +} + +/// Where the rider is on the preview axis right now. +pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 { + let raw = match geom.x_unit { + XUnit::Seconds => elapsed_s, + XUnit::Metres => distance_m, + }; + if geom.looping && geom.total_x > 0.0 { + raw.rem_euclid(geom.total_x) + } else { + raw.clamp(0.0, geom.total_x.max(0.0)) + } +} + +/// Convenience wrapper so callers do not have to build a `Position`. +pub fn position(elapsed_s: f64, distance_m: f64) -> Position { + Position { elapsed_s, distance_m } +} + +fn block_kind(block: &Block) -> &'static str { + match block { + Block::Constant { .. } => "constant", + Block::Ramp { .. } => "ramp", + Block::Wave { .. } => "wave", + Block::Segments { .. } => "segments", + Block::Terrain { .. } => "terrain", + } +} + +fn unit_suffix(channel: Channel) -> &'static str { + match channel { + Channel::Gradient => "%", + Channel::Resistance => "", + Channel::Power => " W", + } +} + +fn block_label(block: &Block) -> String { + let u = unit_suffix(block.channel()); + match block { + Block::Constant { value, .. } => format!("hold {value:.0}{u}"), + Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u} → {to:.0}{u}"), + Block::Wave { shape, midpoint, amplitude, repeats, .. } => format!( + "{} {:.0}{u} ±{:.0}{u} ×{:.0}", + match shape { + Waveform::Sine => "sine", + Waveform::Square => "square", + Waveform::Triangle => "triangle", + Waveform::Sawtooth => "sawtooth", + }, + midpoint, + amplitude, + repeats + ), + Block::Segments { segments } => { + let d: f64 = segments.iter().map(|s| s.distance_m).sum(); + format!("{} segments · {:.1} km", segments.len(), d / 1000.0) + } + Block::Terrain { points } => { + let d = points.last().map(|p| p.distance_m).unwrap_or(0.0); + format!("terrain · {:.1} km", d / 1000.0) + } + } +} diff --git a/src-tauri/src/samples.rs b/src-tauri/src/samples.rs new file mode 100644 index 0000000..1acc6aa --- /dev/null +++ b/src-tauri/src/samples.rs @@ -0,0 +1,115 @@ +//! Profiles shipped with the app, so there is always something to ride and the +//! YAML schema (`crates/core/src/profile.rs`) has worked examples. + +use crate::commands::SampleProfile; + +const OVER_UNDERS: &str = r#"name: Over-unders +description: Ten minutes up to threshold, then eight over-under cycles, then easy. +looping: false +blocks: + - type: ramp + channel: power + from: 110 + to: 210 + extent: { seconds: 600 } + - type: wave + channel: power + shape: sine + midpoint: 245 + amplitude: 45 + period: { seconds: 120 } + repeats: 8 + - type: constant + channel: power + value: 120 + extent: { seconds: 300 } +"#; + +const HILL_REPEATS: &str = r#"name: Hill repeats +description: Four kilometres of rolling terrain, looped. Gradient by distance. +looping: true +blocks: + - type: segments + segments: + - { distance_m: 600, gradient_pct: 1.0 } + - { distance_m: 900, gradient_pct: 6.5 } + - { distance_m: 300, gradient_pct: 9.0 } + - { distance_m: 500, gradient_pct: -3.0 } + - { distance_m: 700, gradient_pct: 4.0 } + - { distance_m: 1000, gradient_pct: -2.0 } +"#; + +const SAWTOOTH_GRADE: &str = r#"name: Sawtooth grade +description: A gradient sawtooth for shakedown testing — every 400 m ramps 0 to 8%. +looping: true +blocks: + - type: wave + channel: gradient + shape: sawtooth + midpoint: 4.0 + amplitude: 4.0 + period: { metres: 400 } + repeats: 20 +"#; + +const STEADY_ENDURANCE: &str = r#"name: Steady endurance +description: Ninety minutes at a fixed grade, with a gentle triangular trim. +looping: false +blocks: + - type: constant + channel: gradient + value: 2.0 + extent: { seconds: 900 } + - type: wave + channel: gradient + shape: triangle + midpoint: 3.0 + amplitude: 2.5 + period: { seconds: 600 } + repeats: 7 + - type: constant + channel: gradient + value: 0.0 + extent: { seconds: 600 } +"#; + +/// A real GPX, bundled so the route view has something to draw on first run. +const SAMPLE_CLIMB_GPX: &str = include_str!("../../testdata/sample-climb.gpx"); + +pub fn all() -> Vec { + let mut out: Vec = [OVER_UNDERS, HILL_REPEATS, SAWTOOTH_GRADE, STEADY_ENDURANCE] + .iter() + .map(|yaml| { + let (name, summary) = header(yaml); + SampleProfile { + name, + summary, + text: (*yaml).to_string(), + is_gpx: false, + } + }) + .collect(); + out.insert( + 0, + SampleProfile { + name: "Sample climb".into(), + summary: "3 km GPX with real GPS elevation noise — smoothed on import.".into(), + text: SAMPLE_CLIMB_GPX.to_string(), + is_gpx: true, + }, + ); + out +} + +fn header(yaml: &str) -> (String, String) { + let mut name = String::from("Profile"); + let mut summary = String::new(); + for line in yaml.lines() { + if let Some(rest) = line.strip_prefix("name: ") { + name = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("description: ") { + summary = rest.trim().to_string(); + } + } + (name, summary) +} diff --git a/src-tauri/src/session_backend.rs b/src-tauri/src/session_backend.rs new file mode 100644 index 0000000..957d9ca --- /dev/null +++ b/src-tauri/src/session_backend.rs @@ -0,0 +1,75 @@ +//! The real backend: `bikecontrol_core::RideSession` driven by trainer +//! telemetry. +//! +//! Compiled only under `--features real-session`, because +//! `RideSession::tick`/`snapshot` are still `todo!()` and would panic on the +//! first tick. Enabling the feature (and disabling `mock-ride`) is the whole +//! swap — nothing above [`crate::backend::RideBackend`] changes, and the +//! frontend does not change at all. +#![cfg(feature = "real-session")] + +use bikecontrol_core::session::{RideSession, SessionEvent}; +use bikecontrol_core::types::{ControlTarget, RideSnapshot, Telemetry}; +use tokio::sync::watch; + +use crate::backend::{RideBackend, RideInputs, Tick}; +use crate::events::RideStatus; + +pub struct SessionBackend { + session: RideSession, + /// Latest decoded Indoor Bike Data, published by `bikecontrol_ble`. + telemetry: watch::Receiver, + last_snapshot: Option, +} + +impl SessionBackend { + pub fn new(inputs: &RideInputs, telemetry: watch::Receiver) -> Self { + Self { + session: RideSession::new(inputs.rider, inputs.limits), + telemetry, + last_snapshot: None, + } + } +} + +impl RideBackend for SessionBackend { + fn source(&self) -> &'static str { + "ftms" + } + + fn reset(&mut self) { + self.session = RideSession::new(self.session.config, self.session.limits); + } + + fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick { + self.session.mode = inputs.mode; + if let Some(profile) = inputs.profile.as_deref() { + if self.session.profile().is_none() { + self.session.load_profile(profile.clone()); + } + } + match inputs.status { + RideStatus::Running => self.session.start(), + RideStatus::Paused => self.session.pause(), + _ => {} + } + + let telemetry = *self.telemetry.borrow(); + let mut command = None; + let mut snapshot = None; + for event in self.session.tick(telemetry, dt_s) { + match event { + SessionEvent::Command(target) => command = Some(target), + SessionEvent::Snapshot(s) => snapshot = Some(s), + SessionEvent::ProfileFinished | SessionEvent::Lap { .. } => {} + } + } + let snapshot = snapshot + .or(self.last_snapshot) + .unwrap_or_else(|| self.session.snapshot(telemetry)); + self.last_snapshot = Some(snapshot); + + let _: Option = command; + Tick { snapshot, command } + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..a46ac8c --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,276 @@ +//! Application state and the two background loops that drive the UI. +//! +//! §4.3: the control loop lives here, in Rust. The webview never computes +//! anything — it receives `RideSnapshot`s on a timer and sends intents back as +//! commands. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bikecontrol_core::profile::Profile; +use bikecontrol_core::types::{ControlTarget, RideSnapshot}; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::backend::{RideBackend, RideInputs}; +use crate::devices::DeviceRegistry; +use crate::events; +use crate::events::{ + ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus, +}; +use crate::derive::{Derived, Deriver, RideFrame}; +use crate::mock::MockBackend; +use crate::profile_view::{ProfileGeometry, ProfileView}; + +/// Snapshot push rate. FTMS notifies at 1–4 Hz (NFR-2); we publish at the top +/// of that range and the frontend interpolates nothing. +pub const TICK_HZ: u64 = 4; +const TICK_MS: u64 = 1000 / TICK_HZ; +/// Device list refresh, deliberately slower than the ride loop. +const SCAN_TICK_MS: u64 = 500; + +pub struct Inner { + pub inputs: RideInputs, + pub backend: Box, + pub devices: DeviceRegistry, + pub profile_view: Option, + /// Precomputed route geometry, kept Rust-side so the per-tick elevation and + /// ascent-remaining lookups are a binary search rather than a scan. + pub geometry: Option, + pub deriver: Deriver, + pub last_snapshot: Option, + pub last_derived: Option, + pub lap_index: u32, + pub laps: Vec, + lap_start_ms: u64, + lap_start_m: f64, + lap_power_sum: f64, + lap_power_n: u64, +} + +impl Inner { + fn new() -> Self { + Self { + inputs: RideInputs::default(), + backend: Box::new(MockBackend::default()), + devices: DeviceRegistry::new(), + profile_view: None, + geometry: None, + deriver: Deriver::default(), + last_snapshot: None, + last_derived: None, + lap_index: 1, + laps: Vec::new(), + lap_start_ms: 0, + lap_start_m: 0.0, + lap_power_sum: 0.0, + lap_power_n: 0, + } + } + + pub fn ride_state(&self) -> RideState { + RideState { + status: self.inputs.status, + mode: self.inputs.mode, + target: self.last_snapshot.and_then(|s| s.target), + gradient_offset_pct: self.inputs.gradient_offset_pct, + manual_gradient_pct: self.inputs.manual_gradient_pct, + resistance_level: self.inputs.resistance_level, + power_target_w: self.inputs.power_target_w, + lap: self.lap_index, + laps: self.laps.clone(), + profile: self.profile_view.clone(), + source: self.backend.source(), + } + } + + pub fn set_profile(&mut self, profile: Profile, view: ProfileView, geom: ProfileGeometry) { + self.inputs.profile = Some(Arc::new(profile)); + self.profile_view = Some(view); + self.geometry = Some(geom); + self.inputs.mode = bikecontrol_core::types::ControlMode::Profile; + } + + pub fn clear_profile(&mut self) { + self.inputs.profile = None; + self.profile_view = None; + self.geometry = None; + if self.inputs.mode == bikecontrol_core::types::ControlMode::Profile { + self.inputs.mode = bikecontrol_core::types::ControlMode::ManualGrade; + } + } + + /// Close the current lap and open the next (FR-3.19, FR-8.7). + pub fn mark_lap(&mut self) -> LapSummary { + let snapshot = self.last_snapshot; + let elapsed_ms = snapshot.map(|s| s.elapsed_ms).unwrap_or(0); + let distance_m = snapshot.map(|s| s.virtual_distance_m).unwrap_or(0.0); + let lap = LapSummary { + index: self.lap_index, + elapsed_ms: elapsed_ms.saturating_sub(self.lap_start_ms), + distance_m: distance_m - self.lap_start_m, + avg_power_w: if self.lap_power_n == 0 { + 0.0 + } else { + (self.lap_power_sum / self.lap_power_n as f64) as f32 + }, + }; + self.laps.push(lap); + self.lap_index += 1; + self.lap_start_ms = elapsed_ms; + self.lap_start_m = distance_m; + self.lap_power_sum = 0.0; + self.lap_power_n = 0; + lap + } + + pub fn reset_ride(&mut self) { + self.backend.reset(); + self.deriver.reset(); + self.last_derived = None; + self.inputs.status = RideStatus::Idle; + self.inputs.gradient_offset_pct = 0.0; + self.last_snapshot = None; + self.lap_index = 1; + self.laps.clear(); + self.lap_start_ms = 0; + self.lap_start_m = 0.0; + self.lap_power_sum = 0.0; + self.lap_power_n = 0; + } + + /// Fold a fresh snapshot into the lap accumulators and the rolling windows. + fn absorb(&mut self, snapshot: &RideSnapshot) -> Derived { + let running = self.inputs.status == RideStatus::Running; + if running { + if let Some(p) = snapshot.telemetry.power_w { + self.lap_power_sum += p as f64; + self.lap_power_n += 1; + } + } + let profile = self.inputs.profile.clone(); + let derived = + self.deriver + .update(snapshot, running, profile.as_deref(), self.geometry.as_ref()); + self.last_derived = Some(derived); + derived + } +} + +#[derive(Clone)] +pub struct AppState(Arc>); + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(Inner::new()))) + } + + /// Panics are impossible to recover from here, and a poisoned lock means + /// the ride loop already died — surface it rather than hide it. + pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// Emit the low-frequency ride state. Call after anything that changes mode, +/// target, status, laps or the loaded profile. +pub fn emit_ride_state(app: &AppHandle) { + let state = app.state::(); + let payload = state.lock().ride_state(); + let _ = app.emit(events::RIDE_STATE, payload); +} + +pub fn emit_devices(app: &AppHandle) { + let state = app.state::(); + let (scanning, devices) = { + let inner = state.lock(); + (inner.devices.scanning, inner.devices.list()) + }; + let _ = app.emit(events::DEVICES_UPDATED, DeviceList { scanning, devices }); +} + +pub fn notify(app: &AppHandle, notice: Notice) { + let _ = app.emit(events::APP_NOTICE, notice); +} + +/// Confirm an input registered so the UI can flash the control (FR-9.9). +pub fn ack(app: &AppHandle, action: &str, detail: Option) { + let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail }); +} + +/// The ride loop. One tick: advance the backend, publish the snapshot, and +/// transmit the (already clamped) target to the trainer. +pub fn spawn_ride_loop(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let dt_s = TICK_MS as f32 / 1000.0; + loop { + interval.tick().await; + let (frame, command) = { + let state = app.state::(); + let mut inner = state.lock(); + let inputs = inner.inputs.clone(); + let tick = inner.backend.tick(dt_s, &inputs); + inner.last_snapshot = Some(tick.snapshot); + let derived = inner.absorb(&tick.snapshot); + (RideFrame { snapshot: tick.snapshot, derived }, tick.command) + }; + let _ = app.emit(events::RIDE_SNAPSHOT, frame); + if let Some(target) = command { + transmit(&app, target); + } + } + }); +} + +/// Where the FTMS control-point write will go. Until `crates/ble` exists this +/// only logs — but every target already passed `SafetyLimits::clamp` before it +/// got here (SAF-3), so wiring the real write is a one-line change. +fn transmit(_app: &AppHandle, target: ControlTarget) { + tracing::debug!(?target, "control target (no trainer attached — mock backend)"); +} + +/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit. +pub fn release_trainer(app: &AppHandle) { + let state = app.state::(); + let limits = state.lock().inputs.limits; + let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 }); + tracing::info!(?safe, "releasing trainer (SAF-2)"); + transmit(app, safe); +} + +/// The scan loop: advances the device mock and pushes the list when it changes. +pub fn spawn_device_loop(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let result = { + let state = app.state::(); + let mut inner = state.lock(); + inner.devices.poll() + }; + for device in &result.transitions { + let _ = app.emit( + events::DEVICE_CONNECTION, + ConnectionEvent { + device_id: device.id.clone(), + state: device.state.clone(), + control_acquired: device.control_acquired, + error: device.error.clone(), + }, + ); + } + if result.changed { + emit_devices(&app); + } + } + }); +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e224f25 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + BikeControl + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..e377db4 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1625 @@ +{ + "name": "bikecontrol-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bikecontrol-ui", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.9.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vite": "^6.0.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz", + "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..94b0956 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "bikecontrol-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "check": "svelte-check --tsconfig ./tsconfig.json", + "preview": "vite preview" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vite": "^6.0.11" + }, + "allowScripts": { + "esbuild@0.25.12": true + } +} diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..5b384e9 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,230 @@ +/* + * Dark theme for a screen someone stares at while suffering (FR-9.12). + * + * Rules this stylesheet follows: + * - Near-black ground, bright data. Contrast comes from the numbers, not + * from boxes. + * - No borders unless they carry meaning. Structure comes from space. + * - Numbers are tabular so they do not jitter as digits change. + * - Type scales with the viewport: the primary readouts must be legible from + * a riding position about a metre away (FR-9.5). + */ + +:root { + --bg: #05070a; + --bg-lift: #0b0f15; + --hairline: #161c25; + + --ink: #f4f7fb; + --ink-soft: #9fb0c2; + --ink-dim: #5d6c7d; + --ink-faint: #313d4a; + + --route: #45d0ff; + --route-deep: #123a4d; + --climb: #ff9a3c; + --climb-deep: #3d2712; + --ok: #35d9a0; + --warn: #ffcf4a; + --bad: #ff5a52; + --power: #dfe8f3; + --power-raw: #3f4d5d; + + --gap: clamp(0.75rem, 1.4vw, 1.5rem); + --edge: clamp(1rem, 2.4vw, 2.75rem); + + color-scheme: dark; + font-synthesis: none; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: + 'Inter var', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', system-ui, sans-serif; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1, 'cv01' 1; + letter-spacing: -0.01em; + user-select: none; + -webkit-user-select: none; +} + +#app { + height: 100%; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +/* ---------- shared primitives ---------------------------------------- */ + +.label { + font-size: clamp(0.6rem, 0.72vw, 0.78rem); + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-dim); + white-space: nowrap; +} + +.hairline { + border-top: 1px solid var(--hairline); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.45em; + padding: 0.3em 0.7em; + border-radius: 999px; + background: var(--bg-lift); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink-soft); + white-space: nowrap; +} + +.dot { + width: 0.5em; + height: 0.5em; + border-radius: 50%; + background: currentColor; + flex: none; +} + +.tone-ok { + color: var(--ok); +} +.tone-warn { + color: var(--warn); +} +.tone-bad { + color: var(--bad); +} +.tone-idle { + color: var(--ink-dim); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: 0.62em 1.05em; + border-radius: 0.5rem; + background: var(--bg-lift); + color: var(--ink-soft); + font-size: 0.92rem; + font-weight: 600; + transition: + background 120ms ease, + color 120ms ease, + transform 90ms ease; +} + +.btn:hover { + background: #131a24; + color: var(--ink); +} + +.btn:active { + transform: translateY(1px); +} + +.btn.primary { + background: var(--route); + color: #04121a; +} + +.btn.primary:hover { + background: #6cdcff; + color: #04121a; +} + +.btn.ghost { + background: transparent; +} + +.btn.ghost:hover { + background: var(--bg-lift); +} + +.btn.danger:hover { + background: #2a1113; + color: var(--bad); +} + +.btn[disabled] { + opacity: 0.35; + pointer-events: none; +} + +.kbd { + display: inline-block; + min-width: 1.5em; + padding: 0.1em 0.35em; + border-radius: 0.28em; + background: #10161f; + color: var(--ink-dim); + font-size: 0.68rem; + font-weight: 700; + text-align: center; + letter-spacing: 0.02em; +} + +/* ---------- uPlot, restyled for the dark theme ------------------------ */ + +.uplot, +.u-wrap { + width: 100% !important; +} + +.u-title, +.u-legend { + display: none; +} + +.u-axis { + color: var(--ink-dim); +} + +.u-select { + background: rgba(69, 208, 255, 0.12); +} + +.u-cursor-x, +.u-cursor-y { + border-color: var(--ink-faint) !important; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: #1b232e; + border-radius: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/ui/src/components/Readout.svelte b/ui/src/components/Readout.svelte new file mode 100644 index 0000000..9187f22 --- /dev/null +++ b/ui/src/components/Readout.svelte @@ -0,0 +1,87 @@ + + +
+ {label} + + {value}{#if unit}{unit}{/if} + + {#if sub}{sub}{/if} +
+ + diff --git a/ui/src/components/RouteChart.svelte b/ui/src/components/RouteChart.svelte new file mode 100644 index 0000000..f03abd5 --- /dev/null +++ b/ui/src/components/RouteChart.svelte @@ -0,0 +1,196 @@ + + +
+
+ {#if !source} +
+ No route loaded +

Load a GPX or a YAML profile to see the terrain ahead.

+
+ {/if} + {#if source && !source.isElevation} + + {profile?.channel} profile — no elevation + + {/if} +
+ + diff --git a/ui/src/components/StreamChart.svelte b/ui/src/components/StreamChart.svelte new file mode 100644 index 0000000..a8271f7 --- /dev/null +++ b/ui/src/components/StreamChart.svelte @@ -0,0 +1,110 @@ + + +
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts new file mode 100644 index 0000000..b8f7d25 --- /dev/null +++ b/ui/src/lib/app.svelte.ts @@ -0,0 +1,116 @@ +/** + * Client-side view state. Everything here is either received from Rust or is + * purely presentational (which screen is showing, which toast is up). + */ +import { api, subscribe } from './bridge'; +import { History } from './history'; +import type { + DeviceList, + InputAck, + LapSummary, + Notice, + RideFrame, + RideState, + SampleProfile, +} from './types'; + +export type Screen = 'connect' | 'ride'; + +let toastSeq = 0; + +class AppStore { + screen = $state('connect'); + frame = $state(null); + ride = $state(null); + devices = $state({ scanning: false, devices: [] }); + samples = $state([]); + toasts = $state<(Notice & { id: number })[]>([]); + lastAck = $state<(InputAck & { at: number }) | null>(null); + lastLap = $state(null); + showHelp = $state(false); + showProfiles = $state(false); + /** Bumped on every snapshot so charts know to redraw without deep tracking. */ + revision = $state(0); + + /** Bounded chart history — raw power, rolling power. */ + readonly power = new History(2); + /** Bounded chart history — commanded gradient. */ + readonly grade = new History(1); + + private lastElapsed = -1; + + async init(): Promise { + const [ride, devices, samples] = await Promise.all([ + api.rideState(), + api.deviceList(), + api.sampleProfiles(), + ]); + this.ride = ride; + this.devices = devices; + this.samples = samples; + + await subscribe({ + onFrame: (f) => this.onFrame(f), + onRideState: (s) => { + this.ride = s; + }, + onDevices: (d) => { + this.devices = d; + }, + onLap: (l) => { + this.lastLap = l; + }, + onNotice: (n) => this.toast(n), + onInputAck: (a) => { + this.lastAck = { ...a, at: performance.now() }; + }, + }); + } + + private onFrame(f: RideFrame): void { + this.frame = f; + const t = f.snapshot.elapsed_ms / 1000; + // The ride clock only advances while running; a paused ride should not + // stack duplicate points onto the charts. + if (t > this.lastElapsed) { + this.lastElapsed = t; + this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]); + this.grade.push(t, [f.snapshot.gradient_pct]); + } else if (t < this.lastElapsed) { + this.clearHistory(); + this.lastElapsed = t; + } + this.revision++; + } + + clearHistory(): void { + this.power.clear(); + this.grade.clear(); + this.lastElapsed = -1; + } + + toast(n: Notice): void { + const entry = { ...n, id: ++toastSeq }; + this.toasts = [...this.toasts, entry]; + const ttl = n.level === 'error' ? 8000 : 4000; + setTimeout(() => { + this.toasts = this.toasts.filter((t) => t.id !== entry.id); + }, ttl); + } + + dismiss(id: number): void { + this.toasts = this.toasts.filter((t) => t.id !== id); + } + + /** Run a command and surface any rejection as a toast rather than silently. */ + async run(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + this.toast({ level: 'error', message: String(e) }); + return undefined; + } + } +} + +export const app = new AppStore(); diff --git a/ui/src/lib/bridge.ts b/ui/src/lib/bridge.ts new file mode 100644 index 0000000..9793372 --- /dev/null +++ b/ui/src/lib/bridge.ts @@ -0,0 +1,108 @@ +/** + * The only place that talks to Rust. + * + * Commands are intents; they never mutate local state directly. Truth comes + * back on the event channel (§4.3). + */ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { + ControlMode, + DeviceInfo, + DeviceList, + InputAck, + LapSummary, + Notice, + ProfileView, + RideFrame, + RideState, + RiderConfig, + SafetyLimits, + SampleProfile, +} from './types'; + +export const EVENTS = { + snapshot: 'ride://snapshot', + rideState: 'ride://state', + lap: 'ride://lap', + devices: 'devices://updated', + connection: 'devices://connection', + notice: 'app://notice', + inputAck: 'app://input-ack', +} as const; + +/** True when running inside the Tauri shell rather than a bare browser. */ +export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +async function call(cmd: string, args?: Record): Promise { + return invoke(cmd, args); +} + +export const api = { + // ride lifecycle + rideState: () => call('ride_state'), + start: () => call('start_ride'), + pause: () => call('pause_ride'), + resume: () => call('resume_ride'), + togglePause: () => call('toggle_pause'), + stop: () => call('stop_ride'), + reset: () => call('reset_ride'), + + // control modes and targets + setMode: (mode: ControlMode) => call('set_control_mode', { mode }), + cycleMode: () => call('cycle_control_mode'), + nudgeGradient: (deltaPct: number) => call('nudge_gradient', { deltaPct }), + setGradient: (percent: number) => call('set_gradient', { percent }), + resetGradient: () => call('reset_gradient'), + setResistance: (level: number) => call('set_target_resistance', { level }), + setPower: (watts: number) => call('set_target_power', { watts }), + markLap: () => call('mark_lap'), + + // configuration + riderConfig: () => call('rider_config'), + setRiderConfig: (config: RiderConfig) => call('set_rider_config', { config }), + safetyLimits: () => call('safety_limits'), + setSafetyLimits: (limits: SafetyLimits) => call('set_safety_limits', { limits }), + + // profiles + loadProfilePath: (path: string) => call('load_profile_from_path', { path }), + loadProfileText: (name: string, text: string, isGpx: boolean) => + call('load_profile_from_text', { name, text, isGpx }), + previewYaml: (yaml: string) => call('preview_profile_yaml', { yaml }), + clearProfile: () => call('clear_profile'), + sampleProfiles: () => call('sample_profiles'), + + // devices + deviceList: () => call('device_list'), + startScan: () => call('start_scan'), + stopScan: () => call('stop_scan'), + connect: (deviceId: string) => call('connect_device', { deviceId }), + disconnect: (deviceId: string) => call('disconnect_device', { deviceId }), + forget: (deviceId: string) => call('forget_device', { deviceId }), + trainerControllable: () => call('trainer_controllable'), +}; + +type Handlers = { + onFrame?: (f: RideFrame) => void; + onRideState?: (s: RideState) => void; + onLap?: (l: LapSummary) => void; + onDevices?: (d: DeviceList) => void; + onNotice?: (n: Notice) => void; + onInputAck?: (a: InputAck) => void; +}; + +/** Subscribe to the whole event channel. Returns a single unsubscribe. */ +export async function subscribe(h: Handlers): Promise { + const offs: UnlistenFn[] = []; + const add = async (name: string, fn?: (p: T) => void) => { + if (!fn) return; + offs.push(await listen(name, (e) => fn(e.payload))); + }; + await add(EVENTS.snapshot, h.onFrame); + await add(EVENTS.rideState, h.onRideState); + await add(EVENTS.lap, h.onLap); + await add(EVENTS.devices, h.onDevices); + await add(EVENTS.notice, h.onNotice); + await add(EVENTS.inputAck, h.onInputAck); + return () => offs.forEach((off) => off()); +} diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..d30df1f --- /dev/null +++ b/ui/src/lib/format.ts @@ -0,0 +1,104 @@ +/** Display formatting only. No ride logic lives in the frontend (§4.3). */ + +const EM_DASH = '—'; + +export function clock(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return EM_DASH; + const s = Math.max(0, Math.round(seconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; +} + +/** Shorter form for an ETA: "1h 04" / "42 min" / "38 s". */ +export function duration(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return EM_DASH; + const s = Math.max(0, Math.round(seconds)); + if (s >= 3600) { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h}h ${String(m).padStart(2, '0')}`; + } + if (s >= 60) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + return `${s}s`; +} + +/** Wall-clock time of arrival, e.g. "14:37". */ +export function finishAt(secondsFromNow: number | null | undefined): string { + if (secondsFromNow == null || !Number.isFinite(secondsFromNow)) return EM_DASH; + const t = new Date(Date.now() + secondsFromNow * 1000); + return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`; +} + +export function km(metres: number | null | undefined, digits = 2): string { + if (metres == null || !Number.isFinite(metres)) return EM_DASH; + return (metres / 1000).toFixed(digits); +} + +export function num(v: number | null | undefined, digits = 0): string { + if (v == null || !Number.isFinite(v)) return EM_DASH; + return v.toFixed(digits); +} + +export function signed(v: number | null | undefined, digits = 1): string { + if (v == null || !Number.isFinite(v)) return EM_DASH; + return `${v >= 0 ? '' : '−'}${Math.abs(v).toFixed(digits)}`; +} + +export function axisLabel(unit: 'seconds' | 'metres'): string { + return unit === 'metres' ? 'distance' : 'time'; +} + +export function axisValue(unit: 'seconds' | 'metres', x: number): string { + return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x); +} + +export function rssiBars(rssi: number): number { + if (rssi >= -55) return 4; + if (rssi >= -67) return 3; + if (rssi >= -78) return 2; + if (rssi >= -88) return 1; + return 0; +} + +export function targetText( + target: + | { Gradient: { percent: number } } + | { Resistance: { level: number } } + | { Power: { watts: number } } + | null, +): string { + if (!target) return EM_DASH; + if ('Gradient' in target) return `${signed(target.Gradient.percent, 1)}%`; + if ('Resistance' in target) return `L${target.Resistance.level}`; + return `${target.Power.watts} W`; +} + +export const MODE_LABEL: Record = { + ManualGrade: 'Manual grade', + Resistance: 'Resistance', + Profile: 'Profile', + Erg: 'ERG', +}; + +export function connectionText( + state: string | { Lost: { reason: string } }, +): { label: string; tone: 'ok' | 'warn' | 'bad' | 'idle' } { + if (typeof state !== 'string') return { label: `Lost — ${state.Lost.reason}`, tone: 'bad' }; + switch (state) { + case 'Controlling': + return { label: 'Connected', tone: 'ok' }; + case 'Connected': + return { label: 'Connected', tone: 'warn' }; + case 'Connecting': + return { label: 'Connecting', tone: 'warn' }; + case 'Reconnecting': + return { label: 'Reconnecting', tone: 'warn' }; + case 'Scanning': + return { label: 'Discovered', tone: 'idle' }; + default: + return { label: 'Idle', tone: 'idle' }; + } +} diff --git a/ui/src/lib/history.ts b/ui/src/lib/history.ts new file mode 100644 index 0000000..119f93f --- /dev/null +++ b/ui/src/lib/history.ts @@ -0,0 +1,86 @@ +/** + * Bounded, self-decimating chart history (NFR-3). + * + * A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is + * both a memory leak and a rendering cost that grows through the ride — exactly + * what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it + * averages adjacent pairs in place, halving the point count and doubling the + * time each point represents. Later samples are then averaged in groups of that + * same stride before being stored. + * + * The result is constant memory and a constant point count for any ride + * length, with resolution degrading gracefully: full 250 ms detail for the + * first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so + * uPlot can consume them without a copy. + */ + +const CAPACITY = 3600; + +export class History { + readonly capacity: number; + readonly seriesCount: number; + /** Seconds since ride start. */ + readonly x: Float64Array; + readonly y: Float64Array[]; + /** Number of populated points. */ + length = 0; + /** Raw samples currently folded into one stored point. */ + stride = 1; + + private pendingX = 0; + private pendingY: Float64Array; + private pendingN = 0; + + constructor(seriesCount: number, capacity = CAPACITY) { + this.capacity = capacity; + this.seriesCount = seriesCount; + this.x = new Float64Array(capacity); + this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity)); + this.pendingY = new Float64Array(seriesCount); + } + + push(x: number, values: number[]): void { + this.pendingX += x; + for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0; + this.pendingN++; + if (this.pendingN < this.stride) return; + + if (this.length >= this.capacity) this.compact(); + + const i = this.length++; + this.x[i] = this.pendingX / this.pendingN; + for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN; + + this.pendingX = 0; + this.pendingY.fill(0); + this.pendingN = 0; + } + + /** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */ + private compact(): void { + const half = this.length >> 1; + for (let i = 0; i < half; i++) { + const a = i * 2; + const b = a + 1; + this.x[i] = (this.x[a] + this.x[b]) / 2; + for (let s = 0; s < this.seriesCount; s++) { + this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2; + } + } + this.length = half; + this.stride *= 2; + } + + clear(): void { + this.length = 0; + this.stride = 1; + this.pendingX = 0; + this.pendingY.fill(0); + this.pendingN = 0; + } + + /** Views sized to the populated region, ready for `uPlot.setData`. */ + view(): Float64Array[] { + return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))]; + } +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 0000000..35274f1 --- /dev/null +++ b/ui/src/lib/types.ts @@ -0,0 +1,207 @@ +/** + * TypeScript mirror of the Rust payloads. + * + * The authoritative definitions are `crates/core/src/types.rs` (frozen), + * `src-tauri/src/events.rs`, `src-tauri/src/derive.rs` and + * `src-tauri/src/profile_view.rs`. Nothing here is computed — these are shapes + * that arrive over the event channel. + */ + +// --- crates/core/src/types.rs (frozen contract) ----------------------------- + +export interface Telemetry { + elapsedMs: number; + power_w: number | null; + cadence_rpm: number | null; + speed_kph: number | null; + resistance_level: number | null; + heart_rate_bpm: number | null; + total_distance_m: number | null; + total_energy_kcal: number | null; +} + +/** Serde externally-tagged enum. */ +export type ControlTarget = + | { Gradient: { percent: number } } + | { Resistance: { level: number } } + | { Power: { watts: number } }; + +export type ControlMode = 'ManualGrade' | 'Resistance' | 'Profile' | 'Erg'; + +export type ConnectionState = + | 'Idle' + | 'Scanning' + | 'Connecting' + | 'Connected' + | 'Controlling' + | 'Reconnecting' + | { Lost: { reason: string } }; + +export interface RideSnapshot { + elapsed_ms: number; + telemetry: { + elapsed_ms: number; + power_w: number | null; + cadence_rpm: number | null; + speed_kph: number | null; + resistance_level: number | null; + heart_rate_bpm: number | null; + total_distance_m: number | null; + total_energy_kcal: number | null; + }; + virtual_speed_kph: number; + virtual_distance_m: number; + gradient_pct: number; + elevation_gain_m: number; + mode: ControlMode; + target: ControlTarget | null; + profile_progress: number | null; +} + +export interface RiderConfig { + rider_kg: number; + bike_kg: number; + crr: number; + cda: number; + drivetrain_efficiency: number; + air_density: number; + wheel_circumference_m: number; +} + +export interface SafetyLimits { + min_gradient_pct: number; + max_gradient_pct: number; + min_resistance: number; + max_resistance: number; + min_power_w: number; + max_power_w: number; +} + +// --- src-tauri/src/derive.rs ------------------------------------------------- + +export type EtaKind = 'exact' | 'estimated' | 'held' | 'looping' | 'unavailable'; +export type XUnit = 'seconds' | 'metres'; + +export interface Derived { + etaKind: EtaKind; + timeRemainingS: number | null; + distanceTotalM: number | null; + distanceRemainingM: number | null; + elevationM: number | null; + ascentRemainingM: number | null; + positionX: number; + axisUnit: XUnit; + axisTotal: number; + loopIndex: number | null; + smoothedSpeedKph: number; + rollingPowerW: number; + rollingPowerWindowS: number; + avgPowerW: number; + maxPowerW: number; + normalisedPowerW: number | null; + avgCadenceRpm: number; + energyKj: number; +} + +/** What arrives on `ride://snapshot`. */ +export interface RideFrame { + snapshot: RideSnapshot; + derived: Derived; +} + +// --- src-tauri/src/profile_view.rs ------------------------------------------ + +export type Channel = 'gradient' | 'resistance' | 'power'; + +export interface BlockSummary { + index: number; + kind: 'constant' | 'ramp' | 'wave' | 'segments' | 'terrain'; + channel: Channel; + label: string; + startX: number; + endX: number; + unit: XUnit; +} + +export interface ProfileView { + name: string; + description: string | null; + looping: boolean; + source: string; + channel: Channel; + xUnit: XUnit; + totalX: number; + totalSeconds: number | null; + totalMetres: number | null; + series: [number, number][]; + elevation: [number, number][] | null; + elevationMinM: number | null; + elevationMaxM: number | null; + totalAscentM: number | null; + blocks: BlockSummary[]; + yaml: string; +} + +// --- src-tauri/src/events.rs ------------------------------------------------- + +export type RideStatus = 'idle' | 'running' | 'paused' | 'finished'; + +export interface LapSummary { + index: number; + elapsedMs: number; + distanceM: number; + avgPowerW: number; +} + +export interface RideState { + status: RideStatus; + mode: ControlMode; + target: ControlTarget | null; + gradientOffsetPct: number; + manualGradientPct: number; + resistanceLevel: number; + powerTargetW: number; + lap: number; + laps: LapSummary[]; + profile: ProfileView | null; + source: string; +} + +export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown'; + +export interface DeviceInfo { + id: string; + name: string; + address: string; + rssi: number; + kind: DeviceKind; + state: ConnectionState; + controlAcquired: boolean; + services: string[]; + remembered: boolean; + batteryPct: number | null; + unlockExpiresInS: number | null; + error: string | null; +} + +export interface DeviceList { + scanning: boolean; + devices: DeviceInfo[]; +} + +export interface Notice { + level: 'info' | 'warn' | 'error'; + message: string; +} + +export interface InputAck { + action: string; + detail: string | null; +} + +export interface SampleProfile { + name: string; + summary: string; + text: string; + isGpx: boolean; +} diff --git a/ui/src/lib/uplot.ts b/ui/src/lib/uplot.ts new file mode 100644 index 0000000..83137eb --- /dev/null +++ b/ui/src/lib/uplot.ts @@ -0,0 +1,67 @@ +/** Shared uPlot styling and sizing helpers. */ +import type uPlot from 'uplot'; + +export const INK_DIM = '#5d6c7d'; +export const GRID = '#141a23'; +export const FONT = '600 11px Inter, system-ui, sans-serif'; + +export function axis(overrides: Partial = {}): uPlot.Axis { + return { + stroke: INK_DIM, + font: FONT, + labelFont: FONT, + ticks: { stroke: GRID, width: 1, size: 4 }, + grid: { stroke: GRID, width: 1 }, + gap: 4, + ...overrides, + }; +} + +/** Keep a chart sized to its container without a resize storm. */ +export function observeSize(el: HTMLElement, apply: (w: number, h: number) => void): () => void { + let frame = 0; + const ro = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) apply(Math.round(rect.width), Math.round(rect.height)); + }); + }); + ro.observe(el); + return () => { + cancelAnimationFrame(frame); + ro.disconnect(); + }; +} + +/** + * Vertical "you are here" marker, drawn straight onto the canvas after the + * series. Cheaper and steadier than a series with one point. + */ +export function positionMarker(getX: () => number | null, colour: string): uPlot.Plugin { + return { + hooks: { + draw: (u: uPlot) => { + const x = getX(); + if (x == null || !Number.isFinite(x)) return; + const left = u.valToPos(x, 'x', true); + if (!Number.isFinite(left)) return; + const ctx = u.ctx; + const top = u.bbox.top; + const bottom = u.bbox.top + u.bbox.height; + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = colour; + ctx.lineWidth = Math.max(1, Math.round(devicePixelRatio)); + ctx.moveTo(left, top); + ctx.lineTo(left, bottom); + ctx.stroke(); + ctx.beginPath(); + ctx.fillStyle = colour; + ctx.arc(left, bottom, 4 * devicePixelRatio, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }, + }, + }; +} diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..ff2e6e2 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": false, + "checkJs": false, + "isolatedModules": true, + "strict": true, + "noUnusedLocals": false, + "types": ["svelte", "vite/client"] + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..ed99abd --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// Tauri drives this dev server; the port is fixed and must match +// src-tauri/tauri.conf.json's devUrl. +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { + // Rust sources are rebuilt by cargo, not by vite. + ignored: ['**/src-tauri/**'], + }, + }, + build: { + target: 'esnext', + sourcemap: false, + chunkSizeWarningLimit: 1200, + }, +});