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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user