Files
BikeControl/crates/fit/src/lib.rs
T
dtourolleandClaude Opus 5 7c17ca6158 Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:34:27 +02:00

159 lines
5.8 KiB
Rust

//! FIT activity file encoder (REQUIREMENTS.md §5.8, FR-8).
//!
//! Records a ride to a crash-safe journal and turns it into a FIT activity file
//! that Strava and Garmin Connect will accept.
//!
//! # Why this is hand-rolled
//!
//! RISK-3 in the requirements is accurate: the Rust ecosystem reads FIT far
//! better than it writes it. There *is* a capable encoder on crates.io
//! (`rustyfit`), but it was not the right dependency here:
//!
//! * The value it adds is the generated Garmin profile — several megabytes of
//! message definitions — of which an activity file needs seven messages. The
//! binary container underneath is about two hundred lines and is the part
//! that decides whether an upload is accepted.
//! * We have no Strava to test against, so correctness has to come from tests.
//! Encoding with someone's crate and round-tripping through the same crate's
//! decoder proves only self-consistency. Encoding with our own writer and
//! decoding with an *independent* parser — `fitparser`, a dev-dependency —
//! is a genuinely independent check, and it is the check this crate rests on.
//! * When an upload is rejected, the fix is at the byte level. Owning those
//! bytes is worth more here than saving a few hundred lines.
//!
//! The field numbers and enum values in [`profile`] were transcribed from the
//! Garmin FIT SDK profile and cross-checked against `rustyfit`'s generated
//! tables, so the SDK's knowledge is used — just not its code.
//!
//! # Shape of the crate
//!
//! ```text
//! RideSnapshot --> Recorder --> raw journal (JSON Lines, flushed per sample)
//! |
//! v
//! build_fit_from_log --> .fit
//! ```
//!
//! The FIT file cannot be written incrementally: its header carries a data size
//! and its last two bytes are a CRC over everything before them, so a
//! half-written FIT is a broken FIT. Crash safety therefore lives one level
//! down, in the journal — see [`rawlog`]. A ride that ends in a crash is
//! recovered by pointing [`build_fit_from_log`] at the journal, and the
//! resulting file is byte-identical to the one a clean shutdown would have
//! produced.
//!
//! # Example
//!
//! ```no_run
//! use bikecontrol_fit::{Recorder, RecorderOptions};
//! # use bikecontrol_core::RideSnapshot;
//! # fn demo(snapshots: &[RideSnapshot]) -> Result<(), bikecontrol_fit::FitError> {
//! let mut rec = Recorder::create("rides/2026-08-05.jsonl", RecorderOptions::default())?;
//! for snap in snapshots {
//! rec.record(snap)?;
//! }
//! let summary = rec.finish("rides/2026-08-05.fit")?;
//! # Ok(())
//! # }
//! ```
#![warn(missing_docs)]
use std::path::{Path, PathBuf};
pub mod builder;
pub mod crc;
pub mod encode;
pub mod profile;
pub mod rawlog;
pub mod recorder;
pub mod timestamp;
pub use builder::{encode_activity, FitSummary};
pub use crc::crc16;
pub use encode::{verify, VerifyError};
pub use rawlog::{parse_log, read_log, LogEntry, RawLog, Sample, SessionStart};
pub use recorder::{Recorder, RecorderOptions};
pub use timestamp::FIT_EPOCH_UNIX_SECS;
/// Anything that can go wrong recording or encoding a ride.
#[derive(Debug, thiserror::Error)]
pub enum FitError {
/// Reading or writing a file failed.
#[error("i/o error on {path}: {source}")]
Io {
/// The file involved.
path: PathBuf,
/// The underlying error.
#[source]
source: std::io::Error,
},
/// A journal line could not be serialised or deserialised.
#[error("journal encoding error: {0}")]
Json(#[from] serde_json::Error),
/// The journal has no `start` header, so elapsed times cannot be anchored
/// to the wall clock.
#[error("raw log has no session start entry")]
MissingSessionStart,
/// The journal contains no telemetry. An activity with no records is
/// rejected by every uploader, so it is refused here instead.
#[error("raw log contains no samples; nothing to encode")]
NoSamples,
/// A timestamp lies outside the FIT `date_time` range — before
/// 1989-12-31 UTC, or beyond 2158.
#[error("timestamp {unix_secs} is outside the FIT date_time range (1989-12-31 onwards)")]
TimestampOutOfRange {
/// The offending Unix timestamp, in seconds.
unix_secs: i64,
},
}
/// Build a FIT activity from a raw journal and write it to `fit_path`.
///
/// This is the crash-recovery entry point (FR-8.4): point it at a journal left
/// behind by a ride that ended badly and it produces the activity that ride
/// should have exported. It is also what [`Recorder::finish`] calls, so the two
/// paths cannot drift apart.
///
/// The encoded file is verified — header, declared data size, both CRCs —
/// before it is written, so a file that reaches disk is structurally sound.
pub fn build_fit_from_log(
log_path: impl AsRef<Path>,
fit_path: impl AsRef<Path>,
) -> Result<FitSummary, FitError> {
let log = read_log(log_path.as_ref())?;
let (bytes, summary) = encode_activity(&log)?;
debug_assert!(
verify(&bytes).is_ok(),
"encoder produced a structurally invalid FIT file: {:?}",
verify(&bytes)
);
let fit_path = fit_path.as_ref();
if let Some(parent) = fit_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| FitError::Io {
path: parent.to_path_buf(),
source,
})?;
}
}
std::fs::write(fit_path, &bytes).map_err(|source| FitError::Io {
path: fit_path.to_path_buf(),
source,
})?;
Ok(summary)
}
/// Build a FIT activity from a raw journal and return the bytes without
/// touching the filesystem.
pub fn fit_bytes_from_log(log_path: impl AsRef<Path>) -> Result<(Vec<u8>, FitSummary), FitError> {
let log = read_log(log_path.as_ref())?;
encode_activity(&log)
}