From 7c17ca615847f2f15902a6df15f0067d8d5df31e Mon Sep 17 00:00:00 2001 From: dtourolle Date: Wed, 5 Aug 2026 13:34:27 +0200 Subject: [PATCH] Core ride logic, FTMS client, FIT encoder and probe CLI Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 3765 +++++++++++++++++++++++++- Cargo.toml | 2 +- REQUIREMENTS.md | 5 +- crates/ble/src/capabilities.rs | 804 ++++++ crates/ble/src/client.rs | 1632 +++++++++++ crates/ble/src/control_point.rs | 488 ++++ crates/ble/src/error.rs | 71 + crates/ble/src/indoor_bike_data.rs | 520 ++++ crates/ble/src/lib.rs | 69 + crates/ble/src/scan.rs | 319 +++ crates/ble/src/uuids.rs | 152 ++ crates/core/src/gpx.rs | 863 +++++- crates/core/src/physics.rs | 433 ++- crates/core/src/profile.rs | 1027 ++++++- crates/core/src/session.rs | 757 +++++- crates/fit/Cargo.toml | 8 + crates/fit/src/builder.rs | 1143 ++++++++ crates/fit/src/crc.rs | 120 + crates/fit/src/encode.rs | 578 ++++ crates/fit/src/lib.rs | 159 +- crates/fit/src/profile.rs | 184 ++ crates/fit/src/rawlog.rs | 554 ++++ crates/fit/src/recorder.rs | 634 +++++ crates/fit/src/timestamp.rs | 150 + crates/probe/src/cli.rs | 365 +++ crates/probe/src/commands.rs | 745 +++++ crates/probe/src/main.rs | 69 +- src-tauri/Cargo.toml | 1 + src-tauri/icons/128x128.png | Bin 0 -> 8735 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 14317 bytes src-tauri/icons/32x32.png | Bin 0 -> 1964 bytes src-tauri/icons/icon.ico | Bin 0 -> 370070 bytes src-tauri/icons/icon.png | Bin 0 -> 29548 bytes src-tauri/src/backend.rs | 67 + src-tauri/src/commands.rs | 423 +++ src-tauri/src/derive.rs | 291 ++ src-tauri/src/devices.rs | 333 +++ src-tauri/src/events.rs | 119 + src-tauri/src/lib.rs | 93 + src-tauri/src/main.rs | 6 + src-tauri/src/mock.rs | 222 ++ src-tauri/src/profile_view.rs | 330 +++ src-tauri/src/samples.rs | 115 + src-tauri/src/session_backend.rs | 75 + src-tauri/src/state.rs | 276 ++ ui/index.html | 12 + ui/package-lock.json | 1625 +++++++++++ ui/package.json | 29 + ui/src/app.css | 230 ++ ui/src/components/Readout.svelte | 87 + ui/src/components/RouteChart.svelte | 196 ++ ui/src/components/StreamChart.svelte | 110 + ui/src/lib/app.svelte.ts | 116 + ui/src/lib/bridge.ts | 108 + ui/src/lib/format.ts | 104 + ui/src/lib/history.ts | 86 + ui/src/lib/types.ts | 207 ++ ui/src/lib/uplot.ts | 67 + ui/svelte.config.js | 5 + ui/tsconfig.json | 17 + ui/vite.config.ts | 22 + 61 files changed, 20933 insertions(+), 55 deletions(-) create mode 100644 crates/ble/src/capabilities.rs create mode 100644 crates/ble/src/client.rs create mode 100644 crates/ble/src/control_point.rs create mode 100644 crates/ble/src/error.rs create mode 100644 crates/ble/src/indoor_bike_data.rs create mode 100644 crates/ble/src/scan.rs create mode 100644 crates/ble/src/uuids.rs create mode 100644 crates/fit/src/builder.rs create mode 100644 crates/fit/src/crc.rs create mode 100644 crates/fit/src/encode.rs create mode 100644 crates/fit/src/profile.rs create mode 100644 crates/fit/src/rawlog.rs create mode 100644 crates/fit/src/recorder.rs create mode 100644 crates/fit/src/timestamp.rs create mode 100644 crates/probe/src/cli.rs create mode 100644 crates/probe/src/commands.rs create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/src/backend.rs create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/derive.rs create mode 100644 src-tauri/src/devices.rs create mode 100644 src-tauri/src/events.rs create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/mock.rs create mode 100644 src-tauri/src/profile_view.rs create mode 100644 src-tauri/src/samples.rs create mode 100644 src-tauri/src/session_backend.rs create mode 100644 src-tauri/src/state.rs create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/src/app.css create mode 100644 ui/src/components/Readout.svelte create mode 100644 ui/src/components/RouteChart.svelte create mode 100644 ui/src/components/StreamChart.svelte create mode 100644 ui/src/lib/app.svelte.ts create mode 100644 ui/src/lib/bridge.ts create mode 100644 ui/src/lib/format.ts create mode 100644 ui/src/lib/history.ts create mode 100644 ui/src/lib/types.ts create mode 100644 ui/src/lib/uplot.ts create mode 100644 ui/svelte.config.js create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts 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 0000000000000000000000000000000000000000..b49215c653380b10515f754f5d136af216283188 GIT binary patch literal 8735 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 W7Gvbl=^$=B0MwMU6u-+`$NUe#Xt)~y literal 0 HcmV?d00001 diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5902e1824419860fa661f6bcd113cf5a544c61 GIT binary patch literal 14317 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..52cb3c62ce7b7394d14287a058b6ad44705bcbb4 GIT binary patch literal 370070 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 A`v3p{ literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..819c0d55d4852d0c9b6262c3a1a3297cb64835fa GIT binary patch literal 29548 zcmZ^~1yogC*FSnVfD+OvUDDmsC?$w=NFxX+ND0y$KtvFvL#ab}K#-K~?(Xgm0qHn# z_j#W8{r=zo-fQ2Z4e@K_K+2pj{BiQxF8&w*rBrzJWkguIcqUGQfv>uQXJZfG6

5q6+|FnU~m+5#`{dpy%rzQ2!yjZeX`u9d_Eyn5uJZ0uoc?eSX5 z#{D($00|2Ui}MLe@(GIR3kgXHJ(d!F%p)i$B`BEB?Oy$VO?c;G`^L`a|9`@?S3g=n z&i|p{@#fv@_a4^oT>oElgoUI;{zIe(G1Lsqd5E5)=VIsKW$peNPzW4l}C7&QVd!JR*vEI~<#|W(M$Jm>`#clX2xSk|)@$3@9&>NHJ0s zH85@&Jrj>&ae+qZ>sVGR zq99p3;ou}Uw=dX^85^aX$xmjx_-J&W1}H);HRG~~vgRL|rm#p-n|^CHdDU7=O5x;q z)7?xt;M3~UYGQ>w3huP?->ouKFQ^+xmLK23T4Op9KEZ1rFur7I#IsZS5??j^mwg%* z)Mf4O!Li8P6OBHQ$pl!?|$}u`2(KDOaW10Qh^78Z0jUS6LtF` z53RtGSTgq;K{72& zxy+MtY9;e9B^hn;UBz5UMqeH+@xT%b8ORhE%jmu7yuscUQPK`euSoF2UH_<2$*W(A z2)Xg@ZV;F~Kfp~VEOqx zNw-kWHYlLAqcxzF^~P*XTavWes{9l0de}EQ+6T9iJcZVzK}x1ZZ?Jmt5`E#gMN1DB z?=3z^|0NLs@bC9jf+hB2HUvX3kg(m}7c~y}IJWP|p4o@S#l>aC zjJ>}|0-c|aia9@1#wzM+3nOsHJ|SGS4;J(E8dm3mHC8fS6P{>=$h>CW31`mma)_jQ zNc|H!5B7X7^1PD2%j6@gl{v0XWeUbI*VS#xy#Rk;(%b%Wcb1b!Gv)GJ)P4A{;?M-v zi}Fup+hl-dsKT)~UY)IzR#V|$%C?SRHYqYfb=3<}KagJ=tsWB$>k)Pv)3wIa6#r4m zvI8b)5K*^Q&E`bbc2%{Of?KeP9^76h0ya$g*lNTFH=nh;Bu}S~7BVJbf9o-$pveyE zd(`yp6$VM!D06ZR2CCRB(+E(&>A+blO1YMH0Ax(p^VY=1D^_KJzu;6Ss~~w^`Ph8%;ip_MeOcFFPbQZqKk#SC`qQk5*ZZjFJhW~w!lsJ%UD6|}8J=Kc zvKXtrqF%=Hr}R!UJknF=qR7fS6|pH1i;jwTO{CUFuod2&)@!e)$hh**z!N+=IZqIz z=lf;iZTaO!>v4+X1>gm`Q&!(wwOG%C4vA;z%fWHRA^vv4yEf9rh0j6jf0{%;Ve>G5 zS(Q9dS$)4%959v@qXoz#^^Dw`&-KylQ*kzd$Ts#|O=8Eq*y3iTtbG8kj#S1UlZXv} zEb;N3-sxG{jP~RKD$eLqU;X+j+?Z#?l-Y()$p-h-AQ?;F037S|M~OGQ7Ic6uhuz5N zfO|U&3@&)A4xWi{B|K@|m{lZUCe?`jmBSTW_zXl^yoA#}^7W!ZB-tDD#Aj7Ai0r%^ zU`Y3DGtsNy9mXZ!8}7z*u3Cd6EUZkBho`5GN17X!Qut9gdm#4p^P7kOz#RH~2iSE0 z`Z$!mmrYDPScfXva(_(hDG>3FVlj;^cl25@j(LN*2+kl_*OH?Yy9PRF=uUtaK}zM9 z&q(^%QwX(rJ>o{NY77L+u$boNKe9fNq)`opF+srad&ESCZ#e;ukl`RB=5f|n(7_jM z9CYPMIk@f^Q}{S+7;4EocahVe-xjegpk>@pa2*~RA}F&}-Pp6Bd|qKTmQb5Mg?(&& z84R_`56p=Sn;gBLz+OpIcT}KC)WyVa0U6;{ASnOoSHLUkjA? z89`6gW`XCg=z;`uaJ{CCxD`>FF;JQoRW-uDOXc? z5VS0dIIbitDoP9#oGRFryonHW2L0>JBZBkMKSp3{nR6s%mFmtEbbRye;^RjOy4ZfIqIS2m&zKbU!Q0tV3nQ zBFhXWuP}yWiZ)y1N2V|+_y{ZYW3xIyQa{5j2w!>DS@|+vV)JaNwKv4lRR>o=gCm@R z_$2}SwNK^~v;@lRS1r1rcBA$fA9X!#Hgm*HZp zC&qOv{xb+>2|0ly8N791hs=bC=EEpIp`j~b#M$u3-Ni9HZYE6&^;?i zh$u*G%=p!E19)2=+e%&QhNPCnTWi!v0bu*B_Hmn=^Y8Uls~yD984a+6XA*-B)A&pN z3qR{odmyt=7yHScWy*VH5_j&8k2U0o)%vB2tJdnzLhwO|+DlkO7S7NL=FZwLI#^^ge5UXsRfR02YD?7N6%9!$e4sFc0h66!YuW)-7$%`b|3tY$u?SXhaUO6r!48_n)BF>F1E(uv->pE$)yeeq zk03RIvtcnbwpXIaXg;Ct0=V@e606WtcB(lJSMKT0M)ogq~toM1zcN6tLQm z^EHUjAhCkwS+`USq7Ca4n^qs!K33v%rwuZ$Mb;L(FcyP{`d>2r*C@72Mh zi*i*{mi+fh@NORft8-N`8R5DM;SBxBmt$N_#A+qS|K|Uq4E|u0qpHPd>6G8JyC+aAcDi2SLchHL#rNq}F_l+S z)$;GM8Un&4a+FYM2vDY`rD&lxJix@MI7vtrCyZg-TZez0yGlFto`a^jBqC-4SE2b) z47J(ng%li(=&7%gu1%&mk69z^^HG1VX+RhUqe4lK(0fy%vlCYs9=&0-JY6e>gY#bM z?FwzMry?T^Z8DiE!dcMhqDlRZG$^Di5m}GzfPc&R54D01tK1(yT3K;paIUYKO#FaB z(73O!>VnPcADyJ2OjA+E_xSN{chQ>NiX6JP_ZtWFVj}*u1HwxiRMOQ{jV4g2Uz0e4 zA?oR|L2X2AgCUMNq{x_o{@_3Mz=;yGn}SZlsI%6&G9B~}uYvEn#dIVbU0YeEtHD&h zC+%pp6k1T3@DxAi16Q`YXG!elqNgX8CSwjL^m~Ge%(l;Z{jRlniJ_)XYK(xY2K z{Nn;04-0OmvC?^7xfUgrC8G?0Sv1{Jjvd*=hVfQ2_X0%Kr$`;!K+`6948_brYHutw z*>+j=)e9y}@VmB&XR*pyc5`(KDUIeHDN3%v9|>U!=hEkY%IqXPq` zo*GJL{VZYN!ja7BCCN@+6)tjQ8sh`ewkWQ)xTDmym6AkVxjm1d%dD6u03&>hmEvu8mJ=^}&9AQmL|NeD+lKX|CDC9` zSfO$QJ4k(~-B{A=*|!+;nP*1h=Eld6+pg8qWon6{H|aivR4;{ z+jvkba8WdVP-O)Fm~>OdH!6-gTPZbpB6GObEQ?<(^tr}-^tlYL6kb*$p2;Ex%Rj_g zjg6P-)@^1!{4V4>>>EY(Ih&-cuEWL(<`sd|J24zH^rIXeEW|F@SnYa2j-C1D^>- zjBCB$B*vIcC~6G;&A*cGYEP{eta;}!y@SaK3n6y{0f^V;=G21)%KK{Gr>$o}F2e_u zR{3aCUyhNE>wsYT+GZIM`ULL^Cy$E{sEqf$n0cKJzqp?H^kr#Ta!s|00z?M9^?~M>N_k@!tm$h@e>ChE0{hEptGM$ z00;vnGDK72enti|s44m+QF2x~=~8X=ebA>S*z$kAq~vKJEDp2^EN#|YpbTq{BVT>4 za?mUpC;|f1i&RV=Zv`gaQg}N+zG%ry^%~WDb$7}EF%Ss;OU?lx7hMVq#I~UCJL@4L zaJ6l0Jqvglc9Jv**+Tfi&c)={PJXwxiy>`0zmw~59yrk*;{fY3@V1Oa+7KWUmQZPoiM?7u(8 z-2MJu+#bYE)Uo#NMrizTOyEzn`V4}^1q%7EP4|1ME+$jA_*m2>(T;BkA9_rmX!7A&ylNb7H1&*wh! z!$zqyAIz0Sq3&|Ge{|>!7&cX}K)2<758jzY@G*{)d>)$Alor629=FN97lweqKg7ip zfj1%{|3#?*K42p;=AQum{JH*F%6C5TXDYGne?k5>A2+DbS25+*9Os|U9{@^)hMNBc zePI&7V4=bP_{7^hV7SSY;l5Y)&Oh{~(27YaEdMW4Ys z{GtC?Iaho1zIJI5Hm0#-)n{>{>2}XwY0)XS7uV~2)xa8qyhQNri)SVq3{J1pbr`O* z5nR8OQL4frS;wrNQs8i$7j+X^+kn(DFHPSrENl?cO658oMvZ3R__Tx^93Yee<0kP6^nf> z`K}*Vaa7gNR21VRwtSTGeeJuaA~lw??aHz`COL%JvBQ;v1NsNYFu({g4}>#CC%8SK&owdE9cOh=}oPs5=D@(8$l zi3R-?yEvA`!{M{)2BP1&D`~>risM&tsVVL?@@XRQyGSxPv>5a7@@Kjit1D}HXU|8u za1(o8Ancyhnflhg4v3H4KrB|7)P|J%Z;vrIR|k+sUQHpl)v?&$C+E4oN$vRS(D<8g z+uiARZ*FNGrj&>aHU5s5(zOPlsGBlk_fX9x9dS=(pLOl9V11o@%x#69Ny@(D&pfEB ziwv`!9=-j^bsYbzF5DG$1b=6ffTp(v!*#Kk1%r3t=Gk$J#fs|NU|zGDya-f40LMEa z)kxc+V0KWXEtf3pXecnT&>)hv?3a8`hCIzV41fX9grmB?s)2snAy+9Z#w8uMWpgTlI>dkDvw}5i08MPUF4* zG*NMD9-Bm31rcQE@b5?D#2K3BsUwiTM*c-Pmz!aSTk<8vwUWV^FFjnRlUrY8Dh@w$22-fR3E^M>t=H$QPos98Pw~N}hnPiD#TF6m)O+cwUTqJzf;$YZkE#`T+2Gqre;O^W z?s(*JqFxh7-*;Ez6UD+PY-i8;AlX_H%sWCqu&>7%!Zs@Za#M?eVHK7=&yBecB0AFe zd%jkg|7`Q$3_yR0GpGty|M0k}l3;9F^+quiNDSv>V<%`AU+!MK#3G?qr$zh9T>1-4 zESPqs|5}7~V6E5h?NdQ64uO?fp%>Y^OPsAht{~r#T~`>-Y_qnW$;nuk_c8g~QMwMY zcN;Lbw#fhn^eX@@w!|;>XvdHuAjS$lKIBij<(oWtK6rJ|C{xA-xY=$Mz0>ZO-66}H zr>iTL>8qLM8)6HT`tj(gVAh8njPK}Ivjdj-w{`8APeuXbd%Fg;(F|W6G|7}`_^^ zNI!t;CEQ}lTH{YSFqhx0!@+b7_kKUf*IcSX04XJZjN8>h^27w)>9*Zo>(vLX6HDZU z+8aMx?2#T5H6=Q^8@;8*yLiK6NKk9SXJNSOF~1_V)EAZlE*o0T`|J#- z*Hni5H*m|N$L-Sr1@%$)J?$=*fki1bE+cSi9c&Bh(zuD>BLY!>|Ff4QrTu3w>FSrE zD{xp#H4srp_@-lz@ZQjkjfuf1l{oT~3R$8tV;G;G_5P|dWtjaZt@)W9Kca5HqX|kZ zWSQ6shL#SUwl=2#snx!An3k-G^do3epGv;?SqxZ1g%_^j~=>bYyljekNYf#SnH;!`=WF=8D5|!sH>gvF}tXe9w&f zpQ0sz(FLd6dyfv`#}}9x zAQ#Xz;B{F|lkS!jw>WVkXZmk_Vy|=M=*axg?Rsg((^a`t-V^yhxed2ot6G6d4tnY! z$!s9+0FmVjAb+JSv6$*irf?f%3ByoYHBU43?)k*G4E3&?JZ6*^ZEY(LAw=+OVWRc$ zN@M^tCQsG`DVHnbD#MGvUrWVjR^_fzU>P&Sd+{qNFbSPjsTS?RNL&m_A+oidbwV0aA3Hk4zMd&^=GV0A37pRco-GUNz z^Hb~hdR_^|_p!yPVUe^MU-vnLvOY{%X5Z)=lH9~|X(>x84j@rE8|G?qv9Z=L3chN} ztJ2?}+3AitS()A?#<)O~h0}|<6saTw& zU1nd%sZX*kq0i-+T{H5!yT;Y(&q!>|i>}W|{2Q649ZwIJDpGnFAW@96a6#2*Ixfak zc5q=cP^{=Dl}(ez^we!loBCYQAKP_we^tsI(+kTlmJE$)_y8MO|b4AXn-J+_>>gv;l7KAi>+65*4%doiJEuC!pXZ}uMaa*?u z?$>5@2iF)0+@PQY2KE(CxlhSk#-_J*f0wRESCE>W=p4|x|+H3lz=3jU(cQa@+qee3NAa}*34>0^$kG2AEr8<1Ugug zsM_(xsf7+vSw3eYUF+k3JduOWuqjMETEfoKr>I3Maz+3_y5{Qb%rjp6Xr(D`aXj** z#G-`w@ilpNwvnsuQ#R-g_t7;;j0^J7cTb5}x#O?PrCcXLRms|%ozg28{LXb3(^*8S zTq|gJ2So{;U-IuP9S0Ki*}5|<3*@#7 z!A%Lla|<}3Q*A+^FN%XMuZk_<*>XTn!^>)?@6KfUoW^iUCvi% z^>G?}1S$eOeK-e8~SaG|G1#+&O`MQ*-zP~TI zcwL^!QItK}oEo9A@+qRqSJGVP;UA4;;s_-Vprp&-ekYVz=)%%I6nTWuYWnu{!_X9Y zT8hESXnobJT6~{IVBC|Q`h96R7|l0y;FvvMM2+zM2KpN`c*Q)uCC!Dm)l(5q-*yba z5J<84U{iaKqZCkfG|t`B0JDy#bd}9GM0)<-{%wY`ww5$gqa<8{osc` zcH-2nGDQw6Db3!UTClUThscq`WPtijmqPUYRtrZHifdZi_;Sqeg! zeat}iS)a_-nNPLLM0{(Z9NK@eZ^rKgDzZ8EJSD7hhcGxWV7e^@LoTc$n zJ33^}21S6q;UB|uch)SU$SwOZ1)htg+-C6fxXo603EUCUp_>_x&>=Z?F90QXj?}PA zyie$-&)~iMS(_FXxZ}Y+eL5(jB_b|W2$YQ)=rD}Ye1*(U>c`xXZsH}9nPD_Hg$0f% zDSb;%-~65rwNQw#gT3tl@5gY%wjF(sRw4sX_PYE;>E?I7ECJGO`X}2IUt;TacX~^G z`J%PQ+LfkFwD2#&aa3a%UiU=P&)*q&Y5-96w~Mmw`a&_$1VJ(K+Vqt)#$T5llh7>c zeWxuKqtnb(wLEw|%i6;&eeS|n?wtP8uQQ4&FP`D}64=X&0Z%)EocId}v^*ht$`D z>ER0e#8e_cIj1^XeyG=5r>%dfP-I?*v5;I4c;jIQR9?7~Rp9Hr-@5{&Uw``!y?v4$ z%W-z0w}8r=`U5E(2JDm=)9ACl0tO0!#q-6M>l+(%D*=8m?AA&0qkRDh__&8xh0j@6 zW--PnFhysVACO$PE(w*t=H+7*TzseJfArT{7@M{?8sf-DE8+ZVZyc8S2vm>I62n7t z_RI>vOknbTN^eiLk$<<@CvT=8AhVgR-va`yacbW2py%&Uez#mp$ozdM0O}j2lV!-u z0-2KoSplN-K2y27Sz`G>v9GkFLn|g4j*dJj7QWx}f1zR+Wc!CR076spXhMpsXmG|K zFK@mB7;|Y9?4GIqif!UHYcf!1@JsS)(KFNB2B1L`0nqRn99eD&A3W>cbOQ$G6TZHl z%mr$c2a3zA#Z_kReXz=^DnD_Q{QEnAqrzphqI%%_h)A5$=3DN&t3PR8q}tp>je%}} zqJgFma&rv>=(=EwA8XxgGXmSz;pu>k#&4z2I1)5PbgJOfJF*PP)?VeYL<&Q%JG4e; zydLy?-&m+IW00&sex23!Xv+nC%Gtu#dT`d!ln{Ig=i&gFo12??jM=%=p#F>j+IIs2 zQsQTT59#QRqosx$zKL1h->fQFH3y{tTZJxx%V+1IP8c9xM6t z6QUcy-s*grXg)dtKIEV~3oA&<&xG{g)uU2KMnenIb1->KH1xVCW7+73T%LirtcnM8)is>5mzN?O+2?lYwK=m%xb zR)>^32Br9Pne5KPXG?>r$PtZ~^!$-J3cg5r8Cqpq{}F6Cj3SwLiA_^E#k z*|$5EBn$`$@V}jaz5=}L%!EkW$-Hg$ATZ(YFG#}TsfJKFrR$=*uvM!Y@SFR9!0SWL zmf*$HyT-p%kgvAmY0HYb-x-!hMP+7Y z)*+l6%i`GfF61@sE)HXLFI8J7g%k1H{OtB%vvzf2@H28owNTErvLzXUAKw5{8NqO*pQL zg^x$RxUI}UmKmp}rh2d3Esq3a-oIWW3KYB|xgV3!h|q!}^C#Q)$bl903+hl4LfyPp zI;)ANW-VNq*KXr1iZ&0HmX`caJ~FO{;Pc}Y)n~gbpw9Oog%|)NCt37;k09ds7UZ=- z@0V)bSQ+7?pZ8wYi^j4kpbbS27|J9x%MT^e5~vsn-sz+6xR|jVxi5EuF;cxc^w}Au z*DnnO%qoSc(L3my?VMJdo^2g2*G#&ewRbLl(#N%k59xUP-zEU;_@F}`gtTzFy=kqgZ8TGuX`QJh4IGCDia)8h(xKIuT!U7iraFH7T%$AaAw|9`q!!Yrs`B5(7=gv4B7JueP=C3q|8Vi2cYBBt8K}w2 z|1&p3vFQSB#T*5|*ArWSsJ{PTzxWDrDx`n*zut-NS6#{Li3#9M0_wa$tMf%}CSV(b zjeiHBfQfG@zXF!f{razvNc`ptokBb@bwB_&gNgq$Y@hAxx8%CSaf9EElQ7R((_87J4NWA&aRQ=cbE<8W>)3A$VFFF$C?e5I5rG~yP-a&l` zLd?jGCl*kJq9sYGnw-0s+MfN%D4TpFXO;W9lv;0aY-DIZ#0yKQl_YQ8&hhcG6-^`` zwNo3=Bf9k~_oyJo3LWp6T%qcv?)XB%ljn!BU#Y7>wh(_ezqxnu4gD8% z$%i8$A%VjQb@r-x832|T^^AH3a7Oa>Z=Rnl?4FL<{d}${QDs*Xo6!1%(E9k=rDuze zG$n_0(rqix?O8&U7RhK|NS0dQy}=m$+$-yw3#NeZ z67z4$UJAiEu()Y!)N3v}I%bGf=wdVT>0MYaBXxI_ogmp`!MsI>;bm7E8{?8Dl-{5j zIS>LMq24k_H{{)e%E6_Lr+CZ~(xfm3-Co&c;ukfawwSdlzNsu_N}BIWNk-tX<!8sC=;Imd1+wR08)E(q(!XRTL$(7;v>z^s*_;vU!8ha@hK<&gy4* zxHTcY%;w7+x6@1ctIc7YHHT~rx2Ye?aY#=ketAyR6=qm9;qk}BNlMGwDt_@uPI!cY{qB(2z4U>#%EY%E z>zn?h`8#<65@xZ-edb;W3Lc}9&F(bk(|5A1VFa`Ox(y^tI3A;TciazWg;uDrUm$o()5{Psx=s@6X8F)rt)niCLE}7s zYexxWqCDH!y!j`c5^m?Wp6;E@jg4QgrySK9k!CEk>R(w)f?l@5?W#yI?iZDNM1Q*O z=uDd%ttPc;YAB$HR6c4v)4FMBK`f*;-;CHstr18p?;hkRl*U{Up;kBiZyZqyPE+w= z5snYO#fi>k^r*?gg$q=#bR-3%hA1_lr3v2pk-4}Exja{#>D!#;8p#vqx1OD)CQoZ? zC|F(uh(%Xq$erMiXF9X^%rm+Q zr9?DAG|E0dSN^ES)~=bQuK1F~eU(+4<+~!MIp~&Ox+BWa!uf+3F@kYp^CyD1((G0L zawEs8tf!d*s`u8}G8*c`Yj-=(7?@e#h?dHY>O`&Uk%8kumgCr>-_) zg6`4ZJOU4R%*+IQIueJ^h411y?LjjnB&{Q@maDOCV#b5+bB46nDHRm)z{bYLbLI~E zOlkMvV52vV)y#9sKvY7)Xvi0Ie=VGTgZ9S$Tjfh7vLL;=)AQDGyLD4TvIZw?YK^rM zYH__$(oO;evLIhOUQ%2V$dGz6{IaSNF-{R`RK@IUvvQs9i)taQ`@+w$w+G`S$AkY_ z35+W?t(ksz3}Gj+Mj;WKXFe8p^E|SUko6~zCE-8Hpm(PNx}Ez0htGM#SwnqI9ekH) za(2A*=!Udq#P4C}=3;n0&j;=6_&{B~he%BV|LVp|xODz;NG)%9c_V&>YRC7KzJf?i z)p81I#nE@=+H&rmpv)ddc0LhZa^)Y3^!wiEMU`s_34`-|l%mnj7sdD~(ajQp6G1zY z`zLyacy83d_iX?Aj-1jnwlB|$N3R_#CzX{oUW9|W;_N;8$5d(yw@%@SN@c(|N zI;%&ws?j0;>N+X+TU%e)zE}hf85!B(T*YS%frhA~qodYoOi3JpR5>IcO6D=16siRW zaE3fSN1Re8R@6Rc*S*LC-+WvJSx%y*M=O=V(J_l^a8JkBvRnyN!u8`T%g)oU@Cum* z-}q|^9-#u68$?H}fcPNY+bUi>^3eNu0)LIIq04ZpfsjyomK`s$27l$J-JY_*4aw<; z8b513U0ODLxwLHgGWwg6{zx8E-VbHjC)@U8h;(0#xW%0!`;q3R1p}{-QW4imMKno6@SM#lvAHQfrKlDMyBK-4%pRu zP1p|O8$DU+8GSIv@RRplQFQt_K(0f;(U?;#`baSD5vre<`~YH_6dpUUHCt|Te<#zA z;b6M>*Q|b!GwN5AO4Nbx4_t*}A6%_G`FEzI zJtD04XrG7TWx-ju@K5DnGCbQhkT>#)b>LxXrABJn=z8;I z@+h{&wQ+NIkSHI{!GUPtb^az4zKjyZCnX?RTGaU2@O^RE=G$MVC5hxD3vy1JT=Ai$ zI(zEMgtiALBgqoXDPUbKU|ouCQj3{qJE|^hHxoNEFT`33x<^S|)Au&2>sZ9~I`iIt z(3`6oR7($n3?$dEfLub2%1i+szGOm0i0RdvU9YO}(JfrUxk)d^m) zg(gI;vgOwD2`xsmFP5-58^yc2;5%!HykH4Q2xkIzO6z$t=*5x7REzsDYOQ_w^vmF5 zQSU9r6^|toV6XWI`5-^H6FnK=Mq%^7Yb8M9mr?0t^E#|gO$5X3GmsRkZ1 z-F#&I7WaTXp~p^xVBO=seTgi#WByzEB$_b?91hCvH<2V3b1K*Age(VA;YG(Rm5mfN zVqpZ2&u7`7;Y)+QH4771tyVW_hq?YDSBLG+qS3KSZc%O0$E;_QZS^j^Uxhrpyxs#j z-nJu5LD9RhEJn-z$iDRjzcal;Qnwg$jqiZ3gt6I-lqRU~dsU}pLuzzPb8zo%$-Zw# z%4hj^B?^CMvF7Mi`{cYr2QOJSuL!KWD`3_y?1ta_N)|nydON=XNpZl*f2{7HOj{o5 z7hzGIZrQwTf8&zja^j)*?c>R-a;SW-e~gWt)7sOa=vcouL1^v}*T{NCaj|aKHf*Ys z0+nzxLoz2@;D19eN2=fsd9A*QI8x&o*&E&sBoYKGP>wn_2y3sEV6h|5I2u zwPA_;=N9Ba%UZ~pxbOUinFkQ{ReM`xdRtL97<)ZLlL0Y6O*n-5^8va06qWX(=HWqy zSu*G$9^Cv=2q{*BIF!!~Tc=T2w|>&Z*U4|H2=AZjoHRwfS_h zuzZWZcqvljV-jvR%!AtL4{7)j0i=`wShOW&3|nN`0YEj!Px?ue75Qz(719On~Ld>XD2N1#tS zmDjjhdIo8Z+YLLiazSpbX^SMYBX$E-klQ^`@FlVzkaOX#=7sCL*BMKj$E+n}v?*>M{TO!YGnzjbl95_} zZ3xZSo{d-lJBpU%4=o033zFYw*T|(bwPe40B@YgjB2$y0b7C3AaWmfl!VG&isa#FM zFzQ0(6%`-;(Y8P$ZSRAGtES#jOJsO?sjTLQp7L?H{b*D6WAD3#l{VAm+v)uy#E5O% zpBJPrXP((DkPK?mzk{Wc%#1}Qk1K~Pu%?W0y8*pLd=Iq{{%607y~7hX<7i?I^ag)E zwwmxc1>EpI&# zv7qQ*3>)tI;5biM7jzEDo1G2C&i#VsHs)WwNv3v*G3(dO zhvi&lmDLuxSoUR_3!I+v2(W%F<8G3XwR#^0&&1G;IqZmfja&M*kPcn_=E2(tvAHToXvCjIJi!P!H?ti;RIv*-kZ#%<9Zl>;59Jzrufuh;x-5Ya& zw*@u$#2Ew#e0-ZIAfah*md8EPm(Sy}b zZL8_K-$jsEjD>|ReW`wYK|FH-h|ZMs3+@@Xd2jJ(yPxvRHFUk)O$B*`8+Pm)3g_wQ zp(3ARvh+iByI%E@$?P!{?qb@pQL_JTPFRe#sjB+AFY5L#=-|mgEqfz>R`GfA(UEiV z#pUU&O^lF`+_K~|P|wOjSMJ6SNF$L*D)aAZviwITb8K0L8})>5z{KP>xEIx!dlIEpSUw6WNpk$>(kke z!_8EFb%OZ}cO(f9W}qXxvz!YxE~O>H^}toW5h}vycx2x%fB|)~AO&L}pZj!&%A>W; z&r?DAspUW^owL#5!c<)mYHeIjD|@NWCE{^4o}AXnxVIhMb$Es<}6_T5=>BL=1X5^k377-^-E^DI_OHTFS^%ZfR)en86u zDUnRM7>)Hp=J|d(B35Eb#MU>tmeBofV9AnvvhrKgq4fe+&@ z%#rtNSvNm0*sN+Du{Qj1HD88*72>sQPOoXcC=TsdzT`f;ky$}r)<}Bnjuteb5*|Of zykUI+?+e)cKzD{RN!ghNsQ6l+kEoa(kI=oh$x(_~4_A|lsglo_?n?$OcqM@Bd?m6>ZWJBFyMnFO&W z2yVysLL+M0%i>GJkG_@G525XALSb?jR&QM1Wh}#gat+md;ZS3oz zC+lJFBP^@BSAvqq z_=hB=?;{+kK5HK7rm`K*S$_$4!M}(SGJ_y4{8up#wES*5ZEZK*Y6srZ%)a!R5Ncqn0eJ?o)0YWcmGj0 ztgaF5sjW7SCw-mJFgd{}FDKqz8)JHzM}^(Iarvo7vFS}{t+N8nlAK24noJJWwb{W+ zd*{^$;SX08vro!Ps&6xj-uj^??&FYuiCRMNlC3PQxd_|w0;O4;cZZK0GInn0Q3>DZ zLFMo9H&=(kwIu@|O>0h#peUIF{r(g-D_@<}yOj|F7w?ZRutz1`mK=clS&>ztzNv2l z2R^|JiUZw32Pf`7L>@5OuH4PPYwT7TS8m0YVxbKsvl+)z?1EW})>{TQsg)9yeQ`kNE>(Y41G|T*wSa_TcV9 zw+xBoPO@A&&g(f{DLv_Cz5@4z<&7g3zO(rZsIVRC9k<9F%im7rtIhoVg5naJxP$hQ zjB7Rb?;1f*J(u;{`TI=8lQn8?A5V!wYcB)upc|5ha}6no`-H3Y{Qv7Ls+k?5p7Vt5 zf^vr7IhsPaWmVp!#wm}hvYPC<@W#qKA;G9fK>?5tbPFPj|NLO^oom^9=osOA$ThWg zRgsadYIawS7%>?T8Ic*9-Q{En*^q2JX~*|JO@IuS+w}NrfGc%ZdH1=*O!97zNW)hN`phi1hbnRmPd$FYs z)tOH@5SUeOQV@Pm86MS&hR(wselowZ7(fCGemHN7=FbBA`FKxYCl z?~6|1z>zcC>bu0smq0s_ZP_(bpy9Vq;CzNrp5#jaSyTYx;i2tt%_lveLc%F_ zIun1KZf|y1g*e{Jhq7n97?B!0mW<}dd+>y>2e>`n>ZoZ1efqPe3#vfDjOgY0!Eoue zrb$td23$cP(Ixux=pxq$euH?ZQCm}0>CkWU{;Q*W4NCTIP{clcV@5d`qhPJp*&`L? z4Qo^@+`FwJzI-Dzrh%h{(#Des9|a zvlP9W{0;Z_cb}`STGWCsk@=EOJ}j)cW2-V+tWc~Qm6*Uqrgk3ABrOAb{6CVA&b&

dMhoIUV~=_cCz&yj$Ig6qr-&jtaO-+ZiD@( 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 V_oY__K?_MF1z8oD5-Di#{{ubSvc&)Z literal 0 HcmV?d00001 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, + }, +});