Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<pct>`, `sim=<pct>`, `resistance=<level>` or `power=<watts>`, 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.
|
||||
@@ -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<u16>,
|
||||
/// Peak power. `None` if no sample reported power.
|
||||
pub max_power_w: Option<u16>,
|
||||
/// Energy in kilocalories, from the trainer if it reports it and otherwise
|
||||
/// derived from mechanical work.
|
||||
pub total_calories: Option<u16>,
|
||||
/// 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<f64> {
|
||||
@@ -181,8 +188,13 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, 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<u8>, 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![
|
||||
|
||||
+48
-12
@@ -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<u8>,
|
||||
/// 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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
+90
-12
@@ -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<i16>,
|
||||
/// Cadence, rpm.
|
||||
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
|
||||
pub cadence_rpm: Option<f32>,
|
||||
/// 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<f32>,
|
||||
/// Heart rate, bpm, if a strap is paired.
|
||||
#[serde(rename = "h", default, skip_serializing_if = "Option::is_none")]
|
||||
pub heart_rate_bpm: Option<u8>,
|
||||
/// 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<u64>)> = 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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<LogEntry> = 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<u8> {
|
||||
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<String> = 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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0e20d552f90100002e464954b2b140000000000600010001028402028403048c040486080c070004ff000100010000006066d64442696b65436f6e74726f6c00410000170007fd04860001020202840402840502841901001b0c07016066d64400ff00010064000542696b65436f6e74726f6c00420000150004fd0486000100010100040102026066d644000000430000140007fd0486020284050486060284090283070284040102036066d644c409000000008d200000d2005a036166d644c4095203000034216400dc005b036266d644c509b8060000db21c800e6005c440000130016fe0284fd04860001000101000204860704860804860904860d02840e02841101021201021302841402841502841602841701001801001901002701002904862d02830400006266d64409016066d644d0070000d0070000b80600009821db215b5cdc00e600000000000007023ac20100006400026266d644000400450000120016fe0284fd04860001000101000204860501000601000704860804860904860e02840f02841201021301021402841502841602841702841902841a02841c01003004860500006266d64408016066d644023ad0070000d0070000b80600009821db215b5cdc00e600000000000000010000c2010000460000220007fd0486000486010284020100030100040100050486066266d644d00700000100001a016266d6441a4b
|
||||
@@ -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<FitDataRecord> {
|
||||
// `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<MesgNum> = 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<i64> = 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
|
||||
);
|
||||
}
|
||||
+181
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ pub fn run() {
|
||||
handle.state::<AppState>().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(())
|
||||
|
||||
@@ -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::<AppState>();
|
||||
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::<AppState>();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { app } from './lib/app.svelte';
|
||||
import { api, inTauri } from './lib/bridge';
|
||||
import ConnectionScreen from './components/ConnectionScreen.svelte';
|
||||
import HelpOverlay from './components/HelpOverlay.svelte';
|
||||
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
||||
import RideScreen from './components/RideScreen.svelte';
|
||||
import Toasts from './components/Toasts.svelte';
|
||||
|
||||
let ready = $state(false);
|
||||
let fatal = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!inTauri) {
|
||||
fatal = 'Not running inside the Tauri shell — start the app with `cargo tauri dev`.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.init();
|
||||
ready = true;
|
||||
} catch (e) {
|
||||
fatal = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyboard control (FR-3.19). Handled once, here, and dispatched straight to
|
||||
* Rust — the frontend never applies a step itself, it only asks.
|
||||
*/
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const step = e.shiftKey ? 2 : 0.5;
|
||||
const run = (fn: () => Promise<unknown>) => {
|
||||
e.preventDefault();
|
||||
app.run(fn);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
return run(() => api.nudgeGradient(step));
|
||||
case 'ArrowDown':
|
||||
return run(() => api.nudgeGradient(-step));
|
||||
case '0':
|
||||
return run(() => api.resetGradient());
|
||||
case ' ':
|
||||
return run(() => api.togglePause());
|
||||
case 'm':
|
||||
case 'M':
|
||||
return run(() => api.cycleMode());
|
||||
case 'l':
|
||||
case 'L':
|
||||
return run(() => api.markLap());
|
||||
case ']':
|
||||
return run(() => bumpTarget(1));
|
||||
case '[':
|
||||
return run(() => bumpTarget(-1));
|
||||
case 'p':
|
||||
case 'P':
|
||||
e.preventDefault();
|
||||
app.showProfiles = !app.showProfiles;
|
||||
return;
|
||||
case 'd':
|
||||
case 'D':
|
||||
app.screen = 'connect';
|
||||
return;
|
||||
case 'r':
|
||||
case 'R':
|
||||
app.screen = 'ride';
|
||||
return;
|
||||
case '?':
|
||||
app.showHelp = !app.showHelp;
|
||||
return;
|
||||
case 'Escape':
|
||||
app.showHelp = false;
|
||||
app.showProfiles = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** `[` / `]` adjust whichever target the active mode actually uses. */
|
||||
async function bumpTarget(dir: number): Promise<unknown> {
|
||||
const ride = app.ride;
|
||||
if (!ride) return;
|
||||
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
|
||||
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
|
||||
return api.nudgeGradient(dir * 0.5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKey} />
|
||||
|
||||
<main>
|
||||
{#if fatal}
|
||||
<div class="fatal">
|
||||
<h1>BikeControl could not start</h1>
|
||||
<p>{fatal}</p>
|
||||
</div>
|
||||
{:else if !ready}
|
||||
<div class="boot"><span class="label">Starting…</span></div>
|
||||
{:else if app.screen === 'ride'}
|
||||
<RideScreen />
|
||||
{:else}
|
||||
<ConnectionScreen />
|
||||
{/if}
|
||||
|
||||
{#if app.showProfiles}<ProfileDrawer />{/if}
|
||||
{#if app.showHelp}<HelpOverlay />{/if}
|
||||
<Toasts />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boot,
|
||||
.fatal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.fatal p {
|
||||
margin: 0;
|
||||
color: var(--ink-dim);
|
||||
max-width: 40rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Connection screen (FR-9.1–9.3).
|
||||
*
|
||||
* The one thing this screen must get right: **connected is not
|
||||
* controllable**. A trainer can be attached at the BLE level and still refuse
|
||||
* the FTMS control point, in which case nothing you do moves the resistance.
|
||||
* So every trainer row carries two independent states, side by side, and the
|
||||
* ride screen stays gated until control is real.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { connectionText, rssiBars } from '../lib/format';
|
||||
import type { DeviceInfo, DeviceKind } from '../lib/types';
|
||||
|
||||
const devices = $derived(app.devices.devices);
|
||||
const scanning = $derived(app.devices.scanning);
|
||||
const trainerReady = $derived(
|
||||
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
|
||||
);
|
||||
|
||||
const KIND_LABEL: Record<DeviceKind, string> = {
|
||||
trainer: 'Smart trainer · FTMS',
|
||||
clickLeft: 'Zwift Click · left pod',
|
||||
clickRight: 'Zwift Click · right pod',
|
||||
heartRate: 'Heart rate monitor',
|
||||
unknown: 'Unidentified',
|
||||
};
|
||||
|
||||
function isConnected(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connected' || d.state === 'Controlling';
|
||||
}
|
||||
|
||||
function busy(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connecting' || d.state === 'Reconnecting';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>
|
||||
{#if scanning}
|
||||
Scanning for Bluetooth peripherals…
|
||||
{:else}
|
||||
Scan stopped.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if scanning}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.stopScan())}>Stop scan</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
|
||||
{/if}
|
||||
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
|
||||
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !trainerReady}
|
||||
<div class="gate">
|
||||
<span class="dot tone-warn"></span>
|
||||
<span
|
||||
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
|
||||
telemetry until an FTMS trainer accepts the control point.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list">
|
||||
{#each devices as device (device.id)}
|
||||
{@const conn = connectionText(device.state)}
|
||||
{@const bars = rssiBars(device.rssi)}
|
||||
<article class="row" class:live={isConnected(device)}>
|
||||
<div class="identity">
|
||||
<span class="name">{device.name}</span>
|
||||
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
|
||||
{#if device.error}
|
||||
<span class="error">{device.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="signal" title="{device.rssi} dBm">
|
||||
<span class="bars">
|
||||
{#each [1, 2, 3, 4] as bar}
|
||||
<i class:on={bar <= bars} style:height="{bar * 25}%"></i>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="dbm">{device.rssi}</span>
|
||||
</div>
|
||||
|
||||
<!-- Two states, never conflated (FR-9.3). -->
|
||||
<div class="states">
|
||||
<span class="state">
|
||||
<span class="label">Bluetooth</span>
|
||||
<span class="value {conn.tone === 'ok' ? 'tone-ok' : `tone-${conn.tone}`}">
|
||||
<span class="dot"></span>{busy(device) ? conn.label + '…' : conn.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if device.kind === 'trainer'}
|
||||
<span class="state">
|
||||
<span class="label">FTMS control</span>
|
||||
<span class="value" class:tone-ok={device.controlAcquired} class:tone-warn={!device.controlAcquired}>
|
||||
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
|
||||
<span class="state">
|
||||
<span class="label">Zwift unlock</span>
|
||||
<span
|
||||
class="value"
|
||||
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
|
||||
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
{#if (device.unlockExpiresInS ?? 0) > 0}
|
||||
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
|
||||
{:else}
|
||||
Expired — re-unlock in Zwift
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.batteryPct != null}
|
||||
<span class="state">
|
||||
<span class="label">Battery</span>
|
||||
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
{#if isConnected(device)}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnect(device.id))}>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn" disabled={busy(device)} onclick={() => app.run(() => api.connect(device.id))}>
|
||||
{busy(device) ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.forget(device.id))}>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
{#if devices.length === 0}
|
||||
<!-- A-4 / FR-1.8: absent is not the same as missing. -->
|
||||
<div class="empty">
|
||||
<h2>Nothing found yet</h2>
|
||||
<p>Most devices sleep until you touch them. To wake them:</p>
|
||||
<ul>
|
||||
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
|
||||
<li><strong>Zwift Click</strong> — press any button on the pod.</li>
|
||||
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li>
|
||||
</ul>
|
||||
<p class="quiet">
|
||||
They will appear here as soon as they advertise. Scanning continues in the background.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--gap);
|
||||
padding: clamp(1.4rem, 3vw, 2.6rem) var(--edge) 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.4rem);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0 var(--edge) 0.6rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(255, 207, 74, 0.06);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gate strong {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem var(--edge) 2rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) auto minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 1rem 0.25rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.row.live .name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.bars i {
|
||||
width: 3px;
|
||||
background: var(--ink-faint);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.bars i.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dbm {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-dim);
|
||||
min-width: 2.3em;
|
||||
}
|
||||
|
||||
.states {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.state .value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty {
|
||||
max-width: 42rem;
|
||||
margin: clamp(2rem, 8vh, 6rem) auto;
|
||||
text-align: left;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty ul {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.1rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.empty strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
'identity signal'
|
||||
'states states'
|
||||
'controls controls';
|
||||
}
|
||||
.identity {
|
||||
grid-area: identity;
|
||||
}
|
||||
.signal {
|
||||
grid-area: signal;
|
||||
}
|
||||
.states {
|
||||
grid-area: states;
|
||||
}
|
||||
.controls {
|
||||
grid-area: controls;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* On-screen equivalents for every controller action (FR-3.19, FR-9.10), with
|
||||
* the keyboard shortcut shown on each so the rider learns them. Every press
|
||||
* flashes, because a rider needs to know the input registered (FR-9.9).
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { MODE_LABEL } from '../lib/format';
|
||||
|
||||
const ride = $derived(app.ride);
|
||||
const running = $derived(ride?.status === 'running');
|
||||
|
||||
let flash = $state<string | null>(null);
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const ack = app.lastAck;
|
||||
if (!ack) return;
|
||||
flash = ack.action;
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flash = null), 220);
|
||||
});
|
||||
|
||||
const grade = (d: number) => app.run(() => api.nudgeGradient(d));
|
||||
</script>
|
||||
|
||||
<div class="bar">
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
|
||||
<span class="glyph">−</span> Grade <span class="kbd">↓</span>
|
||||
</button>
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
|
||||
<span class="glyph">+</span> Grade <span class="kbd">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
class:lit={flash === 'gradient-reset'}
|
||||
onclick={() => app.run(() => api.resetGradient())}
|
||||
>
|
||||
Zero <span class="kbd">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'mode'} onclick={() => app.run(() => api.cycleMode())}>
|
||||
Mode: {MODE_LABEL[ride?.mode ?? 'ManualGrade']} <span class="kbd">M</span>
|
||||
</button>
|
||||
<button class="btn ghost" onclick={() => (app.showProfiles = true)}>
|
||||
Profile <span class="kbd">P</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group right">
|
||||
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
|
||||
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
|
||||
</button>
|
||||
{#if running}
|
||||
<button class="btn" class:lit={flash === 'toggle-pause'} onclick={() => app.run(() => api.togglePause())}>
|
||||
Pause <span class="kbd">␣</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn primary" onclick={() => app.run(() => api.start())}>
|
||||
{ride?.status === 'paused' ? 'Resume' : 'Start ride'} <span class="kbd">␣</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
|
||||
<span class="kbd">?</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 0.7rem var(--edge) 0.85rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.group.right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-size: 1.15em;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lit {
|
||||
background: var(--route) !important;
|
||||
color: #04121a !important;
|
||||
}
|
||||
|
||||
.lit :global(.kbd) {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: #04121a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Keyboard shortcuts (FR-3.19). These exist because there is no physical
|
||||
* controller in this phase — and because if a Click pod dies mid-ride, the
|
||||
* keyboard is the only way to keep the session going.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
|
||||
const BINDINGS: [string, string][] = [
|
||||
['↑ / ↓', 'Gradient +0.5% / −0.5%'],
|
||||
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
|
||||
['0', 'Reset gradient trim to zero'],
|
||||
['Space', 'Pause / resume'],
|
||||
['M', 'Cycle control mode'],
|
||||
['L', 'Insert lap marker'],
|
||||
['P', 'Profiles and routes'],
|
||||
['D', 'Device / connection screen'],
|
||||
['R', 'Ride screen'],
|
||||
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
||||
['?', 'This list'],
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={() => (app.showHelp = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (app.showHelp = false)}
|
||||
></div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Keyboard</h2>
|
||||
<dl>
|
||||
{#each BINDINGS as [key, what]}
|
||||
<div>
|
||||
<dt><span class="kbd">{key}</span></dt>
|
||||
<dd>{what}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="note">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 4, 7, 0.75);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 41;
|
||||
width: min(34rem, 92vw);
|
||||
padding: 1.6rem 1.8rem 1.4rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #0a0e14;
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 1.2rem 0 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Load and inspect a profile: bundled samples, a file picker for GPX/YAML,
|
||||
* and a small editor that previews the YAML as you type (§5.5, §5.6, FR-6.7).
|
||||
*
|
||||
* Parsing and preview both happen in Rust — the editor sends text and gets a
|
||||
* `ProfileView` back, so what you see here is exactly what the ride engine
|
||||
* will do with it.
|
||||
*/
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { axisValue } from '../lib/format';
|
||||
import type { ProfileView } from '../lib/types';
|
||||
|
||||
let yaml = $state('');
|
||||
let preview = $state<ProfileView | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const loaded = $derived(app.ride?.profile ?? null);
|
||||
|
||||
function close() {
|
||||
app.showProfiles = false;
|
||||
}
|
||||
|
||||
async function refreshPreview(text: string) {
|
||||
if (!text.trim()) {
|
||||
preview = null;
|
||||
error = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preview = await api.previewYaml(text);
|
||||
error = null;
|
||||
} catch (e) {
|
||||
preview = null;
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(text: string) {
|
||||
yaml = text;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(() => refreshPreview(text), 220);
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: 'Route or profile', extensions: ['gpx', 'yaml', 'yml'] },
|
||||
{ name: 'All files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (typeof path === 'string') {
|
||||
const view = await app.run(() => api.loadProfilePath(path));
|
||||
if (view) {
|
||||
yaml = view.yaml;
|
||||
preview = view;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSample(name: string, text: string, isGpx: boolean) {
|
||||
const view = await app.run(() => api.loadProfileText(name, text, isGpx));
|
||||
if (view) {
|
||||
yaml = view.yaml;
|
||||
preview = view;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEdited() {
|
||||
if (!yaml.trim()) return;
|
||||
const view = await app.run(() => api.loadProfileText('Edited profile', yaml, false));
|
||||
if (view) preview = view;
|
||||
}
|
||||
|
||||
function sparkline(view: ProfileView): string {
|
||||
const pairs = view.elevation ?? view.series;
|
||||
if (!pairs || pairs.length < 2) return '';
|
||||
const step = Math.max(1, Math.floor(pairs.length / 220));
|
||||
const pts: [number, number][] = [];
|
||||
for (let i = 0; i < pairs.length; i += step) pts.push(pairs[i]);
|
||||
const xs = pts.map((p) => p[0]);
|
||||
const ys = pts.map((p) => p[1]);
|
||||
const x0 = Math.min(...xs);
|
||||
const x1 = Math.max(...xs);
|
||||
const y0 = Math.min(...ys);
|
||||
const y1 = Math.max(...ys);
|
||||
const sx = (v: number) => ((v - x0) / (x1 - x0 || 1)) * 100;
|
||||
const sy = (v: number) => 30 - ((v - y0) / (y1 - y0 || 1)) * 28;
|
||||
return `M ${pts.map((p) => `${sx(p[0]).toFixed(2)} ${sy(p[1]).toFixed(2)}`).join(' L ')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={close}
|
||||
onkeydown={(e) => e.key === 'Escape' && close()}
|
||||
></div>
|
||||
|
||||
<aside class="drawer">
|
||||
<header>
|
||||
<h2>Profiles & routes</h2>
|
||||
<button class="btn ghost" onclick={close}>Close <span class="kbd">Esc</span></button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="pick">
|
||||
<div class="row-head">
|
||||
<span class="label">Load</span>
|
||||
<button class="btn" onclick={pickFile}>Open GPX or YAML…</button>
|
||||
</div>
|
||||
|
||||
<ul class="samples">
|
||||
{#each app.samples as s (s.name)}
|
||||
<li>
|
||||
<button class="sample" onclick={() => loadSample(s.name, s.text, s.isGpx)}>
|
||||
<span class="sample-name">
|
||||
{s.name}
|
||||
{#if s.isGpx}<span class="tag">GPX</span>{/if}
|
||||
</span>
|
||||
<span class="sample-sub">{s.summary}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if loaded}
|
||||
<div class="active">
|
||||
<span class="label">Loaded</span>
|
||||
<strong>{loaded.name}</strong>
|
||||
<span class="sample-sub">
|
||||
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
|
||||
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
||||
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
||||
{loaded.looping ? '· loops' : ''}
|
||||
</span>
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||
Unload
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="edit">
|
||||
<div class="row-head">
|
||||
<span class="label">Editor — YAML profile</span>
|
||||
<button class="btn" disabled={!preview} onclick={loadEdited}>Load into ride</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
spellcheck="false"
|
||||
placeholder={'name: My profile\nblocks:\n - type: constant\n channel: gradient\n value: 4.0\n extent: { seconds: 600 }'}
|
||||
value={yaml}
|
||||
oninput={(e) => onEdit(e.currentTarget.value)}
|
||||
></textarea>
|
||||
|
||||
{#if error}
|
||||
<p class="err">{error}</p>
|
||||
{:else if preview}
|
||||
<div class="preview">
|
||||
<svg viewBox="0 0 100 32" preserveAspectRatio="none">
|
||||
<path d={sparkline(preview)} />
|
||||
</svg>
|
||||
<div class="blocks">
|
||||
{#each preview.blocks as b (b.index)}
|
||||
<span class="block">
|
||||
<em>{b.kind}</em>
|
||||
{b.label}
|
||||
<span class="quiet">
|
||||
{axisValue(b.unit, b.endX - b.startX)}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 4, 7, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(56rem, 94vw);
|
||||
z-index: 21;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 1.1rem var(--edge) 0.9rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
header .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: var(--edge);
|
||||
padding: 1.1rem var(--edge) 1.4rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.row-head .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.samples {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sample {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.7rem 0.6rem;
|
||||
border-radius: 0.45rem;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.sample:hover {
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
.sample-name {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.15em 0.45em;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(69, 208, 255, 0.14);
|
||||
color: var(--route);
|
||||
}
|
||||
|
||||
.sample-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.8rem 0.6rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.active .btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
resize: none;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--hairline);
|
||||
background: #080b10;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #23394a;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--bad);
|
||||
font-size: 0.85rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview svg {
|
||||
width: 100%;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
.preview path {
|
||||
fill: none;
|
||||
stroke: var(--route);
|
||||
stroke-width: 0.8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.blocks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
max-height: 6rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.28em 0.6em;
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg-lift);
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.block em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
color: var(--ink-dim);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The ride screen, route-led.
|
||||
*
|
||||
* The hierarchy is deliberate and is the whole design: the *route* is the
|
||||
* hero, then the numbers that answer "how much longer" — ETA, distance
|
||||
* remaining, speed, gradient, elevation. Power, cadence and heart rate are
|
||||
* real but subordinate; they live in a single quiet strip.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import {
|
||||
clock,
|
||||
duration,
|
||||
finishAt,
|
||||
km,
|
||||
MODE_LABEL,
|
||||
num,
|
||||
signed,
|
||||
targetText,
|
||||
} from '../lib/format';
|
||||
import ControlBar from './ControlBar.svelte';
|
||||
import Readout from './Readout.svelte';
|
||||
import RouteChart from './RouteChart.svelte';
|
||||
import StreamChart from './StreamChart.svelte';
|
||||
|
||||
const frame = $derived(app.frame);
|
||||
const snap = $derived(frame?.snapshot ?? null);
|
||||
const d = $derived(frame?.derived ?? null);
|
||||
const ride = $derived(app.ride);
|
||||
const profile = $derived(ride?.profile ?? null);
|
||||
|
||||
const gradient = $derived(snap?.gradient_pct ?? 0);
|
||||
const gradeColour = $derived(
|
||||
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
|
||||
);
|
||||
|
||||
/** ETA presentation, driven entirely by the kind Rust reported (FR-9.15). */
|
||||
const eta = $derived.by(() => {
|
||||
if (!d) return { label: 'Time to go', value: '—', sub: null, dim: true };
|
||||
switch (d.etaKind) {
|
||||
case 'exact':
|
||||
return {
|
||||
label: 'Time to go',
|
||||
value: duration(d.timeRemainingS),
|
||||
sub: `ends ${finishAt(d.timeRemainingS)}`,
|
||||
dim: false,
|
||||
};
|
||||
case 'estimated':
|
||||
return {
|
||||
label: 'ETA',
|
||||
value: duration(d.timeRemainingS),
|
||||
sub: `arrive ${finishAt(d.timeRemainingS)}`,
|
||||
dim: false,
|
||||
};
|
||||
case 'held':
|
||||
return {
|
||||
label: 'ETA',
|
||||
value: duration(d.timeRemainingS),
|
||||
sub: 'held — not moving',
|
||||
dim: true,
|
||||
};
|
||||
case 'looping':
|
||||
return {
|
||||
label: 'Lap',
|
||||
value: String(d.loopIndex ?? 1),
|
||||
sub: 'looping profile',
|
||||
dim: false,
|
||||
};
|
||||
default:
|
||||
return { label: 'ETA', value: '—', sub: 'no route loaded', dim: true };
|
||||
}
|
||||
});
|
||||
|
||||
const remaining = $derived.by(() => {
|
||||
if (!d || d.distanceRemainingM == null) return null;
|
||||
return d.distanceRemainingM;
|
||||
});
|
||||
|
||||
const statusChip = $derived.by(() => {
|
||||
switch (ride?.status) {
|
||||
case 'running':
|
||||
return { label: 'Riding', tone: 'tone-ok' };
|
||||
case 'paused':
|
||||
return { label: 'Paused', tone: 'tone-warn' };
|
||||
case 'finished':
|
||||
return { label: 'Finished', tone: 'tone-idle' };
|
||||
default:
|
||||
return { label: 'Ready', tone: 'tone-idle' };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ride">
|
||||
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
|
||||
<header>
|
||||
<div class="who">
|
||||
<h1>{profile?.name ?? 'No route'}</h1>
|
||||
{#if profile?.description}
|
||||
<p>{profile.description}</p>
|
||||
{:else}
|
||||
<p>Manual control — load a profile to ride terrain.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
{#if ride?.source === 'mock'}
|
||||
<span class="chip tone-warn"><span class="dot"></span>Simulated — no trainer</span>
|
||||
{/if}
|
||||
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
|
||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
||||
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- The hero. -->
|
||||
<section class="route">
|
||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||
</section>
|
||||
|
||||
<!-- Primary readouts. -->
|
||||
<section class="primary">
|
||||
<Readout
|
||||
label={eta.label}
|
||||
value={eta.value}
|
||||
size="hero"
|
||||
colour="var(--route)"
|
||||
sub={eta.sub}
|
||||
dim={eta.dim}
|
||||
/>
|
||||
<Readout
|
||||
label="To go"
|
||||
value={remaining != null ? km(remaining, 2) : '—'}
|
||||
unit={remaining != null ? 'km' : ''}
|
||||
size="big"
|
||||
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
|
||||
dim={remaining == null}
|
||||
/>
|
||||
<Readout
|
||||
label="Speed"
|
||||
value={num(d?.smoothedSpeedKph ?? 0, 1)}
|
||||
unit="km/h"
|
||||
size="big"
|
||||
sub={`now ${num(snap?.virtual_speed_kph ?? 0, 1)}`}
|
||||
/>
|
||||
<Readout
|
||||
label="Gradient"
|
||||
value={signed(gradient, 1)}
|
||||
unit="%"
|
||||
size="big"
|
||||
colour={gradeColour}
|
||||
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Route detail. -->
|
||||
<section class="detail">
|
||||
<Readout
|
||||
label="Elevation"
|
||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
||||
unit={d?.elevationM != null ? 'm' : ''}
|
||||
colour="var(--climb)"
|
||||
/>
|
||||
<Readout
|
||||
label="Climbing left"
|
||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
||||
/>
|
||||
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
|
||||
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
|
||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
||||
</section>
|
||||
|
||||
<!-- Effort: present, readable, subordinate. -->
|
||||
<section class="effort">
|
||||
<Readout
|
||||
label={`Power · ${num(d?.rollingPowerWindowS ?? 10, 0)}s`}
|
||||
value={num(d?.rollingPowerW ?? 0, 0)}
|
||||
unit="W"
|
||||
size="mid"
|
||||
colour="var(--power)"
|
||||
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||
/>
|
||||
<Readout label="Cadence" value={num(snap?.telemetry.cadence_rpm ?? 0, 0)} unit="rpm" size="mid" />
|
||||
<Readout
|
||||
label="Heart rate"
|
||||
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
||||
unit="bpm"
|
||||
size="mid"
|
||||
/>
|
||||
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
|
||||
<Readout
|
||||
label="Normalised"
|
||||
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
|
||||
unit="W"
|
||||
size="small"
|
||||
/>
|
||||
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
|
||||
<div class="spacer"></div>
|
||||
<div class="charts">
|
||||
<div class="chart">
|
||||
<span class="label">Power</span>
|
||||
<StreamChart
|
||||
history={app.power}
|
||||
revision={app.revision}
|
||||
series={[
|
||||
{ stroke: 'var(--power-raw)', width: 1 },
|
||||
{ stroke: 'var(--power)', width: 2 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<span class="label">Gradient</span>
|
||||
<StreamChart
|
||||
history={app.grade}
|
||||
revision={app.revision}
|
||||
zeroLine
|
||||
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ControlBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ride {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(150px, 1fr) auto auto minmax(190px, 0.95fr) auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap);
|
||||
padding: 0.9rem var(--edge) 0.6rem;
|
||||
}
|
||||
|
||||
.who {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.who p {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip.mode {
|
||||
color: var(--ink);
|
||||
background: #131b26;
|
||||
}
|
||||
|
||||
.chip.target {
|
||||
color: var(--route);
|
||||
background: rgba(69, 208, 255, 0.1);
|
||||
}
|
||||
|
||||
.route {
|
||||
min-height: 0;
|
||||
padding: 0 var(--edge);
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr 1fr 1fr;
|
||||
gap: var(--gap);
|
||||
padding: 1.1rem var(--edge) 0.9rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--gap);
|
||||
padding: 0 var(--edge) 1rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.effort {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, auto)) 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--gap);
|
||||
padding: 0.9rem var(--edge) 0.4rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.charts {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: var(--gap);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0.15rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.primary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.detail {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.effort {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<string | null>(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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { app } from '../lib/app.svelte';
|
||||
</script>
|
||||
|
||||
<div class="toasts">
|
||||
{#each app.toasts as t (t.id)}
|
||||
<button class="toast {t.level}" onclick={() => app.dismiss(t.id)}>
|
||||
<span class="dot"></span>{t.message}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 5.5rem;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: #101720;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
max-width: min(60rem, 90vw);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info .dot {
|
||||
color: var(--route);
|
||||
}
|
||||
.warn .dot {
|
||||
color: var(--warn);
|
||||
}
|
||||
.error .dot {
|
||||
color: var(--bad);
|
||||
}
|
||||
.error {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user