diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8a47ab --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# BikeControl + +Ride a Van Rysel D100 smart trainer without Zwift. Load a GPX route or a synthetic +waveform profile, and the app drives the trainer's resistance to match — recording +power, speed and distance as you go. + +See [REQUIREMENTS.md](REQUIREMENTS.md) for the full specification. + +--- + +## Status + +| Component | State | +|-----------|-------| +| `crates/core` — physics, profiles, GPX import, ride engine | Implemented, 103 tests | +| `crates/ble` — FTMS client for the D100 | Implemented, 96 tests. **Not yet wired into the app** | +| `crates/fit` — FIT activity encoder | Implemented, 106 tests. **Not yet wired into the app** | +| `crates/probe` — hardware discovery CLI | **Works against the real trainer** | +| `src-tauri` + `ui` — desktop app | Runs on a **mock rider**, not the real trainer | + +The GUI and the trainer are both working, but **not yet connected to each other** — the +app ticks a synthetic rider while `probe` talks to the real hardware. Joining them is the +next step. + +--- + +## Prerequisites + +- Rust (built with 1.92) and `cargo-tauri`: `cargo install tauri-cli --version '^2'` +- Node 20+ and npm +- Linux: `webkit2gtk-4.1`, `libsoup-3.0`, and a running Bluetooth stack (BlueZ) + +--- + +## Running the app + +### Standalone binary (recommended) + +Embeds the frontend, so there is no dev server and nothing to go wrong: + +```bash +cd src-tauri +cargo tauri build --debug --no-bundle +cd .. +./target/debug/bikecontrol-app +``` + +Open straight onto a running ride with the sample GPX loaded: + +```bash +BIKECONTROL_DEMO=1 ./target/debug/bikecontrol-app +``` + +### Development mode (hot reload) + +```bash +cd ui && npm install # required once — skipping this is what causes a black window +cd ../src-tauri +cargo tauri dev +``` + +> **If the window is black and says "Could not connect to localhost: Connection refused"**, +> the Vite dev server is not running. Either `npm install` was never run in `ui/`, or +> something killed the server. Use the standalone binary above, which has no dev server at +> all. + +### Keyboard + +| Key | Action | +|-----|--------| +| `↑` / `↓` | Gradient up/down (`Shift` for coarse steps) | +| `0` | Reset gradient trim to zero | +| `Space` | Pause / resume | +| `M` | Cycle control mode | +| `L` | Mark lap | +| `P` | Profile picker | +| `[` / `]` | Adjust target power or resistance | +| `?` | Help overlay | + +Every shortcut is mirrored by an on-screen control. + +--- + +## Talking to the trainer + +The `probe` CLI is for hardware discovery and diagnosis — it speaks to the trainer +directly, independently of the app. + +```bash +cargo build -p bikecontrol-probe + +./target/debug/probe scan # find fitness machines +./target/debug/probe scan --all --secs 20 # every peripheral +./target/debug/probe inspect --name VANRYSEL # services, characteristics, capabilities +./target/debug/probe monitor --name VANRYSEL # live telemetry: raw hex + decoded +./target/debug/probe set --name VANRYSEL sim=4.0 # apply a target, then auto-reset +``` + +`set` accepts `gradient=`, `sim=`, `resistance=` or `power=`, and +always finishes by zeroing the gradient, dropping resistance to minimum and issuing +Reset + Stop — including on Ctrl-C. + +**The trainer only advertises once awake.** Spin the cranks for a few seconds first, or +scans will find nothing. + +### What this trainer reports + +Confirmed against the hardware: + +``` +VANRYSEL-HT-2876 DECATHLON, model 355194, firmware 0.108 + Fitness Machine Service (0x1826) + Zwift custom service (00000001-19ca-4651-86e5-fa29dcdd09d1) + + Accepts: SetIndoorBikeSimulationParameters (0x11) <-- use this for gradient + SetTargetResistanceLevel (0x04) range 0..100 step 1 + SetTargetPower (0x05) range 50..600 W + Rejects: SetTargetInclination (0x03) not advertised + Notifies: Indoor Bike Data at 4 Hz +``` + +Note `SetTargetInclination (0x03)` is **not** supported, and its range characteristic +reports only 0–6% with no negatives — so simulation mode is the only usable path for +gradient. + +--- + +## Profiles + +Two kinds of ride, both in the same YAML format — see [profiles/](profiles/): + +| File | What it does | +|------|--------------| +| `sine-overunders.yaml` | Warm-up ramp, then sinusoidal power over-unders | +| `hill-repeats.yaml` | Distance-based terrain loop, repeats indefinitely | +| `sawtooth-gradient.yaml` | Gradient sawtooth, distance-based | +| `square-resistance.yaml` | Raw resistance intervals, bypassing physics | + +Blocks are `constant`, `ramp`, `wave` (sine/square/triangle/sawtooth), `segments` or +`terrain`, each driving the `gradient`, `resistance` or `power` channel over an extent +measured in `seconds` or `metres`: + +```yaml +name: Sine over-unders +blocks: + - { type: ramp, channel: power, from: 100.0, to: 200.0, extent: { seconds: 600.0 } } + - type: wave + channel: power + shape: sine + midpoint: 240.0 + amplitude: 40.0 + period: { seconds: 120.0 } + repeats: 8.0 +looping: false +``` + +GPX files are imported directly — [testdata/sample-climb.gpx](testdata/sample-climb.gpx) +is a 3 km climb with realistic GPS elevation noise. Raw GPS elevation is far too noisy to +differentiate into gradients, so import resamples, smooths and clamps before the trainer +ever sees a number. + +--- + +## Development + +```bash +cargo test --workspace # 300+ tests +cargo clippy --workspace --all-targets +cd ui && npm run check # svelte-check +``` + +The workspace is deliberately layered so most of it is testable without hardware: + +``` +crates/core physics, profiles, GPX, ride state machine — no I/O at all +crates/ble FTMS client; protocol logic is pure functions over bytes +crates/fit FIT encoder; round-trip tested against an independent parser +crates/probe hardware CLI +src-tauri Tauri shell — owns the ride loop +ui Svelte 5 + uPlot — renders snapshots, issues intents +``` + +`crates/core/src/types.rs` is the shared contract between all of them. Change it +deliberately. + +The app selects its data source by Cargo feature: `mock-ride` (default) drives a synthetic +rider; `real-session` wraps the real ride engine and needs a live telemetry source. diff --git a/crates/fit/src/builder.rs b/crates/fit/src/builder.rs index 94d489d..bbe0b32 100644 --- a/crates/fit/src/builder.rs +++ b/crates/fit/src/builder.rs @@ -44,10 +44,16 @@ pub struct FitSummary { pub total_elapsed_s: f64, /// Moving/recording time excluding explicit pauses, seconds. pub total_timer_s: f64, + /// Virtual distance covered, metres. pub total_distance_m: f64, + /// Cumulative climbing, metres. pub total_ascent_m: u16, + /// Mean power over samples that reported one. `None` if none did. pub avg_power_w: Option, + /// Peak power. `None` if no sample reported power. pub max_power_w: Option, + /// Energy in kilocalories, from the trainer if it reports it and otherwise + /// derived from mechanical work. pub total_calories: Option, /// Number of BLE dropouts spanned (FR-8.5). pub gaps: usize, @@ -131,7 +137,8 @@ impl Aggregates { (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)) + let kcal = clamp_u16(self.work_j / 1000.0); + (kcal > 0).then_some(kcal) } fn avg_grade_pct(&self) -> Option { @@ -181,8 +188,13 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec, FitSummary), FitError> 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); + // The first sample of the *next* lap closes this one's distance and + // altitude, so that the laps tile the session exactly rather than each + // dropping the stretch between its last sample and the next boundary. + let tail = resolved.get(cursor); lap_aggs.push(aggregate( &resolved[begin..cursor], + tail, start_fit, lap_start_ms, lap_end_ms, @@ -190,7 +202,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec, FitSummary), FitError> )); } - let session_agg = aggregate(&resolved, start_fit, 0, end_ms, paused_ms); + let session_agg = aggregate(&resolved, None, start_fit, 0, end_ms, paused_ms); let bytes = assemble(log, &resolved, &lap_slices, &lap_aggs, &session_agg, start_fit)?; let summary = FitSummary { @@ -316,8 +328,15 @@ fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 { } /// Fold a slice of samples into lap or session aggregates. +/// +/// `tail` is the first sample *after* this slice, when there is one. It +/// contributes only to the closing distance and altitude, never to averages or +/// maxima — it belongs to the next lap. Without it, lap distances and ascents +/// would not sum to the session's, because each lap would silently drop the +/// stretch between its final sample and the lap boundary. fn aggregate( samples: &[Resolved], + tail: Option<&Resolved>, start_fit: u32, from_ms: u64, to_ms: u64, @@ -399,6 +418,19 @@ fn aggregate( prev_alt = Some(r.altitude_m); } + // Close the lap at the boundary rather than at its last sample. + if let Some(t) = tail { + agg.end_distance_m = t.sample.distance_m; + if let Some(prev) = prev_alt { + let d = t.altitude_m - prev; + if d > 0.0 { + agg.ascent_m += d; + } else { + agg.descent_m -= d; + } + } + } + agg } @@ -1087,6 +1119,69 @@ mod tests { assert_eq!(summary.total_distance_m, 600.0); } + #[test] + fn lap_distance_and_ascent_tile_the_session_exactly() { + // Each lap must be closed at the boundary, not at its last sample, or + // the laps quietly lose one sample interval of distance apiece. + let mut entries = Vec::new(); + for i in 0..61u64 { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + speed_kph: 36.0, + distance_m: (i * 10) as f64, + gradient_pct: 5.0, + ..Default::default() + })); + } + entries.push(LogEntry::Lap { + at_ms: 20_000, + from_controller: true, + }); + entries.push(LogEntry::Lap { + at_ms: 40_000, + from_controller: true, + }); + entries.push(LogEntry::End { at_ms: 60_000 }); + let log = log_with(entries); + + let start_fit = crate::timestamp::from_unix_millis(log.start.start_unix_ms).unwrap(); + let resolved = resolve_samples(&log, start_fit).unwrap(); + let bounds = lap_boundaries(&log, 60_000); + let mut cursor = 0usize; + let mut lap_distance = 0.0; + let mut lap_ascent = 0.0; + for (i, &(from, to)) in bounds.iter().enumerate() { + let begin = cursor; + let is_last = i + 1 == bounds.len(); + while cursor < resolved.len() && (is_last || resolved[cursor].sample.elapsed_ms < to) { + cursor += 1; + } + let agg = aggregate( + &resolved[begin..cursor], + resolved.get(cursor), + start_fit, + from, + to, + 0, + ); + lap_distance += agg.total_distance_m(); + lap_ascent += agg.ascent_m; + } + let session = aggregate(&resolved, None, start_fit, 0, 60_000, 0); + assert!( + (lap_distance - session.total_distance_m()).abs() < 1e-9, + "laps sum to {lap_distance} m, session is {} m", + session.total_distance_m() + ); + assert!( + (lap_ascent - session.ascent_m).abs() < 1e-9, + "laps climb {lap_ascent} m, session climbs {} m", + session.ascent_m + ); + assert_eq!(session.total_distance_m(), 600.0); + } + #[test] fn trainer_reported_energy_is_preferred_for_calories() { let entries = vec![ diff --git a/crates/fit/src/encode.rs b/crates/fit/src/encode.rs index de58506..891027e 100644 --- a/crates/fit/src/encode.rs +++ b/crates/fit/src/encode.rs @@ -35,7 +35,8 @@ pub const PROFILE_VERSION: u16 = 21_205; 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. +/// low 5 bits are the type number. Variant names are the FIT type names. +#[allow(missing_docs)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum BaseType { @@ -54,7 +55,9 @@ pub enum BaseType { Byte = 0x0D, } -/// One encoded field value, carrying its own base type and width. +/// One encoded field value, carrying its own base type and width. Variant +/// names mirror the FIT base types they encode to. +#[allow(missing_docs)] #[derive(Debug, Clone, PartialEq)] pub enum Value { Enum(u8), @@ -176,9 +179,8 @@ impl Message { 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)> { + /// The definition-message shape of this message. + fn shape(&self) -> Shape { self.fields .iter() .map(|(n, v)| (*n, v.size(), v.base_type() as u8)) @@ -186,6 +188,10 @@ impl Message { } } +/// A definition-message shape: `(field number, size in bytes, base type)` per +/// field. Two messages sharing a shape can share a definition. +type Shape = Vec<(u8, u8, u8)>; + /// Accumulates data records and emits a complete FIT file. /// /// Definitions are cached per local message type, so a definition is re-emitted @@ -195,7 +201,7 @@ impl Message { pub struct FitEncoder { data: Vec, /// Cached definition shape per local message type (0..16). - defs: [Option<(u16, Vec<(u8, u8, u8)>)>; 16], + defs: [Option<(u16, Shape)>; 16], message_count: usize, } @@ -345,18 +351,48 @@ pub fn verify(bytes: &[u8]) -> Result<(), VerifyError> { /// Why [`verify`] rejected a file. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum VerifyError { + /// Shorter than the smallest legal file (header plus CRC). #[error("file is {0} bytes, too short to be a FIT file")] - TooShort(usize), + TooShort( + /// Actual file length. + usize, + ), + /// Byte 0 is neither 12 nor 14. #[error("header size {0} is neither 12 nor 14")] - BadHeaderSize(u8), + BadHeaderSize( + /// The declared header size. + u8, + ), + /// Bytes 8..12 are not `.FIT`. #[error("data type signature is {0:?}, expected \".FIT\"")] - BadSignature([u8; 4]), + BadSignature( + /// The bytes found where the signature should be. + [u8; 4], + ), + /// The header's data size does not match the bytes actually present. #[error("header declares {declared} data bytes but the file carries {actual}")] - DataSizeMismatch { declared: usize, actual: usize }, + DataSizeMismatch { + /// Data size from the header. + declared: usize, + /// Data bytes actually present. + actual: usize, + }, + /// The header CRC does not check out. #[error("header CRC is {stored:#06x}, computed {computed:#06x}")] - HeaderCrc { stored: u16, computed: u16 }, + HeaderCrc { + /// CRC read from the file. + stored: u16, + /// CRC computed over bytes 0..12. + computed: u16, + }, + /// The trailing file CRC does not check out. #[error("file CRC is {stored:#06x}, computed {computed:#06x}")] - FileCrc { stored: u16, computed: u16 }, + FileCrc { + /// CRC read from the end of the file. + stored: u16, + /// CRC computed over header plus data. + computed: u16, + }, } #[cfg(test)] diff --git a/crates/fit/src/profile.rs b/crates/fit/src/profile.rs index 797a941..a69f68e 100644 --- a/crates/fit/src/profile.rs +++ b/crates/fit/src/profile.rs @@ -10,6 +10,10 @@ //! numbers into a session message produces a file that parses but reports //! nonsense (average power showing up as maximum heart rate, and so on). +// These are transcription tables: each constant's name *is* its documentation, +// and a doc comment per entry would bury the numbers that matter. +#![allow(missing_docs)] + /// Global message numbers. pub mod mesg { pub const FILE_ID: u16 = 0; diff --git a/crates/fit/src/rawlog.rs b/crates/fit/src/rawlog.rs index 4867eff..41032b3 100644 --- a/crates/fit/src/rawlog.rs +++ b/crates/fit/src/rawlog.rs @@ -56,6 +56,7 @@ pub enum LogEntry { /// A lap marker (FR-8.7). Ends the lap in progress and starts a new one. #[serde(rename = "lap")] Lap { + /// Elapsed time of the lap boundary, ms. at_ms: u64, /// True if triggered by the controller rather than the UI. #[serde(default, skip_serializing_if = "is_false")] @@ -64,14 +65,23 @@ pub enum LogEntry { /// 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 }, + Pause { + /// Elapsed time at which the rider paused, ms. + at_ms: u64, + }, /// The rider resumed. #[serde(rename = "resume")] - Resume { at_ms: u64 }, + Resume { + /// Elapsed time at which the rider resumed, ms. + 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 }, + End { + /// Elapsed time at the end of the ride, ms. + at_ms: u64, + }, } /// Session metadata, written as the first line of the journal. @@ -145,8 +155,10 @@ pub struct Sample { /// Milliseconds since the start of the ride. #[serde(rename = "e")] pub elapsed_ms: u64, + /// Trainer power, watts. #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")] pub power_w: Option, + /// Cadence, rpm. #[serde(rename = "c", default, skip_serializing_if = "Option::is_none")] pub cadence_rpm: Option, /// Virtual speed from the physics engine, km/h. @@ -165,6 +177,7 @@ pub struct Sample { /// integrates gradient over distance to synthesise a profile. #[serde(rename = "a", default, skip_serializing_if = "Option::is_none")] pub altitude_m: Option, + /// Heart rate, bpm, if a strap is paired. #[serde(rename = "h", default, skip_serializing_if = "Option::is_none")] pub heart_rate_bpm: Option, /// Trainer-reported cumulative energy, kcal. @@ -250,17 +263,34 @@ impl RawLog { }) } - /// Recorded BLE dropouts as `(start_ms, end_ms)`. An unterminated gap is - /// closed at `fallback_end_ms`. + /// Recorded BLE dropouts as `(start_ms, end_ms)`, in order. + /// + /// A dropout is written twice: once unterminated the moment it is noticed, + /// so it survives a crash during the dropout, and once with an end time + /// when telemetry returns. The journal is append-only, so the earlier line + /// cannot be rewritten — instead entries are keyed by their start time and + /// a later, terminated entry supersedes the open one. An unterminated gap + /// that is never closed runs to `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))) + let mut resolved: Vec<(u64, Option)> = Vec::new(); + for entry in &self.entries { + if let LogEntry::Gap { at_ms, until_ms, .. } = entry { + match resolved.iter_mut().find(|(start, _)| start == at_ms) { + // A concrete end time always supersedes an open one, and a + // later end time supersedes an earlier one. + Some(slot) => { + if let Some(end) = until_ms { + slot.1 = Some(slot.1.map_or(*end, |prev: u64| prev.max(*end))); + } + } + None => resolved.push((*at_ms, *until_ms)), } - _ => None, - }) + } + } + resolved.sort_unstable_by_key(|(start, _)| *start); + resolved + .into_iter() + .map(|(start, end)| (start, end.unwrap_or(fallback_end_ms).max(start))) .collect() } @@ -489,6 +519,54 @@ mod tests { ]); } + #[test] + fn a_closing_gap_entry_supersedes_the_open_one_rather_than_adding_a_second() { + // A dropout is journalled twice — open, then closed — because the log + // is append-only and must survive a crash during the dropout. It is + // still one dropout. + let text = header_line() + + &entry_to_line(&LogEntry::Gap { + at_ms: 5000, + until_ms: None, + reason: "peripheral disconnected".into(), + }) + .unwrap() + + &entry_to_line(&LogEntry::Gap { + at_ms: 5000, + until_ms: Some(12_000), + reason: "telemetry resumed".into(), + }) + .unwrap(); + let log = parse_log(&text).unwrap(); + assert_eq!( + log.gaps(60_000), + vec![(5000, 12_000)], + "the closed entry must supersede the open one, not add to it" + ); + } + + #[test] + fn distinct_dropouts_stay_distinct() { + let text = header_line() + + &entry_to_line(&LogEntry::Gap { + at_ms: 20_000, + until_ms: Some(25_000), + reason: String::new(), + }) + .unwrap() + + &entry_to_line(&LogEntry::Gap { + at_ms: 5000, + until_ms: Some(9000), + reason: String::new(), + }) + .unwrap(); + // Reported in time order regardless of write order. + assert_eq!(parse_log(&text).unwrap().gaps(60_000), vec![ + (5000, 9000), + (20_000, 25_000) + ]); + } + #[test] fn blank_lines_and_whitespace_are_tolerated() { let text = format!("\n{}\n\n \n", header_line().trim()); diff --git a/crates/fit/tests/crash_recovery.rs b/crates/fit/tests/crash_recovery.rs new file mode 100644 index 0000000..b42aa0a --- /dev/null +++ b/crates/fit/tests/crash_recovery.rs @@ -0,0 +1,262 @@ +//! End-to-end crash safety (FR-8.4, FR-8.5, FR-8.6). +//! +//! These tests do to the journal what a crash does — truncate it at an +//! arbitrary byte — and then check that a valid activity still comes out the +//! other side. + +use std::fs; +use std::path::{Path, PathBuf}; + +use bikecontrol_core::{ControlMode, RideSnapshot, Telemetry}; +use bikecontrol_fit::{build_fit_from_log, read_log, verify, Recorder, RecorderOptions}; +use chrono::{TimeZone, Utc}; +use fitparser::profile::MesgNum; + +fn workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "bikecontrol-fit-it-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn snapshot(second: u64) -> RideSnapshot { + let elapsed_ms = second * 1000; + RideSnapshot { + elapsed_ms, + telemetry: Telemetry { + elapsed_ms, + power_w: Some(180 + (second % 60) as i16), + cadence_rpm: Some(84.0 + (second % 12) as f32), + heart_rate_bpm: Some(138 + (second % 25) as u8), + ..Default::default() + }, + virtual_speed_kph: 29.0 + (second % 7) as f32, + virtual_distance_m: second as f64 * 8.2, + gradient_pct: ((second % 20) as f32 - 10.0) / 2.0, + elevation_gain_m: second as f32 * 0.15, + mode: ControlMode::Profile, + target: None, + profile_progress: Some(second as f32 / 600.0), + } +} + +/// Record `seconds` of riding and leave the journal behind, as a crash would. +fn crashed_journal(dir: &Path, seconds: u64) -> PathBuf { + let log = dir.join("ride.jsonl"); + let mut rec = Recorder::create_at( + &log, + RecorderOptions::default(), + Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(), + 7200, + ) + .unwrap(); + for s in 0..seconds { + rec.record(&snapshot(s)).unwrap(); + } + // No finish(): the process "dies" here. + rec.abandon() +} + +#[test] +fn a_journal_truncated_mid_line_still_yields_a_valid_activity() { + let dir = workdir("torn"); + let log = crashed_journal(&dir, 120); + + // Chop the file part-way through the final line, exactly as a power cut + // during a write would. + let text = fs::read_to_string(&log).unwrap(); + let final_terminator = text.rfind('\n').unwrap(); + let start_of_final_line = text[..final_terminator].rfind('\n').unwrap() + 1; + let torn = &text[..start_of_final_line + 12]; + assert!(!torn.ends_with('\n'), "the cut must land inside a line"); + fs::write(&log, torn).unwrap(); + + let parsed = read_log(&log).unwrap(); + assert_eq!(parsed.skipped_lines, 1, "exactly the torn line was dropped"); + assert_eq!(parsed.samples().count(), 119, "one sample lost, not the ride"); + assert!(!parsed.clean_shutdown); + + let fit = dir.join("recovered.fit"); + let summary = build_fit_from_log(&log, &fit).unwrap(); + assert_eq!(summary.records, 119); + assert!(summary.recovered_from_crash); + + let bytes = fs::read(&fit).unwrap(); + assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes)); + let records = fitparser::from_bytes(&bytes).unwrap(); + assert_eq!( + records.iter().filter(|r| r.kind() == MesgNum::Record).count(), + 119 + ); + assert_eq!( + records.iter().filter(|r| r.kind() == MesgNum::Session).count(), + 1 + ); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn a_journal_truncated_at_any_byte_never_produces_a_broken_fit() { + // The general statement. For every truncation point past the header, the + // recovery path must either refuse cleanly or produce a file that verifies + // — never a file that passes our checks and fails someone else's. + let dir = workdir("everytrunc"); + let log = crashed_journal(&dir, 40); + let full = fs::read(&log).unwrap(); + + let truncated_log = dir.join("truncated.jsonl"); + let mut produced = 0; + for cut in (16..full.len()).step_by(7) { + fs::write(&truncated_log, &full[..cut]).unwrap(); + let fit = dir.join("out.fit"); + // Refusing is fine: a journal with no header, or with no samples yet, + // has no activity in it. Producing a *broken* file is not. + if build_fit_from_log(&truncated_log, &fit).is_ok() { + let bytes = fs::read(&fit).unwrap(); + assert!( + verify(&bytes).is_ok(), + "truncation at {cut} produced an invalid FIT: {:?}", + verify(&bytes) + ); + fitparser::from_bytes(&bytes).unwrap_or_else(|e| { + panic!("truncation at {cut} produced a file the decoder rejected: {e}") + }); + produced += 1; + } + } + assert!(produced > 10, "expected most truncations to be recoverable"); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn recovery_reproduces_the_file_a_clean_finish_would_have_written() { + let dir = workdir("identical"); + let log = dir.join("ride.jsonl"); + let mut rec = Recorder::create_at( + &log, + RecorderOptions::default(), + Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(), + 7200, + ) + .unwrap(); + for s in 0..90 { + rec.record(&snapshot(s)).unwrap(); + } + rec.mark_lap(30_000, true).unwrap(); + rec.mark_gap(45_000, "trainer dropped").unwrap(); + rec.record(&snapshot(60)).unwrap(); + + let clean = dir.join("clean.fit"); + let clean_summary = rec.finish(&clean).unwrap(); + + let rebuilt = dir.join("rebuilt.fit"); + let rebuilt_summary = build_fit_from_log(&log, &rebuilt).unwrap(); + + assert_eq!(clean_summary, rebuilt_summary); + assert_eq!(fs::read(&clean).unwrap(), fs::read(&rebuilt).unwrap()); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn a_ride_survives_repeated_dropouts_and_laps() { + // FR-8.5 plus FR-8.7 together, over a ride that keeps losing the trainer. + let dir = workdir("messy"); + let log = dir.join("ride.jsonl"); + let mut rec = Recorder::create_at( + &log, + RecorderOptions::default(), + Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(), + 0, + ) + .unwrap(); + + let mut second = 0u64; + for round in 0..4 { + for _ in 0..30 { + rec.record(&snapshot(second)).unwrap(); + second += 1; + } + rec.mark_lap(second * 1000, round % 2 == 0).unwrap(); + rec.mark_gap(second * 1000, "peripheral disconnected").unwrap(); + second += 15; // fifteen seconds of silence + } + for _ in 0..30 { + rec.record(&snapshot(second)).unwrap(); + second += 1; + } + + let fit = dir.join("ride.fit"); + let summary = rec.finish(&fit).unwrap(); + + assert_eq!(summary.records, 150, "every sample we actually saw"); + assert_eq!(summary.laps, 5, "four markers split the ride into five laps"); + assert_eq!(summary.gaps, 4); + assert!(!summary.recovered_from_crash); + + let bytes = fs::read(&fit).unwrap(); + assert!(verify(&bytes).is_ok()); + let records = fitparser::from_bytes(&bytes).unwrap(); + assert_eq!(records.iter().filter(|r| r.kind() == MesgNum::Lap).count(), 5); + assert_eq!( + records.iter().filter(|r| r.kind() == MesgNum::Record).count(), + 150 + ); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn a_journal_with_only_a_header_is_refused_rather_than_written_empty() { + let dir = workdir("empty"); + let log = crashed_journal(&dir, 0); + let fit = dir.join("out.fit"); + assert!( + build_fit_from_log(&log, &fit).is_err(), + "an activity with no records is rejected by every uploader" + ); + assert!(!fit.exists(), "no file should be left behind"); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn a_long_ride_records_and_recovers() { + // Two hours at 1 Hz — the realistic worst case for a trainer session. + let dir = workdir("long"); + let log = dir.join("ride.jsonl"); + let mut rec = Recorder::create_at( + &log, + RecorderOptions { + // fsync per sample would dominate the runtime and proves nothing + // extra here; durability is covered by its own test. + fsync_every: None, + ..Default::default() + }, + Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(), + 7200, + ) + .unwrap(); + for s in 0..7200 { + rec.record(&snapshot(s)).unwrap(); + } + let fit = dir.join("ride.fit"); + let summary = rec.finish(&fit).unwrap(); + + assert_eq!(summary.records, 7200); + assert_eq!(summary.total_elapsed_s, 7199.0); + let bytes = fs::read(&fit).unwrap(); + assert!(verify(&bytes).is_ok()); + assert!( + bytes.len() < 200_000, + "two hours came to {} bytes", + bytes.len() + ); + let records = fitparser::from_bytes(&bytes).unwrap(); + assert_eq!( + records.iter().filter(|r| r.kind() == MesgNum::Record).count(), + 7200 + ); + let _ = fs::remove_dir_all(dir); +} diff --git a/crates/fit/tests/golden.rs b/crates/fit/tests/golden.rs new file mode 100644 index 0000000..0e15dd0 --- /dev/null +++ b/crates/fit/tests/golden.rs @@ -0,0 +1,294 @@ +//! Golden test: the exact bytes of a small synthetic activity. +//! +//! The round-trip tests prove an independent decoder agrees with us about what +//! the file *means*. This one pins what the file *is*, byte for byte, so that +//! any change to the encoder — a reordered field, a different scale, an extra +//! message — has to be made deliberately and reviewed as a diff of the golden +//! constant rather than slipping through unnoticed. +//! +//! Regenerate with `PRINT_GOLDEN=1 cargo test -p bikecontrol-fit --test golden` +//! and paste the printed constant back in, *after* checking the change is one +//! you meant to make. + +use bikecontrol_fit::encode::{file_header, verify}; +use bikecontrol_fit::rawlog::{LogEntry, RawLog, Sample, SessionStart}; +use bikecontrol_fit::{crc16, encode_activity, timestamp}; + +/// 2026-08-05T10:00:00Z. +const START_UNIX_MS: i64 = 1_785_967_200_000; + +/// A three-second ride, chosen to be small enough to read in hex and rich +/// enough to exercise every message type. +fn tiny_ride() -> RawLog { + let samples = [ + // (elapsed_ms, power, cadence, speed_kph, distance_m, grade) + (0u64, 210i16, 90.0f32, 30.0f32, 0.0f64, 0.0f32), + (1000, 220, 91.0, 30.6, 8.5, 1.0), + (2000, 230, 92.0, 31.2, 17.2, 2.0), + ]; + let mut entries: Vec = samples + .iter() + .map(|&(e, p, c, v, d, g)| { + LogEntry::Sample(Sample { + elapsed_ms: e, + power_w: Some(p), + cadence_rpm: Some(c), + speed_kph: v, + distance_m: d, + gradient_pct: g, + ..Default::default() + }) + }) + .collect(); + entries.push(LogEntry::End { at_ms: 2000 }); + + RawLog { + start: SessionStart { + start_unix_ms: START_UNIX_MS, + utc_offset_secs: 0, + product_name: "BikeControl".to_string(), + software_version: 100, + serial_number: 1, + ..Default::default() + }, + entries, + skipped_lines: 0, + clean_shutdown: true, + path: None, + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(s: &str) -> Vec { + s.as_bytes() + .chunks(2) + .map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()) + .collect() +} + +/// The complete encoding of [`tiny_ride`]. +const GOLDEN: &str = include_str!("golden/tiny_ride.hex"); + +#[test] +fn the_encoding_of_a_tiny_ride_is_stable() { + let (bytes, _) = encode_activity(&tiny_ride()).unwrap(); + + if std::env::var("PRINT_GOLDEN").is_ok() { + println!("{}", hex(&bytes)); + } + + let expected = unhex(GOLDEN.trim()); + assert_eq!( + hex(&bytes), + hex(&expected), + "the encoder's output changed; if that was intended, regenerate the \ + golden with PRINT_GOLDEN=1 after reviewing the diff" + ); +} + +#[test] +fn the_golden_file_is_internally_consistent() { + // The three checks an uploader makes before it looks at anything else. + let bytes = unhex(GOLDEN.trim()); + assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes)); + + // Header, spelled out rather than delegated. + assert_eq!(bytes[0], 14, "header size"); + assert_eq!(bytes[1], 0x20, "protocol version 2.0"); + assert_eq!(&bytes[8..12], b".FIT", "signature"); + + let declared = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + assert_eq!( + declared, + bytes.len() - 16, + "header data size must equal the bytes between the header and the CRC" + ); + + let header_crc = u16::from_le_bytes([bytes[12], bytes[13]]); + assert_eq!(header_crc, crc16(&bytes[0..12]), "header CRC"); + + let file_crc = u16::from_le_bytes([bytes[bytes.len() - 2], bytes[bytes.len() - 1]]); + assert_eq!( + file_crc, + crc16(&bytes[..bytes.len() - 2]), + "file CRC over header plus data" + ); + + // And the header we produce independently for that data size must match + // the one embedded in the golden. + assert_eq!(&bytes[0..14], &file_header(declared as u32)); +} + +#[test] +fn the_first_message_is_a_file_id_definition_with_the_expected_bytes() { + let bytes = unhex(GOLDEN.trim()); + let data = &bytes[14..]; + + // Definition message for file_id (global 0), local type 0. + assert_eq!(data[0], 0x40, "normal header, definition bit set, local 0"); + assert_eq!(data[1], 0x00, "reserved"); + assert_eq!(data[2], 0x00, "little-endian architecture"); + assert_eq!(&data[3..5], &[0x00, 0x00], "global message number 0 = file_id"); + let n_fields = data[5] as usize; + assert_eq!(n_fields, 6, "type, manufacturer, product, serial, time, name"); + + // Field 0 (type) is a one-byte enum; the first data message must set it to + // 4 (activity). This is the single most important byte in the file. + assert_eq!(&data[6..9], &[0x00, 0x01, 0x00], "field 0: 1 byte, enum"); + + let data_msg = 6 + n_fields * 3; + assert_eq!(data[data_msg], 0x00, "normal header, data, local 0"); + assert_eq!( + data[data_msg + 1], + 4, + "file_id.type must be 4 (activity), or nothing will import it" + ); + // manufacturer 255 (development), little endian. + assert_eq!(&data[data_msg + 2..data_msg + 4], &[0xFF, 0x00]); +} + +#[test] +fn the_golden_timestamp_is_the_fit_epoch_not_the_unix_epoch() { + let bytes = unhex(GOLDEN.trim()); + let expected = timestamp::from_unix_millis(START_UNIX_MS).unwrap(); + + // file_id.time_created sits at a known offset: 14 (header) + 6 (definition + // header) + 18 (six field definitions) + 1 (data header) + 1 (type) + // + 2 (manufacturer) + 2 (product) + 4 (serial) = 48. + let at = 14 + 6 + 6 * 3 + 1 + 1 + 2 + 2 + 4; + let encoded = u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]); + assert_eq!(encoded, expected); + + // Spelled out: the value must be the Unix time minus the FIT epoch... + assert_eq!( + i64::from(encoded), + START_UNIX_MS / 1000 - bikecontrol_fit::FIT_EPOCH_UNIX_SECS + ); + // ...and emphatically not the Unix time itself. + assert_ne!(i64::from(encoded), START_UNIX_MS / 1000); + // A 1989 date would decode below this bound. + assert!(encoded > 1_100_000_000, "would render as the 1990s"); +} + +#[test] +fn the_golden_still_decodes_with_an_independent_parser() { + // Ties the two test strategies together: the pinned bytes are not just + // stable, they are still a valid FIT file. + let bytes = unhex(GOLDEN.trim()); + let records = fitparser::from_bytes(&bytes).expect("golden bytes must still parse"); + let kinds: Vec = records.iter().map(|r| format!("{:?}", r.kind())).collect(); + assert_eq!(kinds, vec![ + "FileId", + "DeviceInfo", + "Event", + "Record", + "Record", + "Record", + "Lap", + "Event", + "Session", + "Activity", + ]); +} + +#[test] +fn definitions_are_emitted_once_each() { + // Seven message types, seven definition messages, no matter how many + // records. If this number grows, the definition cache has broken and every + // record is carrying its own schema. + let bytes = unhex(GOLDEN.trim()); + let definitions = count_definitions(&bytes); + assert_eq!(definitions, 7, "file_id, device_info, event, record, lap, session, activity"); + + // The same holds for a much longer ride. + let mut log = tiny_ride(); + log.entries.clear(); + for i in 0..600u64 { + log.entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200), + cadence_rpm: Some(90.0), + speed_kph: 30.0, + distance_m: (i as f64) * 8.33, + ..Default::default() + })); + } + log.entries.push(LogEntry::End { at_ms: 599_000 }); + let (long_bytes, _) = encode_activity(&log).unwrap(); + assert_eq!(count_definitions(&long_bytes), 7); +} + +/// Walk the data-records section and count definition messages. +/// +/// A deliberately independent re-implementation of the record framing: if the +/// encoder and this walker disagree about message sizes, the walk desynchronises +/// and the count comes out wrong. +fn count_definitions(bytes: &[u8]) -> usize { + let data = &bytes[14..bytes.len() - 2]; + // local message type -> total data-message body size + let mut sizes = [0usize; 16]; + let mut i = 0; + let mut definitions = 0; + + while i < data.len() { + let header = data[i]; + assert_eq!(header & 0x80, 0, "we never emit compressed timestamp headers"); + let local = (header & 0x0F) as usize; + if header & 0x40 != 0 { + definitions += 1; + let n = data[i + 5] as usize; + let mut total = 0; + for f in 0..n { + total += data[i + 6 + f * 3 + 1] as usize; + } + sizes[local] = total; + i += 6 + n * 3; + } else { + assert!(sizes[local] > 0, "data message before its definition"); + i += 1 + sizes[local]; + } + } + assert_eq!(i, data.len(), "message framing did not land exactly on the end"); + definitions +} + +#[test] +fn a_record_costs_a_fixed_and_small_number_of_bytes() { + // A tripwire on file size. The fixed cost is the seven definitions plus the + // summary messages; the marginal cost is one record. If the marginal cost + // jumps, a definition is being re-emitted per sample and an hour-long ride + // will produce a file uploaders throttle or reject. + let baseline = unhex(GOLDEN.trim()).len(); + + let mut log = tiny_ride(); + let extra = 100u64; + log.entries.clear(); + for i in 0..(3 + extra) { + log.entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(210), + cadence_rpm: Some(90.0), + speed_kph: 30.0, + distance_m: (i as f64) * 8.5, + ..Default::default() + })); + } + log.entries.push(LogEntry::End { + at_ms: (2 + extra) * 1000, + }); + let (bigger, _) = encode_activity(&log).unwrap(); + + let per_record = (bigger.len() - baseline) as f64 / extra as f64; + assert!( + (17.0..=19.0).contains(&per_record), + "expected ~18 bytes per record (1 header + timestamp, altitude, \ + distance, speed, grade, power, cadence), got {per_record:.1}" + ); + + // An hour at 1 Hz must stay comfortably within what an uploader accepts. + let hour = baseline as f64 + 3600.0 * per_record; + assert!(hour < 100_000.0, "an hour would be {hour:.0} bytes"); +} diff --git a/crates/fit/tests/golden/tiny_ride.hex b/crates/fit/tests/golden/tiny_ride.hex new file mode 100644 index 0000000..6255dd3 --- /dev/null +++ b/crates/fit/tests/golden/tiny_ride.hex @@ -0,0 +1 @@ +0e20d552f90100002e464954b2b140000000000600010001028402028403048c040486080c070004ff000100010000006066d64442696b65436f6e74726f6c00410000170007fd04860001020202840402840502841901001b0c07016066d64400ff00010064000542696b65436f6e74726f6c00420000150004fd0486000100010100040102026066d644000000430000140007fd0486020284050486060284090283070284040102036066d644c409000000008d200000d2005a036166d644c4095203000034216400dc005b036266d644c509b8060000db21c800e6005c440000130016fe0284fd04860001000101000204860704860804860904860d02840e02841101021201021302841402841502841602841701001801001901002701002904862d02830400006266d64409016066d644d0070000d0070000b80600009821db215b5cdc00e600000000000007023ac20100006400026266d644000400450000120016fe0284fd04860001000101000204860501000601000704860804860904860e02840f02841201021301021402841502841602841702841902841a02841c01003004860500006266d64408016066d644023ad0070000d0070000b80600009821db215b5cdc00e600000000000000010000c2010000460000220007fd0486000486010284020100030100040100050486066266d644d00700000100001a016266d6441a4b diff --git a/crates/fit/tests/roundtrip.rs b/crates/fit/tests/roundtrip.rs new file mode 100644 index 0000000..5d7e344 --- /dev/null +++ b/crates/fit/tests/roundtrip.rs @@ -0,0 +1,542 @@ +//! Round-trip our encoder's output through an *independent* FIT decoder. +//! +//! This is the strongest correctness check available without an actual upload. +//! `fitparser` is a third-party, widely-used FIT reader carrying its own copy +//! of the Garmin profile; it knows nothing about this crate. If it can decode +//! our file, validate both CRCs, name every message and field correctly, and +//! recover the physical quantities we put in, then the encoder agrees with an +//! outside party about what the FIT specification says — including all the +//! field numbers, scale factors and the epoch. + +use bikecontrol_fit::rawlog::{LogEntry, RawLog, Sample, SessionStart}; +use bikecontrol_fit::{encode_activity, timestamp}; +use chrono::{TimeZone, Utc}; +use fitparser::profile::MesgNum; +use fitparser::{FitDataRecord, Value}; + +const START_UNIX_MS: i64 = 1_785_931_200_000; // 2026-08-05T00:00:00Z + +fn session_start() -> SessionStart { + SessionStart { + start_unix_ms: START_UNIX_MS, + utc_offset_secs: 7200, + product_name: "BikeControl".to_string(), + software_version: 100, + serial_number: 424_242, + ..Default::default() + } +} + +/// A ride with every optional stream present: power, cadence, heart rate, +/// resistance, a climb, and a lap marker. +fn rich_ride(seconds: u64) -> RawLog { + let mut entries = Vec::new(); + let mut distance = 0.0f64; + for i in 0..seconds { + let speed_kph = 30.0 + (i % 5) as f32; + distance += f64::from(speed_kph) / 3.6; + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(200 + (i % 50) as i16), + cadence_rpm: Some(85.0 + (i % 10) as f32), + speed_kph, + distance_m: distance, + gradient_pct: 4.0, + elevation_gain_m: (distance * 0.04) as f32, + heart_rate_bpm: Some(140 + (i % 20) as u8), + resistance: Some(12), + ..Default::default() + })); + } + entries.push(LogEntry::Lap { + at_ms: (seconds / 2) * 1000, + from_controller: true, + }); + entries.push(LogEntry::End { + at_ms: (seconds - 1) * 1000, + }); + RawLog { + start: session_start(), + entries, + skipped_lines: 0, + clean_shutdown: true, + path: None, + } +} + +fn decode(bytes: &[u8]) -> Vec { + // `from_bytes` validates the header CRC and the file CRC on the way + // through; a checksum mistake fails here rather than silently at Strava. + fitparser::from_bytes(bytes).expect("independent decoder rejected our FIT file") +} + +fn of_kind(records: &[FitDataRecord], kind: MesgNum) -> Vec<&FitDataRecord> { + records.iter().filter(|r| r.kind() == kind).collect() +} + +fn field<'a>(rec: &'a FitDataRecord, name: &str) -> Option<&'a Value> { + rec.fields() + .iter() + .find(|f| f.name() == name) + .map(|f| f.value()) +} + +/// Look a field up under its classic name or its `enhanced_` component. +/// +/// We write the classic `speed`, `altitude`, `avg_speed` and `max_speed` +/// fields, which are the most widely understood. The FIT profile declares each +/// of them as a *component* of its `enhanced_` counterpart, so a +/// profile-aware decoder — including Strava's and Garmin's — presents the value +/// under the enhanced name. Either is a correct decode of what we wrote. +fn field_or_enhanced<'a>(rec: &'a FitDataRecord, name: &str) -> Option<&'a Value> { + field(rec, name).or_else(|| field(rec, &format!("enhanced_{name}"))) +} + +fn enhanced_num(rec: &FitDataRecord, name: &str) -> f64 { + as_f64( + field_or_enhanced(rec, name) + .unwrap_or_else(|| panic!("neither {name} nor enhanced_{name} in {:?}", rec.kind())), + ) +} + +fn as_f64(v: &Value) -> f64 { + match v { + Value::Float64(f) => *f, + Value::Float32(f) => f64::from(*f), + Value::UInt8(n) => f64::from(*n), + Value::UInt16(n) => f64::from(*n), + Value::UInt32(n) => f64::from(*n), + Value::SInt8(n) => f64::from(*n), + Value::SInt16(n) => f64::from(*n), + Value::SInt32(n) => f64::from(*n), + Value::Enum(n) | Value::Byte(n) => f64::from(*n), + Value::UInt8z(n) => f64::from(*n), + Value::UInt16z(n) => f64::from(*n), + Value::UInt32z(n) => f64::from(*n), + Value::SInt64(n) => *n as f64, + Value::UInt64(n) | Value::UInt64z(n) => *n as f64, + other => panic!("expected a number, got {other:?}"), + } +} + +fn num(rec: &FitDataRecord, name: &str) -> f64 { + as_f64(field(rec, name).unwrap_or_else(|| panic!("field {name} missing from {:?}", rec.kind()))) +} + +#[test] +fn an_independent_decoder_accepts_the_file() { + let (bytes, summary) = encode_activity(&rich_ride(120)).unwrap(); + let records = decode(&bytes); + assert!(!records.is_empty()); + // Every message we claimed to write is there, named correctly. + assert_eq!(of_kind(&records, MesgNum::FileId).len(), 1); + assert_eq!(of_kind(&records, MesgNum::DeviceInfo).len(), 1); + assert_eq!(of_kind(&records, MesgNum::Record).len(), summary.records); + assert_eq!(of_kind(&records, MesgNum::Lap).len(), 2); + assert_eq!(of_kind(&records, MesgNum::Session).len(), 1); + assert_eq!(of_kind(&records, MesgNum::Activity).len(), 1); + assert_eq!(of_kind(&records, MesgNum::Event).len(), 2); +} + +#[test] +fn the_file_id_marks_it_as_an_activity() { + let (bytes, _) = encode_activity(&rich_ride(30)).unwrap(); + let records = decode(&bytes); + let file_id = of_kind(&records, MesgNum::FileId)[0]; + + // fitparser resolves the enum against its own profile copy. + assert_eq!( + field(file_id, "type").unwrap().to_string(), + "activity", + "FR-8.2: the file must declare itself an activity" + ); + assert_eq!( + field(file_id, "product_name").unwrap().to_string(), + "BikeControl" + ); + assert!(field(file_id, "time_created").is_some()); + assert_eq!(num(file_id, "serial_number"), 424_242.0); +} + +#[test] +fn timestamps_decode_to_the_right_wall_clock_instant() { + // The epoch check that matters: an independent decoder, applying its own + // notion of the FIT epoch, must recover the instant we encoded. If we had + // used the Unix epoch this activity would decode as 1989. + let (bytes, _) = encode_activity(&rich_ride(60)).unwrap(); + let records = decode(&bytes); + + let expected_start = Utc.timestamp_millis_opt(START_UNIX_MS).unwrap(); + let first_record = of_kind(&records, MesgNum::Record)[0]; + match field(first_record, "timestamp").unwrap() { + Value::Timestamp(ts) => { + assert_eq!(ts.with_timezone(&Utc), expected_start); + assert_eq!(ts.with_timezone(&Utc).format("%Y").to_string(), "2026"); + } + other => panic!("timestamp did not decode as a timestamp: {other:?}"), + } + + // ...and the last record is 59 seconds later. + let last = *of_kind(&records, MesgNum::Record).last().unwrap(); + match field(last, "timestamp").unwrap() { + Value::Timestamp(ts) => { + assert_eq!( + (ts.with_timezone(&Utc) - expected_start).num_seconds(), + 59, + "record spacing must be 1 Hz" + ); + } + other => panic!("unexpected {other:?}"), + } +} + +#[test] +fn record_fields_decode_to_the_physical_values_we_encoded() { + // fitparser applies the profile's scale and offset, so these assertions + // check our scaling against an outside source rather than against + // ourselves. + let log = RawLog { + start: session_start(), + entries: vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + power_w: Some(275), + cadence_rpm: Some(93.0), + speed_kph: 36.0, // 10 m/s + distance_m: 0.0, + gradient_pct: 0.0, + heart_rate_bpm: Some(148), + altitude_m: Some(250.0), + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 1000, + power_w: Some(275), + cadence_rpm: Some(93.0), + speed_kph: 36.0, + distance_m: 1234.5, + gradient_pct: -6.25, + heart_rate_bpm: Some(148), + altitude_m: Some(240.0), + ..Default::default() + }), + LogEntry::End { at_ms: 1000 }, + ], + skipped_lines: 0, + clean_shutdown: true, + path: None, + }; + let (bytes, _) = encode_activity(&log).unwrap(); + let records = decode(&bytes); + let recs = of_kind(&records, MesgNum::Record); + assert_eq!(recs.len(), 2); + + assert_eq!(num(recs[0], "power"), 275.0, "watts"); + assert_eq!(num(recs[0], "cadence"), 93.0, "rpm"); + assert_eq!(num(recs[0], "heart_rate"), 148.0, "bpm"); + assert!( + (enhanced_num(recs[0], "speed") - 10.0).abs() < 1e-6, + "speed must decode as 10 m/s, got {}", + enhanced_num(recs[0], "speed") + ); + assert!( + (enhanced_num(recs[0], "altitude") - 250.0).abs() < 0.2, + "altitude must decode as metres, got {}", + enhanced_num(recs[0], "altitude") + ); + assert!( + (num(recs[1], "distance") - 1234.5).abs() < 0.01, + "distance must decode as metres, got {}", + num(recs[1], "distance") + ); + assert!( + (num(recs[1], "grade") + 6.25).abs() < 0.01, + "grade must decode as percent, got {}", + num(recs[1], "grade") + ); + + // Units come from the decoder's profile, so they confirm we picked the + // fields we think we picked. + let units = |name: &str| { + recs[0] + .fields() + .iter() + .find(|f| f.name() == name || f.name() == format!("enhanced_{name}")) + .unwrap_or_else(|| panic!("no field {name}")) + .units() + .to_string() + }; + assert_eq!(units("power"), "watts"); + assert_eq!(units("speed"), "m/s"); + assert_eq!(units("distance"), "m"); + assert_eq!(units("altitude"), "m"); + assert_eq!(units("heart_rate"), "bpm"); + assert_eq!(units("cadence"), "rpm"); +} + +#[test] +fn session_aggregates_decode_under_the_right_names() { + // The guard against the lap/session field-number divergence: if we had used + // lap numbering in the session message, avg_power would decode as + // max_cadence and this test would catch it. + let log = rich_ride(100); + let (bytes, summary) = encode_activity(&log).unwrap(); + let records = decode(&bytes); + let session = of_kind(&records, MesgNum::Session)[0]; + + assert_eq!(field(session, "sport").unwrap().to_string(), "cycling"); + assert_eq!( + field(session, "sub_sport").unwrap().to_string(), + "virtual_activity", + "FR-8.3: this is what makes Strava file it as a Virtual Ride" + ); + assert_eq!( + num(session, "total_elapsed_time"), + summary.total_elapsed_s, + "seconds" + ); + assert_eq!(num(session, "total_timer_time"), summary.total_timer_s); + assert!( + (num(session, "total_distance") - summary.total_distance_m).abs() < 0.05, + "metres" + ); + assert_eq!( + num(session, "avg_power"), + f64::from(summary.avg_power_w.unwrap()) + ); + assert_eq!( + num(session, "max_power"), + f64::from(summary.max_power_w.unwrap()) + ); + assert_eq!(num(session, "total_ascent"), f64::from(summary.total_ascent_m)); + assert_eq!(num(session, "num_laps"), 2.0); + assert_eq!(num(session, "first_lap_index"), 0.0); + + // Averages must be plausible for the ride we synthesised. + let avg_cad = num(session, "avg_cadence"); + assert!((85.0..=95.0).contains(&avg_cad), "avg_cadence {avg_cad}"); + let max_hr = num(session, "max_heart_rate"); + assert!((150.0..=165.0).contains(&max_hr), "max_heart_rate {max_hr}"); + let avg_speed = enhanced_num(session, "avg_speed"); + assert!((7.0..=10.0).contains(&avg_speed), "avg_speed m/s {avg_speed}"); +} + +#[test] +fn lap_aggregates_decode_under_the_right_names() { + let (bytes, _) = encode_activity(&rich_ride(100)).unwrap(); + let records = decode(&bytes); + let laps = of_kind(&records, MesgNum::Lap); + assert_eq!(laps.len(), 2, "one lap marker splits the ride in two"); + + for (i, lap) in laps.iter().enumerate() { + assert_eq!(num(lap, "message_index"), i as f64); + assert_eq!(field(lap, "sport").unwrap().to_string(), "cycling"); + assert!(num(lap, "total_elapsed_time") > 0.0); + assert!(num(lap, "total_distance") > 0.0); + let avg_power = num(lap, "avg_power"); + assert!( + (200.0..=250.0).contains(&avg_power), + "lap {i} avg_power decoded as {avg_power}; a lap/session field-number \ + mix-up would show up here" + ); + let avg_cadence = num(lap, "avg_cadence"); + assert!((85.0..=95.0).contains(&avg_cadence), "lap {i} avg_cadence {avg_cadence}"); + } + + assert_eq!( + field(laps[0], "lap_trigger").unwrap().to_string(), + "manual", + "a rider-pressed lap" + ); + assert_eq!( + field(laps[1], "lap_trigger").unwrap().to_string(), + "session_end", + "the final lap is closed by the end of the session" + ); + + // The laps must tile the session exactly. + let session = of_kind(&records, MesgNum::Session)[0]; + let lap_elapsed: f64 = laps.iter().map(|l| num(l, "total_elapsed_time")).sum(); + assert!( + (lap_elapsed - num(session, "total_elapsed_time")).abs() < 0.01, + "laps sum to {lap_elapsed}, session says {}", + num(session, "total_elapsed_time") + ); + let lap_distance: f64 = laps.iter().map(|l| num(l, "total_distance")).sum(); + assert!( + (lap_distance - num(session, "total_distance")).abs() < 1.0, + "laps sum to {lap_distance} m, session says {} m", + num(session, "total_distance") + ); +} + +#[test] +fn the_activity_message_closes_the_file() { + let (bytes, summary) = encode_activity(&rich_ride(45)).unwrap(); + let records = decode(&bytes); + let activity = of_kind(&records, MesgNum::Activity)[0]; + + assert_eq!(num(activity, "num_sessions"), 1.0); + assert_eq!(num(activity, "total_timer_time"), summary.total_timer_s); + assert_eq!(field(activity, "type").unwrap().to_string(), "manual"); + assert_eq!(field(activity, "event").unwrap().to_string(), "activity"); + assert_eq!(field(activity, "event_type").unwrap().to_string(), "stop"); + assert!(field(activity, "local_timestamp").is_some()); + + // It must be the last message in the file. + assert_eq!( + records.last().unwrap().kind(), + MesgNum::Activity, + "the activity message closes an activity file" + ); +} + +#[test] +fn timer_events_bracket_the_ride() { + let (bytes, _) = encode_activity(&rich_ride(30)).unwrap(); + let records = decode(&bytes); + let events = of_kind(&records, MesgNum::Event); + assert_eq!(events.len(), 2); + assert_eq!(field(events[0], "event").unwrap().to_string(), "timer"); + assert_eq!(field(events[0], "event_type").unwrap().to_string(), "start"); + assert_eq!(field(events[1], "event_type").unwrap().to_string(), "stop_all"); +} + +#[test] +fn messages_appear_in_the_order_an_uploader_expects() { + let (bytes, _) = encode_activity(&rich_ride(60)).unwrap(); + let records = decode(&bytes); + let kinds: Vec = records.iter().map(|r| r.kind()).collect(); + + assert_eq!(kinds[0], MesgNum::FileId, "file_id must come first"); + + let first_record = kinds.iter().position(|k| *k == MesgNum::Record).unwrap(); + let first_lap = kinds.iter().position(|k| *k == MesgNum::Lap).unwrap(); + let session = kinds.iter().position(|k| *k == MesgNum::Session).unwrap(); + let activity = kinds.iter().position(|k| *k == MesgNum::Activity).unwrap(); + + assert!(first_record < first_lap, "a lap follows the records it covers"); + assert!(first_lap < session, "laps precede the session"); + assert!(session < activity, "the session precedes the activity"); +} + +#[test] +fn a_ride_without_a_heart_rate_strap_carries_no_heart_rate_field() { + // An all-invalid stream is worse than an absent one: some importers render + // it as a flat zero trace. + let log = RawLog { + start: session_start(), + entries: vec![ + LogEntry::Sample(Sample { + elapsed_ms: 0, + power_w: Some(200), + speed_kph: 30.0, + ..Default::default() + }), + LogEntry::Sample(Sample { + elapsed_ms: 1000, + power_w: Some(210), + speed_kph: 30.0, + distance_m: 8.3, + ..Default::default() + }), + LogEntry::End { at_ms: 1000 }, + ], + skipped_lines: 0, + clean_shutdown: true, + path: None, + }; + let (bytes, _) = encode_activity(&log).unwrap(); + let records = decode(&bytes); + for rec in of_kind(&records, MesgNum::Record) { + assert!(field(rec, "heart_rate").is_none()); + assert!(field(rec, "cadence").is_none()); + assert!(field(rec, "power").is_some()); + } +} + +#[test] +fn a_ble_dropout_leaves_a_hole_the_decoder_can_see() { + // FR-8.5: the ride survives a dropout. The records simply skip the missing + // seconds, which is exactly how a Garmin device represents a sensor + // disconnect — no decoder treats it as corruption. + let mut entries = Vec::new(); + for i in (0..10u64).chain(40..50u64) { + entries.push(LogEntry::Sample(Sample { + elapsed_ms: i * 1000, + power_w: Some(220), + speed_kph: 36.0, + distance_m: (i * 10) as f64, + ..Default::default() + })); + } + entries.push(LogEntry::Gap { + at_ms: 9_000, + until_ms: Some(40_000), + reason: "peripheral disconnected".into(), + }); + entries.push(LogEntry::End { at_ms: 49_000 }); + let log = RawLog { + start: session_start(), + entries, + skipped_lines: 0, + clean_shutdown: true, + path: None, + }; + + let (bytes, summary) = encode_activity(&log).unwrap(); + assert_eq!(summary.gaps, 1); + let records = decode(&bytes); + let recs = of_kind(&records, MesgNum::Record); + assert_eq!(recs.len(), 20); + + let stamps: Vec = recs + .iter() + .map(|r| match field(r, "timestamp").unwrap() { + Value::Timestamp(t) => t.timestamp(), + other => panic!("{other:?}"), + }) + .collect(); + let jump = stamps[10] - stamps[9]; + assert_eq!(jump, 31, "the dropout shows as a 31 s hole in the records"); + + // The session still spans the whole ride. + let session = of_kind(&records, MesgNum::Session)[0]; + assert_eq!(num(session, "total_elapsed_time"), 49.0); +} + +#[test] +fn a_long_ride_encodes_and_decodes() { + // An hour at 1 Hz — enough messages to exercise definition reuse, and a + // realistic file size for an upload. + let (bytes, summary) = encode_activity(&rich_ride(3600)).unwrap(); + assert_eq!(summary.records, 3600); + let records = decode(&bytes); + assert_eq!(of_kind(&records, MesgNum::Record).len(), 3600); + + // Definition reuse: one definition for 3600 records, not 3600 of them. + let bytes_per_record = bytes.len() as f64 / 3600.0; + assert!( + bytes_per_record < 30.0, + "{bytes_per_record:.1} bytes per record suggests definitions are being \ + re-emitted for every message" + ); +} + +#[test] +fn the_fit_epoch_constant_agrees_with_the_decoder() { + // Belt and braces: derive the epoch from a decoded file rather than + // trusting our own constant. + let (bytes, _) = encode_activity(&rich_ride(2)).unwrap(); + let records = decode(&bytes); + let first = of_kind(&records, MesgNum::Record)[0]; + let decoded_unix = match field(first, "timestamp").unwrap() { + Value::Timestamp(t) => t.timestamp(), + other => panic!("{other:?}"), + }; + let encoded_fit = timestamp::from_unix_millis(START_UNIX_MS).unwrap(); + assert_eq!( + decoded_unix - i64::from(encoded_fit), + bikecontrol_fit::FIT_EPOCH_UNIX_SECS + ); +} diff --git a/src-tauri/src/derive.rs b/src-tauri/src/derive.rs index 72a21d2..4f62f7c 100644 --- a/src-tauri/src/derive.rs +++ b/src-tauri/src/derive.rs @@ -30,8 +30,10 @@ const SPEED_WINDOW_S: f64 = 45.0; 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; +/// Below this the rider is coasting to a halt rather than riding, so the ETA +/// stops tracking and holds. Set well above walking pace: an ETA computed from +/// 1 km/h is arithmetically valid and completely useless. +const MIN_ETA_SPEED_KPH: f32 = 5.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -227,7 +229,9 @@ impl Deriver { (_, 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 { + // A paused ride holds too — the clock is not running, + // so neither should the estimate. + if running && 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; @@ -289,3 +293,177 @@ pub struct RideFrame { pub snapshot: RideSnapshot, pub derived: Derived, } + +#[cfg(test)] +mod tests { + use super::*; + use bikecontrol_core::profile::{Block, Channel, Extent, Segment}; + use bikecontrol_core::types::Telemetry; + + use crate::profile_view; + + fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot { + RideSnapshot { + elapsed_ms: (elapsed_s * 1000.0) as u64, + telemetry: Telemetry { power_w: Some(200), ..Telemetry::default() }, + virtual_speed_kph: speed_kph, + virtual_distance_m: distance_m, + gradient_pct: 0.0, + elevation_gain_m: 0.0, + mode: bikecontrol_core::types::ControlMode::Profile, + target: None, + profile_progress: None, + } + } + + fn timed_profile() -> Profile { + Profile { + name: "timed".into(), + description: None, + looping: false, + blocks: vec![Block::Constant { + channel: Channel::Power, + value: 200.0, + extent: Extent::Seconds(600.0), + }], + } + } + + fn distance_profile(looping: bool) -> Profile { + Profile { + name: "distance".into(), + description: None, + looping, + blocks: vec![Block::Segments { + segments: vec![ + Segment { distance_m: 1000.0, gradient_pct: 4.0 }, + Segment { distance_m: 1000.0, gradient_pct: -2.0 }, + ], + }], + } + } + + /// A time-based profile knows exactly how long is left. No estimation, no + /// dependence on speed. + #[test] + fn time_based_eta_is_exact() { + let profile = timed_profile(); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + let out = d.update(&snapshot(120.0, 0.0, 0.0), true, Some(&profile), Some(&geom)); + assert_eq!(out.eta_kind, EtaKind::Exact); + assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6); + } + + /// Distance-based ETA uses the smoothed speed, not the instantaneous one. + #[test] + fn distance_based_eta_smooths_speed() { + let profile = distance_profile(false); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + // Ride at 36 km/h (10 m/s) until the speed window is full. + let mut t = 0.0; + for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 { + t = i as f64 * 0.25; + d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom)); + } + t += 0.25; + let steady = d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom)); + t += 0.25; + // One absurd sample: 90 km/h, two and a half times reality. + let spike = d.update(&snapshot(t, t * 10.0, 90.0), true, Some(&profile), Some(&geom)); + assert_eq!(spike.eta_kind, EtaKind::Estimated); + let base = steady.time_remaining_s.unwrap(); + let drift = (spike.time_remaining_s.unwrap() - base).abs(); + assert!( + drift / base < 0.02, + "one noisy sample moved the ETA by {drift:.1}s ({:.1}%)", + drift / base * 100.0 + ); + } + + /// Stopping must hold the last estimate, not diverge to infinity. + #[test] + fn stopping_holds_the_last_eta() { + let profile = distance_profile(false); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + for i in 1..=200 { + let t = i as f64 * 0.25; + d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom)); + } + let moving = d.update(&snapshot(50.25, 402.0, 28.8), true, Some(&profile), Some(&geom)); + assert_eq!(moving.eta_kind, EtaKind::Estimated); + + // Now stop dead for long enough to flush the whole speed window. + let mut prev = moving; + let mut stopped = moving; + for i in 1..=400 { + let t = 50.25 + i as f64 * 0.25; + prev = stopped; + stopped = d.update(&snapshot(t, 402.0, 0.0), true, Some(&profile), Some(&geom)); + } + // The contract: finite, flagged as held, and no longer changing. + assert_eq!(stopped.eta_kind, EtaKind::Held); + let eta = stopped.time_remaining_s.expect("held ETA must still be a number"); + assert!(eta.is_finite(), "ETA diverged when the rider stopped"); + assert_eq!(prev.time_remaining_s, stopped.time_remaining_s, "held ETA still drifting"); + } + + /// Pausing freezes the estimate rather than letting it creep. + #[test] + fn pausing_holds_the_eta() { + let profile = distance_profile(false); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + for i in 1..=200 { + let t = i as f64 * 0.25; + d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom)); + } + let paused = d.update(&snapshot(50.25, 402.0, 28.8), false, Some(&profile), Some(&geom)); + assert_eq!(paused.eta_kind, EtaKind::Held); + assert!(paused.time_remaining_s.unwrap().is_finite()); + } + + /// A looping profile has no finish, so it reports a lap, never an ETA. + #[test] + fn looping_profile_reports_lap_not_eta() { + let profile = distance_profile(true); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + let out = d.update(&snapshot(300.0, 4500.0, 30.0), true, Some(&profile), Some(&geom)); + assert_eq!(out.eta_kind, EtaKind::Looping); + assert_eq!(out.time_remaining_s, None); + assert_eq!(out.loop_index, Some(3)); + // Position wraps into the profile rather than running off the end. + assert!(out.position_x < geom.total_x); + } + + /// No profile means no invented numbers. + #[test] + fn no_profile_means_unavailable() { + let mut d = Deriver::default(); + let out = d.update(&snapshot(60.0, 500.0, 30.0), true, None, None); + assert_eq!(out.eta_kind, EtaKind::Unavailable); + assert_eq!(out.time_remaining_s, None); + } + + /// Rolling power must lag a step change — that is the entire point of it + /// (FR-9.11). + #[test] + fn rolling_power_smooths_a_step() { + let profile = timed_profile(); + let (_, geom) = profile_view::build(&profile, "test"); + let mut d = Deriver::default(); + let mut snap = snapshot(0.0, 0.0, 30.0); + for i in 1..=40 { + snap.elapsed_ms = (i * 250) as u64; + snap.telemetry.power_w = Some(100); + d.update(&snap, true, Some(&profile), Some(&geom)); + } + snap.elapsed_ms = 10_250; + snap.telemetry.power_w = Some(600); + let out = d.update(&snap, true, Some(&profile), Some(&geom)); + assert!(out.rolling_power_w < 250.0, "rolling power tracked the spike too closely"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8328f17..3bdcccf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,12 @@ pub fn run() { handle.state::().lock().devices.start_scan(); state::spawn_ride_loop(handle.clone()); state::spawn_device_loop(handle.clone()); + // `BIKECONTROL_DEMO=1` opens straight onto a running ride with the + // bundled GPX loaded. Purely a development convenience — it makes + // the ride screen reviewable without clicking through first. + if std::env::var("BIKECONTROL_DEMO").is_ok() { + state::start_demo(&handle); + } state::emit_devices(&handle); state::emit_ride_state(&handle); Ok(()) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a46ac8c..7c5d11d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -236,6 +236,30 @@ fn transmit(_app: &AppHandle, target: ControlTarget) { tracing::debug!(?target, "control target (no trainer attached — mock backend)"); } +/// Load the bundled GPX and start riding it. Development only — see the +/// `BIKECONTROL_DEMO` check in `lib.rs`. +pub fn start_demo(app: &AppHandle) { + let Some(sample) = crate::samples::all().into_iter().find(|s| s.is_gpx) else { + return; + }; + let profile = match bikecontrol_core::gpx::import( + &sample.text, + &sample.name, + &bikecontrol_core::gpx::SmoothingConfig::default(), + ) { + Ok(p) => p, + Err(e) => { + tracing::warn!(%e, "demo profile failed to import"); + return; + } + }; + let (view, geom) = crate::profile_view::build(&profile, "demo"); + let state = app.state::(); + let mut inner = state.lock(); + inner.set_profile(profile, view, geom); + inner.inputs.status = RideStatus::Running; +} + /// 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::(); diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..8cddc28 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,143 @@ + + + + +
+ {#if fatal} +
+

BikeControl could not start

+

{fatal}

+
+ {:else if !ready} +
Starting…
+ {:else if app.screen === 'ride'} + + {:else} + + {/if} + + {#if app.showProfiles}{/if} + {#if app.showHelp}{/if} + +
+ + diff --git a/ui/src/components/ConnectionScreen.svelte b/ui/src/components/ConnectionScreen.svelte new file mode 100644 index 0000000..3c5bee9 --- /dev/null +++ b/ui/src/components/ConnectionScreen.svelte @@ -0,0 +1,374 @@ + + +
+
+
+

Devices

+

+ {#if scanning} + Scanning for Bluetooth peripherals… + {:else} + Scan stopped. + {/if} +

+
+
+ {#if scanning} + + {:else} + + {/if} + +
+
+ + {#if !trainerReady} +
+ + No trainer under control yet. The ride screen will show simulated + telemetry until an FTMS trainer accepts the control point. +
+ {/if} + +
+ {#each devices as device (device.id)} + {@const conn = connectionText(device.state)} + {@const bars = rssiBars(device.rssi)} +
+
+ {device.name} + {KIND_LABEL[device.kind]} · {device.address} + {#if device.error} + {device.error} + {/if} +
+ +
+ + {#each [1, 2, 3, 4] as bar} + + {/each} + + {device.rssi} +
+ + +
+ + Bluetooth + + {busy(device) ? conn.label + '…' : conn.label} + + + + {#if device.kind === 'trainer'} + + FTMS control + + {device.controlAcquired ? 'Acquired' : 'Not acquired'} + + + {:else if device.kind === 'clickLeft' || device.kind === 'clickRight'} + + Zwift unlock + 0} + class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0} + > + + {#if (device.unlockExpiresInS ?? 0) > 0} + {Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left + {:else} + Expired — re-unlock in Zwift + {/if} + + + {:else if device.batteryPct != null} + + Battery + {device.batteryPct}% + + {/if} +
+ +
+ {#if isConnected(device)} + + {:else} + + {/if} + +
+
+ {/each} + + {#if devices.length === 0} + +
+

Nothing found yet

+

Most devices sleep until you touch them. To wake them:

+
    +
  • Trainer — turn the pedals for a few seconds.
  • +
  • Zwift Click — press any button on the pod.
  • +
  • Heart rate strap — wet the contacts and put it on.
  • +
+

+ They will appear here as soon as they advertise. Scanning continues in the background. +

+
+ {/if} +
+
+ + diff --git a/ui/src/components/ControlBar.svelte b/ui/src/components/ControlBar.svelte new file mode 100644 index 0000000..9ed4727 --- /dev/null +++ b/ui/src/components/ControlBar.svelte @@ -0,0 +1,109 @@ + + +
+
+ + + +
+ +
+ + +
+ +
+ + {#if running} + + {:else} + + {/if} + + +
+
+ + diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte new file mode 100644 index 0000000..0a382d1 --- /dev/null +++ b/ui/src/components/HelpOverlay.svelte @@ -0,0 +1,108 @@ + + +
(app.showHelp = false)} + onkeydown={(e) => e.key === 'Escape' && (app.showHelp = false)} +>
+ +
+

Keyboard

+
+ {#each BINDINGS as [key, what]} +
+
{key}
+
{what}
+
+ {/each} +
+

+ Every one of these has an on-screen equivalent in the control bar, and each will map to a + Zwift Click button once the controller client lands. +

+
+ + diff --git a/ui/src/components/ProfileDrawer.svelte b/ui/src/components/ProfileDrawer.svelte new file mode 100644 index 0000000..0ff8cec --- /dev/null +++ b/ui/src/components/ProfileDrawer.svelte @@ -0,0 +1,394 @@ + + +
e.key === 'Escape' && close()} +>
+ + + + diff --git a/ui/src/components/RideScreen.svelte b/ui/src/components/RideScreen.svelte new file mode 100644 index 0000000..8c856a3 --- /dev/null +++ b/ui/src/components/RideScreen.svelte @@ -0,0 +1,346 @@ + + +
+ +
+
+

{profile?.name ?? 'No route'}

+ {#if profile?.description} +

{profile.description}

+ {:else} +

Manual control — load a profile to ride terrain.

+ {/if} +
+ +
+ {#if ride?.source === 'mock'} + Simulated — no trainer + {/if} + {statusChip.label} + {MODE_LABEL[ride?.mode ?? 'ManualGrade']} + Target {targetText(ride?.target ?? null)} + +
+
+ + +
+ +
+ + +
+ + + + +
+ + +
+ + + + + +
+ + +
+ + + + + + +
+
+
+ Power + +
+
+ Gradient + +
+
+
+ + +
+ + diff --git a/ui/src/components/RouteChart.svelte b/ui/src/components/RouteChart.svelte index f03abd5..d534a55 100644 --- a/ui/src/components/RouteChart.svelte +++ b/ui/src/components/RouteChart.svelte @@ -113,25 +113,23 @@ ); } - onMount(() => { - build(); - return () => { - disposeSize?.(); - plot?.destroy(); - }; + onMount(() => () => { + disposeSize?.(); + plot?.destroy(); }); - // Rebuild only when the route itself changes; otherwise just re-split. - let builtFor = $state(null); + // Rebuild only when the route itself changes; otherwise just re-split, which + // is a single array walk rather than a chart teardown. + let builtFor: string | null = null; $effect(() => { - const key = profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null; - if (key !== builtFor) { - builtFor = key; - if (key) build(); - else { - plot?.destroy(); - plot = null; - } + const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null; + if (key === builtFor) return; + builtFor = key; + if (key) { + build(); + } else { + plot?.destroy(); + plot = null; } }); diff --git a/ui/src/components/StreamChart.svelte b/ui/src/components/StreamChart.svelte index a8271f7..36bfea5 100644 --- a/ui/src/components/StreamChart.svelte +++ b/ui/src/components/StreamChart.svelte @@ -78,7 +78,7 @@ } : {}, }; - plot = new uPlot(opts, history.view() as unknown as uPlot.AlignedData, host); + plot = new uPlot(opts, seed() as unknown as uPlot.AlignedData, host); dispose = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h })); return () => { dispose?.(); @@ -86,6 +86,12 @@ }; }); + /** uPlot dislikes zero-length series; seed with a single flat point. */ + function seed(): (Float64Array | number[])[] { + if (history.length > 1) return history.view(); + return [[0], ...series.map(() => [0])]; + } + function formatClock(v: number): string { const m = Math.floor(v / 60); return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`; diff --git a/ui/src/components/Toasts.svelte b/ui/src/components/Toasts.svelte new file mode 100644 index 0000000..82cbef0 --- /dev/null +++ b/ui/src/components/Toasts.svelte @@ -0,0 +1,55 @@ + + +
+ {#each app.toasts as t (t.id)} + + {/each} +
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index b8f7d25..41076bd 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -48,6 +48,9 @@ class AppStore { this.ride = ride; this.devices = devices; this.samples = samples; + // A ride already in progress (a reload, or an autostart) belongs on screen + // immediately — nobody wants to click past a device list mid-effort. + if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride'; await subscribe({ onFrame: (f) => this.onFrame(f), diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..646be01 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,8 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +const target = document.getElementById('app'); +if (!target) throw new Error('#app mount point missing from index.html'); + +export default mount(App, { target });