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>
159 lines
5.8 KiB
Rust
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)
|
|
}
|