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>
This commit is contained in:
@@ -8,3 +8,11 @@ license.workspace = true
|
||||
bikecontrol-core = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Independent third-party FIT *decoder* (MIT). Test-only: we encode with our own
|
||||
# writer and decode with someone else's parser, which is a far stronger check
|
||||
# than round-tripping through our own code.
|
||||
fitparser = "0.11"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
//! The FIT CRC-16.
|
||||
//!
|
||||
//! The FIT specification defines a nibble-table CRC. It is bit-for-bit
|
||||
//! CRC-16/ARC (reflected polynomial `0xA001`, init `0x0000`, no final XOR),
|
||||
//! which gives us published test vectors to check against — see the tests.
|
||||
//!
|
||||
//! Two CRCs appear in every FIT file and both must be right or the file is
|
||||
//! silently rejected on upload:
|
||||
//!
|
||||
//! * the *header CRC* — bytes 0..12 of a 14-byte header, stored at bytes 12..14;
|
||||
//! * the *file CRC* — every byte from the start of the header through the end
|
||||
//! of the data records, appended as the last two bytes of the file.
|
||||
|
||||
/// Nibble lookup table from the FIT SDK.
|
||||
const CRC_TABLE: [u16; 16] = [
|
||||
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401,
|
||||
0x5000, 0x9C01, 0x8801, 0x4400,
|
||||
];
|
||||
|
||||
/// Running FIT CRC-16 state.
|
||||
///
|
||||
/// Lets the encoder checksum bytes as they are produced rather than buffering
|
||||
/// the whole file twice.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Crc16(u16);
|
||||
|
||||
impl Crc16 {
|
||||
/// A fresh CRC with the FIT initial value (zero).
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
/// Fold `data` into the running CRC.
|
||||
pub fn update(&mut self, data: &[u8]) {
|
||||
let mut crc = self.0;
|
||||
for &byte in data {
|
||||
// Low nibble, then high nibble.
|
||||
let mut tmp = CRC_TABLE[(crc & 0xF) as usize];
|
||||
crc = (crc >> 4) & 0x0FFF;
|
||||
crc = crc ^ tmp ^ CRC_TABLE[(byte & 0xF) as usize];
|
||||
|
||||
tmp = CRC_TABLE[(crc & 0xF) as usize];
|
||||
crc = (crc >> 4) & 0x0FFF;
|
||||
crc = crc ^ tmp ^ CRC_TABLE[((byte >> 4) & 0xF) as usize];
|
||||
}
|
||||
self.0 = crc;
|
||||
}
|
||||
|
||||
/// The current checksum.
|
||||
pub const fn value(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot FIT CRC-16 over `data`.
|
||||
pub fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc = Crc16::new();
|
||||
crc.update(data);
|
||||
crc.value()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The canonical CRC-16/ARC check value: `crc("123456789") == 0xBB3D`.
|
||||
/// If this fails, every FIT file we produce is rejected.
|
||||
#[test]
|
||||
fn known_vector_check_string() {
|
||||
assert_eq!(crc16(b"123456789"), 0xBB3D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_vector_empty_and_zero() {
|
||||
assert_eq!(crc16(b""), 0x0000);
|
||||
// CRC-16/ARC of a single zero byte is 0.
|
||||
assert_eq!(crc16(&[0x00]), 0x0000);
|
||||
// Published CRC-16/ARC vectors.
|
||||
assert_eq!(crc16(b"A"), 0x30C0);
|
||||
assert_eq!(crc16(&[0x00, 0x00, 0x00, 0x00]), 0x0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_reference_bitwise_implementation() {
|
||||
// Independent, deliberately naive reflected-CRC implementation.
|
||||
fn reference(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= b as u16;
|
||||
for _ in 0..8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
// A deterministic pseudo-random corpus.
|
||||
let mut data = Vec::new();
|
||||
let mut x: u32 = 0x1234_5678;
|
||||
for _ in 0..1000 {
|
||||
x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
data.push((x >> 16) as u8);
|
||||
assert_eq!(crc16(&data), reference(&data), "mismatch at len {}", data.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_equals_one_shot() {
|
||||
let data: Vec<u8> = (0u8..=255).cycle().take(777).collect();
|
||||
let mut running = Crc16::new();
|
||||
for chunk in data.chunks(13) {
|
||||
running.update(chunk);
|
||||
}
|
||||
assert_eq!(running.value(), crc16(&data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! The FIT binary container: file header, definition messages, data messages
|
||||
//! and the trailing CRC.
|
||||
//!
|
||||
//! This is deliberately a small, literal implementation of the FIT protocol
|
||||
//! rather than a wrapper around a generated SDK. The container is about two
|
||||
//! hundred lines and every byte of it matters for whether an upload is
|
||||
//! accepted, so it is worth being able to read all of it.
|
||||
//!
|
||||
//! # File layout
|
||||
//!
|
||||
//! ```text
|
||||
//! +--------------------------------+
|
||||
//! | header (14 bytes) | size, protocol, profile, data size,
|
||||
//! | | ".FIT", header CRC
|
||||
//! +--------------------------------+
|
||||
//! | data records (data_size bytes) | definition + data messages
|
||||
//! +--------------------------------+
|
||||
//! | file CRC (2 bytes) | over header + data records
|
||||
//! +--------------------------------+
|
||||
//! ```
|
||||
|
||||
use crate::crc::Crc16;
|
||||
|
||||
/// Header length we emit. The 12-byte variant (no header CRC) is legal but the
|
||||
/// 14-byte form is universally expected.
|
||||
pub const HEADER_SIZE: u8 = 14;
|
||||
|
||||
/// Protocol version 2.0, encoded as `major << 4 | minor`.
|
||||
pub const PROTOCOL_VERSION: u8 = 0x20;
|
||||
|
||||
/// Profile version, `major * 100 + minor`, from FIT SDK 21.
|
||||
pub const PROFILE_VERSION: u16 = 21_205;
|
||||
|
||||
/// The `.FIT` data type signature at bytes 8..12 of the header.
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum BaseType {
|
||||
Enum = 0x00,
|
||||
Sint8 = 0x01,
|
||||
Uint8 = 0x02,
|
||||
Sint16 = 0x83,
|
||||
Uint16 = 0x84,
|
||||
Sint32 = 0x85,
|
||||
Uint32 = 0x86,
|
||||
String = 0x07,
|
||||
Float32 = 0x88,
|
||||
Uint8z = 0x0A,
|
||||
Uint16z = 0x8B,
|
||||
Uint32z = 0x8C,
|
||||
Byte = 0x0D,
|
||||
}
|
||||
|
||||
/// One encoded field value, carrying its own base type and width.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Enum(u8),
|
||||
Uint8(u8),
|
||||
Uint8z(u8),
|
||||
Sint8(i8),
|
||||
Uint16(u16),
|
||||
Uint16z(u16),
|
||||
Sint16(i16),
|
||||
Uint32(u32),
|
||||
Uint32z(u32),
|
||||
Sint32(i32),
|
||||
Float32(f32),
|
||||
/// Null-terminated UTF-8. The encoded size includes the terminator.
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The FIT base type of this value.
|
||||
pub fn base_type(&self) -> BaseType {
|
||||
match self {
|
||||
Value::Enum(_) => BaseType::Enum,
|
||||
Value::Uint8(_) => BaseType::Uint8,
|
||||
Value::Uint8z(_) => BaseType::Uint8z,
|
||||
Value::Sint8(_) => BaseType::Sint8,
|
||||
Value::Uint16(_) => BaseType::Uint16,
|
||||
Value::Uint16z(_) => BaseType::Uint16z,
|
||||
Value::Sint16(_) => BaseType::Sint16,
|
||||
Value::Uint32(_) => BaseType::Uint32,
|
||||
Value::Uint32z(_) => BaseType::Uint32z,
|
||||
Value::Sint32(_) => BaseType::Sint32,
|
||||
Value::Float32(_) => BaseType::Float32,
|
||||
Value::String(_) => BaseType::String,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoded width in bytes, as it appears in the definition message.
|
||||
pub fn size(&self) -> u8 {
|
||||
match self {
|
||||
Value::Enum(_) | Value::Uint8(_) | Value::Uint8z(_) | Value::Sint8(_) => 1,
|
||||
Value::Uint16(_) | Value::Uint16z(_) | Value::Sint16(_) => 2,
|
||||
Value::Uint32(_) | Value::Uint32z(_) | Value::Sint32(_) | Value::Float32(_) => 4,
|
||||
// UTF-8 bytes plus the null terminator; clamped so a pathological
|
||||
// name cannot overflow the single-byte size field.
|
||||
Value::String(s) => (s.len().min(u8::MAX as usize - 1) + 1) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append this value to `out` in little-endian order.
|
||||
fn write(&self, out: &mut Vec<u8>) {
|
||||
match self {
|
||||
Value::Enum(v) | Value::Uint8(v) | Value::Uint8z(v) => out.push(*v),
|
||||
Value::Sint8(v) => out.push(*v as u8),
|
||||
Value::Uint16(v) | Value::Uint16z(v) => out.extend_from_slice(&v.to_le_bytes()),
|
||||
Value::Sint16(v) => out.extend_from_slice(&v.to_le_bytes()),
|
||||
Value::Uint32(v) | Value::Uint32z(v) => out.extend_from_slice(&v.to_le_bytes()),
|
||||
Value::Sint32(v) => out.extend_from_slice(&v.to_le_bytes()),
|
||||
Value::Float32(v) => out.extend_from_slice(&v.to_le_bytes()),
|
||||
Value::String(s) => {
|
||||
let max = usize::from(self.size()) - 1;
|
||||
let mut bytes = s.as_bytes();
|
||||
if bytes.len() > max {
|
||||
// Never split a UTF-8 sequence.
|
||||
let mut end = max;
|
||||
while end > 0 && (bytes[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
bytes = &bytes[..end];
|
||||
}
|
||||
out.extend_from_slice(bytes);
|
||||
out.resize(out.len() + (max - bytes.len()) + 1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message under construction: an ordered set of (field number, value) pairs.
|
||||
///
|
||||
/// Setting the same field twice replaces the value rather than emitting a
|
||||
/// duplicate, which a definition message may not contain.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Message {
|
||||
fields: Vec<(u8, Value)>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// An empty message.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set a field.
|
||||
pub fn set(&mut self, field: u8, value: Value) -> &mut Self {
|
||||
match self.fields.iter_mut().find(|(n, _)| *n == field) {
|
||||
Some(slot) => slot.1 = value,
|
||||
None => self.fields.push((field, value)),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a field only when the value is present. Absent optional fields are
|
||||
/// omitted from the definition entirely rather than written as the base
|
||||
/// type's "invalid" sentinel, which keeps files small and stops decoders
|
||||
/// from surfacing phantom all-invalid streams.
|
||||
pub fn set_opt(&mut self, field: u8, value: Option<Value>) -> &mut Self {
|
||||
if let Some(v) = value {
|
||||
self.set(field, v);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The fields, in the order they will be written.
|
||||
pub fn fields(&self) -> &[(u8, Value)] {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
/// True when no field has been set.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
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)> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|(n, v)| (*n, v.size(), v.base_type() as u8))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates data records and emits a complete FIT file.
|
||||
///
|
||||
/// Definitions are cached per local message type, so a definition is re-emitted
|
||||
/// only when a message's shape changes — which is what lets a thousand `record`
|
||||
/// messages share a single definition.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FitEncoder {
|
||||
data: Vec<u8>,
|
||||
/// Cached definition shape per local message type (0..16).
|
||||
defs: [Option<(u16, Vec<(u8, u8, u8)>)>; 16],
|
||||
message_count: usize,
|
||||
}
|
||||
|
||||
impl FitEncoder {
|
||||
/// A new, empty encoder.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Write `msg` as global message `global` using local message type `local`.
|
||||
///
|
||||
/// Emits a definition message first if the shape is not already cached for
|
||||
/// this local type. `local` must be 0..=15; anything larger is masked, and
|
||||
/// an empty message is skipped (a zero-field definition is legal but
|
||||
/// pointless and confuses some parsers).
|
||||
pub fn write_message(&mut self, local: u8, global: u16, msg: &Message) {
|
||||
if msg.is_empty() {
|
||||
return;
|
||||
}
|
||||
let local = local & 0x0F;
|
||||
let shape = msg.shape();
|
||||
|
||||
let cached = self.defs[local as usize]
|
||||
.as_ref()
|
||||
.is_some_and(|(g, s)| *g == global && *s == shape);
|
||||
|
||||
if !cached {
|
||||
self.write_definition(local, global, &shape);
|
||||
self.defs[local as usize] = Some((global, shape));
|
||||
}
|
||||
|
||||
// Data message header: bit 7 = 0 (normal), bit 6 = 0 (data),
|
||||
// bits 0..4 = local message type.
|
||||
self.data.push(local);
|
||||
for (_, value) in msg.fields() {
|
||||
value.write(&mut self.data);
|
||||
}
|
||||
self.message_count += 1;
|
||||
}
|
||||
|
||||
fn write_definition(&mut self, local: u8, global: u16, shape: &[(u8, u8, u8)]) {
|
||||
// Definition message header: bit 7 = 0 (normal), bit 6 = 1 (definition).
|
||||
self.data.push(0x40 | local);
|
||||
self.data.push(0); // reserved
|
||||
self.data.push(0); // architecture: 0 = little endian
|
||||
self.data.extend_from_slice(&global.to_le_bytes());
|
||||
// A definition may describe at most 255 fields.
|
||||
self.data.push(shape.len().min(u8::MAX as usize) as u8);
|
||||
for &(num, size, base) in shape.iter().take(u8::MAX as usize) {
|
||||
self.data.push(num);
|
||||
self.data.push(size);
|
||||
self.data.push(base);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of data messages written so far.
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.message_count
|
||||
}
|
||||
|
||||
/// Byte length of the data-records section written so far.
|
||||
pub fn data_len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Finish the file: prepend the 14-byte header (with its own CRC) and
|
||||
/// append the file CRC over header plus data.
|
||||
pub fn finish(self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.data.len() + 16);
|
||||
out.extend_from_slice(&file_header(self.data.len() as u32));
|
||||
out.extend_from_slice(&self.data);
|
||||
|
||||
let mut crc = Crc16::new();
|
||||
crc.update(&out);
|
||||
out.extend_from_slice(&crc.value().to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the 14-byte FIT file header for a given data-records length.
|
||||
///
|
||||
/// `data_size` counts *only* the data records — not the header and not the
|
||||
/// trailing CRC. Getting that wrong is the second classic way to produce a file
|
||||
/// that every uploader rejects.
|
||||
pub fn file_header(data_size: u32) -> [u8; 14] {
|
||||
let mut h = [0u8; 14];
|
||||
h[0] = HEADER_SIZE;
|
||||
h[1] = PROTOCOL_VERSION;
|
||||
h[2..4].copy_from_slice(&PROFILE_VERSION.to_le_bytes());
|
||||
h[4..8].copy_from_slice(&data_size.to_le_bytes());
|
||||
h[8..12].copy_from_slice(DATA_TYPE);
|
||||
|
||||
let mut crc = Crc16::new();
|
||||
crc.update(&h[0..12]);
|
||||
h[12..14].copy_from_slice(&crc.value().to_le_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
/// Structural check on an encoded FIT file: header self-consistency, declared
|
||||
/// data size against actual length, and both CRCs.
|
||||
///
|
||||
/// Exposed because it is exactly the check an uploader performs before deciding
|
||||
/// whether to look at the contents, and it is cheap enough to run on every file
|
||||
/// we write.
|
||||
pub fn verify(bytes: &[u8]) -> Result<(), VerifyError> {
|
||||
if bytes.len() < 16 {
|
||||
return Err(VerifyError::TooShort(bytes.len()));
|
||||
}
|
||||
let header_size = bytes[0] as usize;
|
||||
if header_size != 12 && header_size != 14 {
|
||||
return Err(VerifyError::BadHeaderSize(bytes[0]));
|
||||
}
|
||||
if &bytes[8..12] != DATA_TYPE {
|
||||
return Err(VerifyError::BadSignature([
|
||||
bytes[8], bytes[9], bytes[10], bytes[11],
|
||||
]));
|
||||
}
|
||||
|
||||
let data_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
|
||||
let expected_len = header_size + data_size + 2;
|
||||
if bytes.len() != expected_len {
|
||||
return Err(VerifyError::DataSizeMismatch {
|
||||
declared: data_size,
|
||||
actual: bytes.len().saturating_sub(header_size + 2),
|
||||
});
|
||||
}
|
||||
|
||||
if header_size == 14 {
|
||||
let stored = u16::from_le_bytes([bytes[12], bytes[13]]);
|
||||
// A zero header CRC means "not present", which is legal.
|
||||
if stored != 0 {
|
||||
let computed = crate::crc::crc16(&bytes[0..12]);
|
||||
if stored != computed {
|
||||
return Err(VerifyError::HeaderCrc { stored, computed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stored = u16::from_le_bytes([bytes[expected_len - 2], bytes[expected_len - 1]]);
|
||||
let computed = crate::crc::crc16(&bytes[..expected_len - 2]);
|
||||
if stored != computed {
|
||||
return Err(VerifyError::FileCrc { stored, computed });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why [`verify`] rejected a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerifyError {
|
||||
#[error("file is {0} bytes, too short to be a FIT file")]
|
||||
TooShort(usize),
|
||||
#[error("header size {0} is neither 12 nor 14")]
|
||||
BadHeaderSize(u8),
|
||||
#[error("data type signature is {0:?}, expected \".FIT\"")]
|
||||
BadSignature([u8; 4]),
|
||||
#[error("header declares {declared} data bytes but the file carries {actual}")]
|
||||
DataSizeMismatch { declared: usize, actual: usize },
|
||||
#[error("header CRC is {stored:#06x}, computed {computed:#06x}")]
|
||||
HeaderCrc { stored: u16, computed: u16 },
|
||||
#[error("file CRC is {stored:#06x}, computed {computed:#06x}")]
|
||||
FileCrc { stored: u16, computed: u16 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_layout_is_byte_exact() {
|
||||
let h = file_header(0x1234);
|
||||
assert_eq!(h[0], 14, "header size");
|
||||
assert_eq!(h[1], 0x20, "protocol version 2.0");
|
||||
assert_eq!(&h[2..4], &PROFILE_VERSION.to_le_bytes(), "profile version");
|
||||
assert_eq!(&h[4..8], &[0x34, 0x12, 0x00, 0x00], "data size, little endian");
|
||||
assert_eq!(&h[8..12], b".FIT", "data type signature");
|
||||
|
||||
let crc = u16::from_le_bytes([h[12], h[13]]);
|
||||
assert_eq!(crc, crate::crc::crc16(&h[0..12]));
|
||||
assert_ne!(crc, 0, "a real header CRC, not the 'absent' sentinel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_is_header_plus_crc() {
|
||||
let bytes = FitEncoder::new().finish();
|
||||
assert_eq!(bytes.len(), 16);
|
||||
assert_eq!(&bytes[4..8], &[0, 0, 0, 0]);
|
||||
assert!(verify(&bytes).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_and_data_bytes_are_exact() {
|
||||
let mut enc = FitEncoder::new();
|
||||
let mut msg = Message::new();
|
||||
msg.set(0, Value::Enum(4));
|
||||
msg.set(1, Value::Uint16(255));
|
||||
enc.write_message(0, 0, &msg);
|
||||
let bytes = enc.finish();
|
||||
|
||||
let data = &bytes[14..bytes.len() - 2];
|
||||
#[rustfmt::skip]
|
||||
let expected: &[u8] = &[
|
||||
// definition message for global 0, local 0, two fields
|
||||
0x40, // header: normal, definition, local 0
|
||||
0x00, // reserved
|
||||
0x00, // little endian
|
||||
0x00, 0x00, // global message number 0 (file_id)
|
||||
0x02, // two fields
|
||||
0x00, 0x01, 0x00, // field 0, 1 byte, enum
|
||||
0x01, 0x02, 0x84, // field 1, 2 bytes, uint16
|
||||
// data message
|
||||
0x00, // header: normal, data, local 0
|
||||
0x04, // type = activity
|
||||
0xFF, 0x00, // manufacturer = 255, little endian
|
||||
];
|
||||
assert_eq!(data, expected);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize,
|
||||
expected.len()
|
||||
);
|
||||
assert!(verify(&bytes).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_is_reused_for_identical_shapes() {
|
||||
let mut enc = FitEncoder::new();
|
||||
let mut msg = Message::new();
|
||||
msg.set(253, Value::Uint32(1));
|
||||
for i in 0..5u32 {
|
||||
msg.set(253, Value::Uint32(i));
|
||||
enc.write_message(3, mesg_record(), &msg);
|
||||
}
|
||||
// One 3+3+1-byte definition (6 header bytes + 3 per field) plus five
|
||||
// 5-byte data messages.
|
||||
assert_eq!(enc.data_len(), (6 + 3) + 5 * (1 + 4));
|
||||
assert_eq!(enc.message_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_is_re_emitted_when_the_shape_changes() {
|
||||
let mut enc = FitEncoder::new();
|
||||
let mut a = Message::new();
|
||||
a.set(253, Value::Uint32(1));
|
||||
enc.write_message(3, mesg_record(), &a);
|
||||
let after_first = enc.data_len();
|
||||
|
||||
let mut b = Message::new();
|
||||
b.set(253, Value::Uint32(2));
|
||||
b.set(7, Value::Uint16(250));
|
||||
enc.write_message(3, mesg_record(), &b);
|
||||
// Second write costs a new 12-byte definition plus a 7-byte data message.
|
||||
assert_eq!(enc.data_len() - after_first, (6 + 6) + (1 + 4 + 2));
|
||||
}
|
||||
|
||||
fn mesg_record() -> u16 {
|
||||
crate::profile::mesg::RECORD
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_are_null_terminated_and_sized_with_the_terminator() {
|
||||
let v = Value::String("BikeControl".into());
|
||||
assert_eq!(v.size(), 12);
|
||||
let mut out = Vec::new();
|
||||
v.write(&mut out);
|
||||
assert_eq!(out, b"BikeControl\0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_a_single_null() {
|
||||
let v = Value::String(String::new());
|
||||
assert_eq!(v.size(), 1);
|
||||
let mut out = Vec::new();
|
||||
v.write(&mut out);
|
||||
assert_eq!(out, b"\0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_strings_are_truncated_on_a_char_boundary() {
|
||||
let v = Value::String("é".repeat(200));
|
||||
let size = usize::from(v.size());
|
||||
let mut out = Vec::new();
|
||||
v.write(&mut out);
|
||||
assert_eq!(out.len(), size);
|
||||
assert_eq!(*out.last().unwrap(), 0);
|
||||
// Truncation must not leave a partial UTF-8 sequence.
|
||||
assert!(std::str::from_utf8(&out[..out.len() - 1]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_a_field_twice_replaces_rather_than_duplicates() {
|
||||
let mut msg = Message::new();
|
||||
msg.set(7, Value::Uint16(100));
|
||||
msg.set(7, Value::Uint16(200));
|
||||
assert_eq!(msg.fields().len(), 1);
|
||||
assert_eq!(msg.fields()[0].1, Value::Uint16(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_opt_skips_none() {
|
||||
let mut msg = Message::new();
|
||||
msg.set_opt(3, None);
|
||||
msg.set_opt(4, Some(Value::Uint8(90)));
|
||||
assert_eq!(msg.fields().len(), 1);
|
||||
assert_eq!(msg.fields()[0].0, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_values_use_twos_complement_little_endian() {
|
||||
let mut out = Vec::new();
|
||||
Value::Sint16(-100).write(&mut out);
|
||||
assert_eq!(out, vec![0x9C, 0xFF]);
|
||||
out.clear();
|
||||
Value::Sint8(-1).write(&mut out);
|
||||
assert_eq!(out, vec![0xFF]);
|
||||
out.clear();
|
||||
Value::Sint32(-2).write(&mut out);
|
||||
assert_eq!(out, vec![0xFE, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_corrupted_file_crc() {
|
||||
let mut bytes = FitEncoder::new().finish();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 0xFF;
|
||||
assert!(matches!(verify(&bytes), Err(VerifyError::FileCrc { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_corrupted_header_crc() {
|
||||
let mut bytes = FitEncoder::new().finish();
|
||||
bytes[12] ^= 0xFF;
|
||||
assert!(matches!(verify(&bytes), Err(VerifyError::HeaderCrc { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_wrong_data_size() {
|
||||
let mut bytes = FitEncoder::new().finish();
|
||||
bytes[4] = 99;
|
||||
assert!(matches!(
|
||||
verify(&bytes),
|
||||
Err(VerifyError::DataSizeMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_bad_signature() {
|
||||
let mut bytes = FitEncoder::new().finish();
|
||||
bytes[8] = b'X';
|
||||
assert!(matches!(verify(&bytes), Err(VerifyError::BadSignature(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_truncation() {
|
||||
let bytes = FitEncoder::new().finish();
|
||||
assert!(matches!(
|
||||
verify(&bytes[..10]),
|
||||
Err(VerifyError::TooShort(10))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_byte_corruption_is_caught() {
|
||||
// The strongest statement we can make about the checksums without an
|
||||
// uploader: no one-byte change to the file survives verification.
|
||||
let mut enc = FitEncoder::new();
|
||||
let mut msg = Message::new();
|
||||
msg.set(253, Value::Uint32(1_000_000_000));
|
||||
msg.set(7, Value::Uint16(250));
|
||||
enc.write_message(3, crate::profile::mesg::RECORD, &msg);
|
||||
let good = enc.finish();
|
||||
|
||||
for i in 0..good.len() {
|
||||
let mut bad = good.clone();
|
||||
bad[i] ^= 0x01;
|
||||
assert!(
|
||||
verify(&bad).is_err(),
|
||||
"flipping a bit in byte {i} was not detected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
-1
@@ -1 +1,158 @@
|
||||
//! FIT activity file encoder. See REQUIREMENTS.md §5.8.
|
||||
//! 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! FIT global message numbers, field numbers and enum values.
|
||||
//!
|
||||
//! These are facts from the Garmin FIT SDK profile (`Profile.xlsx`, SDK 21.x),
|
||||
//! transcribed for the handful of messages an activity file needs. They were
|
||||
//! cross-checked against the generated profile in the `rustyfit` crate.
|
||||
//!
|
||||
//! **Watch the `lap` / `session` divergence.** The two messages do *not* share
|
||||
//! field numbers: `session` inserts `total_fat_calories` at 13, which shifts
|
||||
//! every summary field after it by one relative to `lap`. Writing lap field
|
||||
//! numbers into a session message produces a file that parses but reports
|
||||
//! nonsense (average power showing up as maximum heart rate, and so on).
|
||||
|
||||
/// Global message numbers.
|
||||
pub mod mesg {
|
||||
pub const FILE_ID: u16 = 0;
|
||||
pub const SESSION: u16 = 18;
|
||||
pub const LAP: u16 = 19;
|
||||
pub const RECORD: u16 = 20;
|
||||
pub const EVENT: u16 = 21;
|
||||
pub const DEVICE_INFO: u16 = 23;
|
||||
pub const ACTIVITY: u16 = 34;
|
||||
}
|
||||
|
||||
/// `file_id` (global 0) field numbers.
|
||||
pub mod file_id {
|
||||
pub const TYPE: u8 = 0;
|
||||
pub const MANUFACTURER: u8 = 1;
|
||||
pub const PRODUCT: u8 = 2;
|
||||
pub const SERIAL_NUMBER: u8 = 3;
|
||||
pub const TIME_CREATED: u8 = 4;
|
||||
pub const PRODUCT_NAME: u8 = 8;
|
||||
}
|
||||
|
||||
/// `device_info` (global 23) field numbers.
|
||||
pub mod device_info {
|
||||
pub const DEVICE_INDEX: u8 = 0;
|
||||
pub const MANUFACTURER: u8 = 2;
|
||||
pub const PRODUCT: u8 = 4;
|
||||
pub const SOFTWARE_VERSION: u8 = 5;
|
||||
pub const SOURCE_TYPE: u8 = 25;
|
||||
pub const PRODUCT_NAME: u8 = 27;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
}
|
||||
|
||||
/// `event` (global 21) field numbers.
|
||||
pub mod event {
|
||||
pub const EVENT: u8 = 0;
|
||||
pub const EVENT_TYPE: u8 = 1;
|
||||
pub const DATA: u8 = 3;
|
||||
pub const EVENT_GROUP: u8 = 4;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
}
|
||||
|
||||
/// `record` (global 20) field numbers.
|
||||
pub mod record {
|
||||
pub const ALTITUDE: u8 = 2;
|
||||
pub const HEART_RATE: u8 = 3;
|
||||
pub const CADENCE: u8 = 4;
|
||||
pub const DISTANCE: u8 = 5;
|
||||
pub const SPEED: u8 = 6;
|
||||
pub const POWER: u8 = 7;
|
||||
pub const GRADE: u8 = 9;
|
||||
pub const RESISTANCE: u8 = 10;
|
||||
pub const CALORIES: u8 = 33;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
}
|
||||
|
||||
/// `lap` (global 19) field numbers.
|
||||
pub mod lap {
|
||||
pub const EVENT: u8 = 0;
|
||||
pub const EVENT_TYPE: u8 = 1;
|
||||
pub const START_TIME: u8 = 2;
|
||||
pub const TOTAL_ELAPSED_TIME: u8 = 7;
|
||||
pub const TOTAL_TIMER_TIME: u8 = 8;
|
||||
pub const TOTAL_DISTANCE: u8 = 9;
|
||||
pub const TOTAL_CALORIES: u8 = 11;
|
||||
pub const AVG_SPEED: u8 = 13;
|
||||
pub const MAX_SPEED: u8 = 14;
|
||||
pub const AVG_HEART_RATE: u8 = 15;
|
||||
pub const MAX_HEART_RATE: u8 = 16;
|
||||
pub const AVG_CADENCE: u8 = 17;
|
||||
pub const MAX_CADENCE: u8 = 18;
|
||||
pub const AVG_POWER: u8 = 19;
|
||||
pub const MAX_POWER: u8 = 20;
|
||||
pub const TOTAL_ASCENT: u8 = 21;
|
||||
pub const TOTAL_DESCENT: u8 = 22;
|
||||
pub const INTENSITY: u8 = 23;
|
||||
pub const LAP_TRIGGER: u8 = 24;
|
||||
pub const SPORT: u8 = 25;
|
||||
pub const SUB_SPORT: u8 = 39;
|
||||
pub const TOTAL_WORK: u8 = 41;
|
||||
pub const AVG_GRADE: u8 = 45;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
pub const MESSAGE_INDEX: u8 = 254;
|
||||
}
|
||||
|
||||
/// `session` (global 18) field numbers. Note the offset relative to [`lap`].
|
||||
pub mod session {
|
||||
pub const EVENT: u8 = 0;
|
||||
pub const EVENT_TYPE: u8 = 1;
|
||||
pub const START_TIME: u8 = 2;
|
||||
pub const SPORT: u8 = 5;
|
||||
pub const SUB_SPORT: u8 = 6;
|
||||
pub const TOTAL_ELAPSED_TIME: u8 = 7;
|
||||
pub const TOTAL_TIMER_TIME: u8 = 8;
|
||||
pub const TOTAL_DISTANCE: u8 = 9;
|
||||
pub const TOTAL_CALORIES: u8 = 11;
|
||||
pub const AVG_SPEED: u8 = 14;
|
||||
pub const MAX_SPEED: u8 = 15;
|
||||
pub const AVG_HEART_RATE: u8 = 16;
|
||||
pub const MAX_HEART_RATE: u8 = 17;
|
||||
pub const AVG_CADENCE: u8 = 18;
|
||||
pub const MAX_CADENCE: u8 = 19;
|
||||
pub const AVG_POWER: u8 = 20;
|
||||
pub const MAX_POWER: u8 = 21;
|
||||
pub const TOTAL_ASCENT: u8 = 22;
|
||||
pub const TOTAL_DESCENT: u8 = 23;
|
||||
pub const FIRST_LAP_INDEX: u8 = 25;
|
||||
pub const NUM_LAPS: u8 = 26;
|
||||
pub const TRIGGER: u8 = 28;
|
||||
pub const TOTAL_WORK: u8 = 48;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
pub const MESSAGE_INDEX: u8 = 254;
|
||||
}
|
||||
|
||||
/// `activity` (global 34) field numbers.
|
||||
pub mod activity {
|
||||
pub const TOTAL_TIMER_TIME: u8 = 0;
|
||||
pub const NUM_SESSIONS: u8 = 1;
|
||||
pub const TYPE: u8 = 2;
|
||||
pub const EVENT: u8 = 3;
|
||||
pub const EVENT_TYPE: u8 = 4;
|
||||
pub const LOCAL_TIMESTAMP: u8 = 5;
|
||||
pub const TIMESTAMP: u8 = 253;
|
||||
}
|
||||
|
||||
/// Enum values used by the messages above.
|
||||
pub mod enums {
|
||||
/// `file` — `file_id.type`.
|
||||
pub const FILE_ACTIVITY: u8 = 4;
|
||||
|
||||
/// `manufacturer` — 255 is the SDK's "development" manufacturer, the
|
||||
/// correct value for an application that is not a registered Garmin
|
||||
/// partner. Both Strava and Garmin Connect accept it.
|
||||
pub const MANUFACTURER_DEVELOPMENT: u16 = 255;
|
||||
|
||||
/// `sport`.
|
||||
pub const SPORT_CYCLING: u8 = 2;
|
||||
|
||||
/// `sub_sport`. `VIRTUAL_ACTIVITY` is what makes Strava file the upload as
|
||||
/// a *Virtual Ride* rather than an outdoor ride with no GPS.
|
||||
pub const SUB_SPORT_INDOOR_CYCLING: u8 = 6;
|
||||
pub const SUB_SPORT_VIRTUAL_ACTIVITY: u8 = 58;
|
||||
|
||||
/// `event`.
|
||||
pub const EVENT_TIMER: u8 = 0;
|
||||
pub const EVENT_LAP: u8 = 9;
|
||||
pub const EVENT_SESSION: u8 = 8;
|
||||
pub const EVENT_ACTIVITY: u8 = 26;
|
||||
|
||||
/// `event_type`.
|
||||
pub const EVENT_TYPE_START: u8 = 0;
|
||||
pub const EVENT_TYPE_STOP: u8 = 1;
|
||||
pub const EVENT_TYPE_STOP_ALL: u8 = 4;
|
||||
|
||||
/// `lap_trigger`.
|
||||
pub const LAP_TRIGGER_MANUAL: u8 = 0;
|
||||
pub const LAP_TRIGGER_SESSION_END: u8 = 7;
|
||||
|
||||
/// `session_trigger`.
|
||||
pub const SESSION_TRIGGER_ACTIVITY_END: u8 = 0;
|
||||
|
||||
/// `activity` — `activity.type`.
|
||||
pub const ACTIVITY_MANUAL: u8 = 0;
|
||||
|
||||
/// `intensity`.
|
||||
pub const INTENSITY_ACTIVE: u8 = 0;
|
||||
|
||||
/// `source_type`.
|
||||
pub const SOURCE_TYPE_LOCAL: u8 = 5;
|
||||
|
||||
/// `device_index` — 0 is reserved for the device that created the file.
|
||||
pub const DEVICE_INDEX_CREATOR: u8 = 0;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! The raw ride log: an append-only, line-delimited JSON journal (FR-8.4,
|
||||
//! FR-8.6).
|
||||
//!
|
||||
//! The FIT file cannot be written incrementally in any useful sense — the
|
||||
//! header carries a data size and the file ends with a CRC over everything, so
|
||||
//! a partially written FIT is simply a broken FIT. The session is therefore
|
||||
//! made crash-safe a level below: every sample is appended to this journal as a
|
||||
//! complete line and flushed, and the FIT is assembled from the journal at the
|
||||
//! end of the ride. If the app dies mid-ride the journal survives and
|
||||
//! [`crate::build_fit_from_log`] regenerates the activity.
|
||||
//!
|
||||
//! JSON Lines was chosen over a packed binary format for one reason: torn
|
||||
//! writes are recoverable. A crash during the final `write` leaves a truncated
|
||||
//! last line, which the reader drops; every earlier line is intact and
|
||||
//! self-describing. A binary format with a length prefix would need its own
|
||||
//! framing and resync logic to reach the same place.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bikecontrol_core::{ControlMode, RideSnapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Version of the on-disk log format, written into the session header so a
|
||||
/// future reader can tell what it is looking at.
|
||||
pub const LOG_FORMAT_VERSION: u16 = 1;
|
||||
|
||||
/// One line of the journal.
|
||||
///
|
||||
/// The tag is short because these are written at 1 Hz for hours; the field
|
||||
/// names cost real bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "t")]
|
||||
pub enum LogEntry {
|
||||
/// Always the first line. Anchors elapsed time to the wall clock.
|
||||
#[serde(rename = "start")]
|
||||
Start(SessionStart),
|
||||
/// A 1 Hz telemetry sample.
|
||||
#[serde(rename = "s")]
|
||||
Sample(Sample),
|
||||
/// A stretch with no telemetry — a BLE dropout (FR-8.5). Recorded so the
|
||||
/// hole in the record stream is explained rather than mysterious. The
|
||||
/// timer keeps running across a gap: the rider was still pedalling, we just
|
||||
/// stopped hearing about it.
|
||||
#[serde(rename = "gap")]
|
||||
Gap {
|
||||
/// Elapsed time at which telemetry stopped, ms.
|
||||
at_ms: u64,
|
||||
/// Elapsed time at which it resumed, ms. `None` if the ride ended
|
||||
/// during the dropout.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
until_ms: Option<u64>,
|
||||
/// Human-readable cause, e.g. the disconnect reason.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
reason: String,
|
||||
},
|
||||
/// A lap marker (FR-8.7). Ends the lap in progress and starts a new one.
|
||||
#[serde(rename = "lap")]
|
||||
Lap {
|
||||
at_ms: u64,
|
||||
/// True if triggered by the controller rather than the UI.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
from_controller: bool,
|
||||
},
|
||||
/// 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 },
|
||||
/// The rider resumed.
|
||||
#[serde(rename = "resume")]
|
||||
Resume { 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 },
|
||||
}
|
||||
|
||||
/// Session metadata, written as the first line of the journal.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionStart {
|
||||
/// Wall-clock start of the ride, Unix milliseconds UTC. Every sample's
|
||||
/// `elapsed_ms` is an offset from this.
|
||||
pub start_unix_ms: i64,
|
||||
/// The rider's UTC offset in seconds at the start of the ride, used for
|
||||
/// `activity.local_timestamp`.
|
||||
#[serde(default)]
|
||||
pub utc_offset_secs: i32,
|
||||
/// FIT `sub_sport`. Defaults to `virtual_activity`, which is what makes
|
||||
/// Strava file the upload as a Virtual Ride.
|
||||
#[serde(default = "default_sub_sport")]
|
||||
pub sub_sport: u8,
|
||||
/// Name written into `file_id.product_name`.
|
||||
#[serde(default = "default_product_name")]
|
||||
pub product_name: String,
|
||||
/// Application version, scaled by 100 (1.20 is written as 120).
|
||||
#[serde(default = "default_software_version")]
|
||||
pub software_version: u16,
|
||||
/// Device serial. Zero means "unset" (the FIT base type is `uint32z`).
|
||||
#[serde(default)]
|
||||
pub serial_number: u32,
|
||||
/// Format version of this log.
|
||||
#[serde(default)]
|
||||
pub log_format: u16,
|
||||
}
|
||||
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires this signature
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn default_sub_sport() -> u8 {
|
||||
crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY
|
||||
}
|
||||
|
||||
fn default_product_name() -> String {
|
||||
"BikeControl".to_string()
|
||||
}
|
||||
|
||||
fn default_software_version() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
impl Default for SessionStart {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start_unix_ms: 0,
|
||||
utc_offset_secs: 0,
|
||||
sub_sport: default_sub_sport(),
|
||||
product_name: default_product_name(),
|
||||
software_version: default_software_version(),
|
||||
serial_number: 0,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One recorded sample (FR-8.1).
|
||||
///
|
||||
/// This is deliberately not [`RideSnapshot`] itself: the snapshot is a UI
|
||||
/// contract that will keep changing, whereas a journal on disk has to stay
|
||||
/// readable by a later version of the app. Fields are optional and skipped when
|
||||
/// absent so that a log of a ride without a heart-rate strap does not carry
|
||||
/// thousands of nulls.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sample {
|
||||
/// Milliseconds since the start of the ride.
|
||||
#[serde(rename = "e")]
|
||||
pub elapsed_ms: u64,
|
||||
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
|
||||
pub power_w: Option<i16>,
|
||||
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
|
||||
pub cadence_rpm: Option<f32>,
|
||||
/// Virtual speed from the physics engine, km/h.
|
||||
#[serde(rename = "v", default)]
|
||||
pub speed_kph: f32,
|
||||
/// Virtual distance, metres.
|
||||
#[serde(rename = "d", default)]
|
||||
pub distance_m: f64,
|
||||
/// Commanded gradient, percent.
|
||||
#[serde(rename = "g", default)]
|
||||
pub gradient_pct: f32,
|
||||
/// Cumulative elevation gain, metres.
|
||||
#[serde(rename = "eg", default)]
|
||||
pub elevation_gain_m: f32,
|
||||
/// Absolute altitude if a route supplies one; otherwise the encoder
|
||||
/// integrates gradient over distance to synthesise a profile.
|
||||
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
|
||||
pub altitude_m: Option<f32>,
|
||||
#[serde(rename = "h", default, skip_serializing_if = "Option::is_none")]
|
||||
pub heart_rate_bpm: Option<u8>,
|
||||
/// Trainer-reported cumulative energy, kcal.
|
||||
#[serde(rename = "k", default, skip_serializing_if = "Option::is_none")]
|
||||
pub energy_kcal: Option<u16>,
|
||||
/// Trainer resistance level, if the trainer reports one.
|
||||
#[serde(rename = "r", default, skip_serializing_if = "Option::is_none")]
|
||||
pub resistance: Option<i16>,
|
||||
/// Virtual gear (FR-8.1). No FIT record field carries this, so it lives in
|
||||
/// the journal only.
|
||||
#[serde(rename = "gear", default, skip_serializing_if = "Option::is_none")]
|
||||
pub gear: Option<u8>,
|
||||
/// Control mode in force at this sample (FR-8.1). Journal only.
|
||||
#[serde(rename = "m", default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<ControlMode>,
|
||||
}
|
||||
|
||||
impl Sample {
|
||||
/// Build a sample from a ride snapshot.
|
||||
pub fn from_snapshot(snap: &RideSnapshot) -> Self {
|
||||
Self {
|
||||
elapsed_ms: snap.elapsed_ms,
|
||||
power_w: snap.telemetry.power_w,
|
||||
cadence_rpm: snap.telemetry.cadence_rpm,
|
||||
speed_kph: snap.virtual_speed_kph,
|
||||
distance_m: snap.virtual_distance_m,
|
||||
gradient_pct: snap.gradient_pct,
|
||||
elevation_gain_m: snap.elevation_gain_m,
|
||||
altitude_m: None,
|
||||
heart_rate_bpm: snap.telemetry.heart_rate_bpm,
|
||||
energy_kcal: snap.telemetry.total_energy_kcal,
|
||||
resistance: snap.telemetry.resistance_level,
|
||||
gear: None,
|
||||
mode: Some(snap.mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a virtual gear number.
|
||||
pub fn with_gear(mut self, gear: u8) -> Self {
|
||||
self.gear = Some(gear);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach an absolute altitude, overriding the integrated profile.
|
||||
pub fn with_altitude(mut self, altitude_m: f32) -> Self {
|
||||
self.altitude_m = Some(altitude_m);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed journal.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RawLog {
|
||||
/// Session metadata from the `start` line.
|
||||
pub start: SessionStart,
|
||||
/// Every entry after the header, in file order.
|
||||
pub entries: Vec<LogEntry>,
|
||||
/// Lines that failed to parse and were skipped. A count of 1 on the final
|
||||
/// line is the normal signature of a crash mid-write; anything more
|
||||
/// suggests real corruption.
|
||||
pub skipped_lines: usize,
|
||||
/// Whether the log ended with an `end` entry. False means the ride was
|
||||
/// recovered from a crash.
|
||||
pub clean_shutdown: bool,
|
||||
/// Where the log came from, when it came from a file.
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl RawLog {
|
||||
/// Every sample, in order.
|
||||
pub fn samples(&self) -> impl Iterator<Item = &Sample> {
|
||||
self.entries.iter().filter_map(|e| match e {
|
||||
LogEntry::Sample(s) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Elapsed times at which laps were marked.
|
||||
pub fn lap_marks(&self) -> impl Iterator<Item = u64> + '_ {
|
||||
self.entries.iter().filter_map(|e| match e {
|
||||
LogEntry::Lap { at_ms, .. } => Some(*at_ms),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recorded BLE dropouts as `(start_ms, end_ms)`. An unterminated gap is
|
||||
/// closed at `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)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total time excluded from timer time by explicit pauses, in ms.
|
||||
///
|
||||
/// A pause with no matching resume is closed at `fallback_end_ms`.
|
||||
/// Nested or repeated pauses are tolerated: only the outermost counts.
|
||||
pub fn paused_ms(&self, fallback_end_ms: u64) -> u64 {
|
||||
let mut total = 0u64;
|
||||
let mut paused_at: Option<u64> = None;
|
||||
for entry in &self.entries {
|
||||
match entry {
|
||||
LogEntry::Pause { at_ms } => {
|
||||
if paused_at.is_none() {
|
||||
paused_at = Some(*at_ms);
|
||||
}
|
||||
}
|
||||
LogEntry::Resume { at_ms } => {
|
||||
if let Some(start) = paused_at.take() {
|
||||
total += at_ms.saturating_sub(start);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(start) = paused_at {
|
||||
total += fallback_end_ms.saturating_sub(start);
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a journal from anything line-oriented.
|
||||
///
|
||||
/// Malformed lines are skipped rather than fatal — the entire point of this
|
||||
/// format is that a half-written tail costs one sample, not the ride.
|
||||
pub fn parse_log(text: &str) -> Result<RawLog, crate::FitError> {
|
||||
let mut start: Option<SessionStart> = None;
|
||||
let mut entries = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
let mut clean = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<LogEntry>(line) {
|
||||
Ok(LogEntry::Start(s)) => {
|
||||
if start.is_none() {
|
||||
start = Some(s);
|
||||
} else {
|
||||
// A second header means two rides in one file; ignore it
|
||||
// rather than silently merging them.
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
Ok(entry) => {
|
||||
if matches!(entry, LogEntry::End { .. }) {
|
||||
clean = true;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
Err(_) => skipped += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let start = start.ok_or(crate::FitError::MissingSessionStart)?;
|
||||
Ok(RawLog {
|
||||
start,
|
||||
entries,
|
||||
skipped_lines: skipped,
|
||||
clean_shutdown: clean,
|
||||
path: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read and parse a journal from disk.
|
||||
pub fn read_log(path: impl Into<PathBuf>) -> Result<RawLog, crate::FitError> {
|
||||
let path = path.into();
|
||||
let text = std::fs::read_to_string(&path).map_err(|source| crate::FitError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let mut log = parse_log(&text)?;
|
||||
log.path = Some(path);
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
/// Serialise one entry as a journal line, terminator included.
|
||||
pub fn entry_to_line(entry: &LogEntry) -> Result<String, crate::FitError> {
|
||||
let mut s = serde_json::to_string(entry)?;
|
||||
s.push('\n');
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn header_line() -> String {
|
||||
entry_to_line(&LogEntry::Start(SessionStart {
|
||||
start_unix_ms: 1_785_000_000_000,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_round_trip_through_json() {
|
||||
let entries = vec![
|
||||
LogEntry::Start(SessionStart::default()),
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: 1000,
|
||||
power_w: Some(250),
|
||||
cadence_rpm: Some(88.5),
|
||||
speed_kph: 32.4,
|
||||
distance_m: 9.0,
|
||||
gradient_pct: 2.5,
|
||||
elevation_gain_m: 0.2,
|
||||
heart_rate_bpm: Some(145),
|
||||
..Default::default()
|
||||
}),
|
||||
LogEntry::Gap {
|
||||
at_ms: 5000,
|
||||
until_ms: Some(9000),
|
||||
reason: "peripheral disconnected".into(),
|
||||
},
|
||||
LogEntry::Lap {
|
||||
at_ms: 60_000,
|
||||
from_controller: true,
|
||||
},
|
||||
LogEntry::Pause { at_ms: 70_000 },
|
||||
LogEntry::Resume { at_ms: 80_000 },
|
||||
LogEntry::End { at_ms: 90_000 },
|
||||
];
|
||||
for e in entries {
|
||||
let line = entry_to_line(&e).unwrap();
|
||||
assert!(line.ends_with('\n'));
|
||||
assert!(!line[..line.len() - 1].contains('\n'), "one entry, one line");
|
||||
let back: LogEntry = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(back, e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_final_line_costs_one_sample_not_the_ride() {
|
||||
let mut text = header_line();
|
||||
for i in 1..=5u64 {
|
||||
text.push_str(
|
||||
&entry_to_line(&LogEntry::Sample(Sample {
|
||||
elapsed_ms: i * 1000,
|
||||
power_w: Some(200),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
// Simulate a crash part-way through writing the sixth line.
|
||||
text.push_str("{\"t\":\"s\",\"e\":6000,\"p\":2");
|
||||
|
||||
let log = parse_log(&text).unwrap();
|
||||
assert_eq!(log.samples().count(), 5);
|
||||
assert_eq!(log.skipped_lines, 1);
|
||||
assert!(!log.clean_shutdown, "no end entry means crash recovery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_with_no_header_is_an_error() {
|
||||
let text = entry_to_line(&LogEntry::Sample(Sample::default())).unwrap();
|
||||
assert!(matches!(
|
||||
parse_log(&text),
|
||||
Err(crate::FitError::MissingSessionStart)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_shutdown_is_detected() {
|
||||
let text = header_line() + &entry_to_line(&LogEntry::End { at_ms: 10 }).unwrap();
|
||||
assert!(parse_log(&text).unwrap().clean_shutdown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_time_sums_intervals() {
|
||||
let text = header_line()
|
||||
+ &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
|
||||
+ &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap()
|
||||
+ &entry_to_line(&LogEntry::Pause { at_ms: 20_000 }).unwrap()
|
||||
+ &entry_to_line(&LogEntry::Resume { at_ms: 23_000 }).unwrap();
|
||||
assert_eq!(parse_log(&text).unwrap().paused_ms(30_000), 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_pause_runs_to_the_end_of_the_ride() {
|
||||
let text = header_line() + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap();
|
||||
assert_eq!(parse_log(&text).unwrap().paused_ms(25_000), 15_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_pauses_do_not_double_count() {
|
||||
let text = header_line()
|
||||
+ &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
|
||||
+ &entry_to_line(&LogEntry::Pause { at_ms: 12_000 }).unwrap()
|
||||
+ &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap();
|
||||
assert_eq!(parse_log(&text).unwrap().paused_ms(20_000), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_are_reported_with_unterminated_ones_closed() {
|
||||
let text = header_line()
|
||||
+ &entry_to_line(&LogEntry::Gap {
|
||||
at_ms: 1000,
|
||||
until_ms: Some(4000),
|
||||
reason: String::new(),
|
||||
})
|
||||
.unwrap()
|
||||
+ &entry_to_line(&LogEntry::Gap {
|
||||
at_ms: 9000,
|
||||
until_ms: None,
|
||||
reason: "lost".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(parse_log(&text).unwrap().gaps(12_000), vec![
|
||||
(1000, 4000),
|
||||
(9000, 12_000)
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_and_whitespace_are_tolerated() {
|
||||
let text = format!("\n{}\n\n \n", header_line().trim());
|
||||
let log = parse_log(&text).unwrap();
|
||||
assert_eq!(log.skipped_lines, 0);
|
||||
assert_eq!(log.entries.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_header_is_skipped_not_merged() {
|
||||
let text = header_line() + &header_line();
|
||||
let log = parse_log(&text).unwrap();
|
||||
assert_eq!(log.skipped_lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_snapshot_carries_the_fields_the_contract_provides() {
|
||||
use bikecontrol_core::{ControlMode, Telemetry};
|
||||
let snap = RideSnapshot {
|
||||
elapsed_ms: 12_000,
|
||||
telemetry: Telemetry {
|
||||
elapsed_ms: 12_000,
|
||||
power_w: Some(233),
|
||||
cadence_rpm: Some(91.0),
|
||||
heart_rate_bpm: Some(150),
|
||||
total_energy_kcal: Some(42),
|
||||
resistance_level: Some(7),
|
||||
..Default::default()
|
||||
},
|
||||
virtual_speed_kph: 31.5,
|
||||
virtual_distance_m: 105.0,
|
||||
gradient_pct: 3.5,
|
||||
elevation_gain_m: 3.6,
|
||||
mode: ControlMode::Profile,
|
||||
target: None,
|
||||
profile_progress: Some(0.1),
|
||||
};
|
||||
let s = Sample::from_snapshot(&snap);
|
||||
assert_eq!(s.elapsed_ms, 12_000);
|
||||
assert_eq!(s.power_w, Some(233));
|
||||
assert_eq!(s.cadence_rpm, Some(91.0));
|
||||
assert_eq!(s.heart_rate_bpm, Some(150));
|
||||
assert_eq!(s.speed_kph, 31.5);
|
||||
assert_eq!(s.distance_m, 105.0);
|
||||
assert_eq!(s.gradient_pct, 3.5);
|
||||
assert_eq!(s.energy_kcal, Some(42));
|
||||
assert_eq!(s.resistance, Some(7));
|
||||
assert_eq!(s.mode, Some(ControlMode::Profile));
|
||||
// Speed comes from the physics engine, never from the trainer.
|
||||
assert_eq!(s.speed_kph, snap.virtual_speed_kph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_optionals_do_not_appear_on_the_wire() {
|
||||
let line = entry_to_line(&LogEntry::Sample(Sample {
|
||||
elapsed_ms: 1000,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!line.contains("null"), "no null padding: {line}");
|
||||
assert!(!line.contains("\"h\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! [`Recorder`] — the ride-time half of the crate.
|
||||
//!
|
||||
//! The recorder owns the raw journal. It is fed snapshots at whatever rate the
|
||||
//! ride engine ticks, throttles them to 1 Hz (FR-8.1), and appends each one as
|
||||
//! a complete line that is flushed immediately (FR-8.6). Nothing about the FIT
|
||||
//! file is decided until [`Recorder::finish`].
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bikecontrol_core::RideSnapshot;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
|
||||
use crate::builder::{encode_activity, FitSummary};
|
||||
use crate::rawlog::{entry_to_line, read_log, LogEntry, Sample, SessionStart, LOG_FORMAT_VERSION};
|
||||
use crate::FitError;
|
||||
|
||||
/// Tuning for [`Recorder`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RecorderOptions {
|
||||
/// Minimum spacing between recorded samples, ms. Snapshots arriving sooner
|
||||
/// are dropped. The FIT `record` timestamp has one-second resolution, so
|
||||
/// there is nothing to gain from a faster journal.
|
||||
pub sample_interval_ms: u64,
|
||||
/// Force the journal to stable storage every N samples. `None` relies on
|
||||
/// the OS page cache, which is fast but loses the tail on a hard power cut.
|
||||
/// The default trades roughly ten seconds of exposure for one `fsync` per
|
||||
/// ten samples.
|
||||
pub fsync_every: Option<usize>,
|
||||
/// A silence longer than this is recorded as a BLE dropout (FR-8.5).
|
||||
/// `None` disables automatic gap detection; gaps can still be marked
|
||||
/// explicitly with [`Recorder::mark_gap`].
|
||||
pub auto_gap_after_ms: Option<u64>,
|
||||
/// FIT `sub_sport`. `virtual_activity` makes Strava file the ride as a
|
||||
/// Virtual Ride; `indoor_cycling` is the alternative for a plain
|
||||
/// trainer session with no simulated course.
|
||||
pub sub_sport: u8,
|
||||
/// Written to `file_id.product_name` and `device_info.product_name`.
|
||||
pub product_name: String,
|
||||
/// Application version scaled by 100 — 1.20 is `120`.
|
||||
pub software_version: u16,
|
||||
/// Device serial number. Zero means unset.
|
||||
pub serial_number: u32,
|
||||
}
|
||||
|
||||
impl Default for RecorderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_interval_ms: 1000,
|
||||
fsync_every: Some(10),
|
||||
auto_gap_after_ms: Some(5_000),
|
||||
sub_sport: crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY,
|
||||
product_name: "BikeControl".to_string(),
|
||||
software_version: 100,
|
||||
serial_number: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a ride to a crash-safe journal and finalises it to a FIT activity.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use bikecontrol_fit::{Recorder, RecorderOptions};
|
||||
/// # use bikecontrol_core::RideSnapshot;
|
||||
/// # fn demo(snapshots: Vec<RideSnapshot>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut rec = Recorder::create("/tmp/ride.jsonl", RecorderOptions::default())?;
|
||||
/// for snap in &snapshots {
|
||||
/// rec.record(snap)?; // throttled to 1 Hz internally
|
||||
/// }
|
||||
/// rec.mark_lap(60_000, true)?; // controller pressed lap
|
||||
/// let summary = rec.finish("/tmp/ride.fit")?;
|
||||
/// println!("{} records, {:.1} km", summary.records, summary.total_distance_m / 1000.0);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Recorder {
|
||||
file: File,
|
||||
log_path: PathBuf,
|
||||
opts: RecorderOptions,
|
||||
start: SessionStart,
|
||||
samples_written: usize,
|
||||
since_sync: usize,
|
||||
last_sample_ms: Option<u64>,
|
||||
last_elapsed_ms: u64,
|
||||
/// Elapsed time at which an open (unterminated) gap began.
|
||||
open_gap_at: Option<u64>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Start recording, creating the journal at `log_path`.
|
||||
///
|
||||
/// The ride's wall-clock start is taken as "now", and the local UTC offset
|
||||
/// is captured with it so the activity shows the right time of day.
|
||||
pub fn create(log_path: impl AsRef<Path>, opts: RecorderOptions) -> Result<Self, FitError> {
|
||||
Self::create_at(log_path, opts, Utc::now(), local_utc_offset_secs())
|
||||
}
|
||||
|
||||
/// Start recording with an explicit wall-clock start and UTC offset.
|
||||
/// Used by tests, and by anything that needs a reproducible file.
|
||||
pub fn create_at(
|
||||
log_path: impl AsRef<Path>,
|
||||
opts: RecorderOptions,
|
||||
started_at: DateTime<Utc>,
|
||||
utc_offset_secs: i32,
|
||||
) -> Result<Self, FitError> {
|
||||
let log_path = log_path.as_ref().to_path_buf();
|
||||
if let Some(parent) = log_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,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Truncate rather than append: a journal holds exactly one ride, and
|
||||
// silently concatenating two would produce a nonsense activity.
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)
|
||||
.map_err(|source| FitError::Io {
|
||||
path: log_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let start = SessionStart {
|
||||
start_unix_ms: started_at.timestamp_millis(),
|
||||
utc_offset_secs,
|
||||
sub_sport: opts.sub_sport,
|
||||
product_name: opts.product_name.clone(),
|
||||
software_version: opts.software_version,
|
||||
serial_number: opts.serial_number,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
let mut rec = Self {
|
||||
file,
|
||||
log_path,
|
||||
opts,
|
||||
start: start.clone(),
|
||||
samples_written: 0,
|
||||
since_sync: 0,
|
||||
last_sample_ms: None,
|
||||
last_elapsed_ms: 0,
|
||||
open_gap_at: None,
|
||||
finished: false,
|
||||
};
|
||||
rec.append(&LogEntry::Start(start))?;
|
||||
rec.sync()?;
|
||||
Ok(rec)
|
||||
}
|
||||
|
||||
/// Record a snapshot, subject to the 1 Hz throttle.
|
||||
///
|
||||
/// Returns `true` if the sample was written, `false` if it was throttled
|
||||
/// away. Safe to call on every engine tick.
|
||||
pub fn record(&mut self, snapshot: &RideSnapshot) -> Result<bool, FitError> {
|
||||
self.record_sample(Sample::from_snapshot(snapshot))
|
||||
}
|
||||
|
||||
/// Record a fully-formed sample, subject to the same throttle. Use this
|
||||
/// when there is more to record than the snapshot carries — a virtual gear,
|
||||
/// or an absolute altitude from a loaded route.
|
||||
pub fn record_sample(&mut self, sample: Sample) -> Result<bool, FitError> {
|
||||
let t = sample.elapsed_ms;
|
||||
self.last_elapsed_ms = self.last_elapsed_ms.max(t);
|
||||
|
||||
if let Some(prev) = self.last_sample_ms {
|
||||
if t < prev.saturating_add(self.opts.sample_interval_ms) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Telemetry has been silent long enough to call it a dropout.
|
||||
if let Some(threshold) = self.opts.auto_gap_after_ms {
|
||||
if t.saturating_sub(prev) >= threshold && self.open_gap_at.is_none() {
|
||||
self.append(&LogEntry::Gap {
|
||||
at_ms: prev,
|
||||
until_ms: Some(t),
|
||||
reason: "no telemetry".to_string(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly opened gap closes as soon as telemetry returns.
|
||||
if let Some(at_ms) = self.open_gap_at.take() {
|
||||
self.append(&LogEntry::Gap {
|
||||
at_ms,
|
||||
until_ms: Some(t),
|
||||
reason: "telemetry resumed".to_string(),
|
||||
})?;
|
||||
}
|
||||
|
||||
self.last_sample_ms = Some(t);
|
||||
self.append(&LogEntry::Sample(sample))?;
|
||||
self.samples_written += 1;
|
||||
|
||||
self.since_sync += 1;
|
||||
if self
|
||||
.opts
|
||||
.fsync_every
|
||||
.is_some_and(|n| n > 0 && self.since_sync >= n)
|
||||
{
|
||||
self.sync()?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Mark the start of a BLE dropout (FR-8.5). Recording continues; the gap
|
||||
/// is closed automatically by the next sample, or at the end of the ride.
|
||||
///
|
||||
/// Calling this is optional — `auto_gap_after_ms` catches dropouts on its
|
||||
/// own — but a caller that *knows* the peripheral disconnected can record a
|
||||
/// reason and the exact moment.
|
||||
pub fn mark_gap(&mut self, at_ms: u64, reason: impl Into<String>) -> Result<(), FitError> {
|
||||
if self.open_gap_at.is_some() {
|
||||
return Ok(()); // already inside a dropout
|
||||
}
|
||||
self.open_gap_at = Some(at_ms);
|
||||
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
|
||||
// Written now, unterminated, so it survives a crash during the dropout.
|
||||
self.append(&LogEntry::Gap {
|
||||
at_ms,
|
||||
until_ms: None,
|
||||
reason: reason.into(),
|
||||
})?;
|
||||
self.sync()
|
||||
}
|
||||
|
||||
/// Mark a lap boundary (FR-8.7). `from_controller` distinguishes a Click
|
||||
/// button press from an on-screen tap.
|
||||
pub fn mark_lap(&mut self, at_ms: u64, from_controller: bool) -> Result<(), FitError> {
|
||||
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
|
||||
self.append(&LogEntry::Lap {
|
||||
at_ms,
|
||||
from_controller,
|
||||
})?;
|
||||
self.sync()
|
||||
}
|
||||
|
||||
/// Pause the ride timer. Time until [`Recorder::resume`] counts towards
|
||||
/// elapsed time but not timer time.
|
||||
pub fn pause(&mut self, at_ms: u64) -> Result<(), FitError> {
|
||||
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
|
||||
self.append(&LogEntry::Pause { at_ms })?;
|
||||
self.sync()
|
||||
}
|
||||
|
||||
/// Resume the ride timer.
|
||||
pub fn resume(&mut self, at_ms: u64) -> Result<(), FitError> {
|
||||
self.last_elapsed_ms = self.last_elapsed_ms.max(at_ms);
|
||||
self.append(&LogEntry::Resume { at_ms })?;
|
||||
self.sync()
|
||||
}
|
||||
|
||||
/// Close the journal and write the FIT activity to `fit_path`.
|
||||
///
|
||||
/// The FIT is built from the journal on disk, by the same code path crash
|
||||
/// recovery uses — so the file a rider gets after a clean ride and the file
|
||||
/// they get after a crash are produced identically.
|
||||
pub fn finish(mut self, fit_path: impl AsRef<Path>) -> Result<FitSummary, FitError> {
|
||||
let end_ms = self.last_elapsed_ms;
|
||||
// Close any dropout that was still open when the ride ended.
|
||||
if let Some(at_ms) = self.open_gap_at.take() {
|
||||
self.append(&LogEntry::Gap {
|
||||
at_ms,
|
||||
until_ms: Some(end_ms),
|
||||
reason: "ride ended during dropout".to_string(),
|
||||
})?;
|
||||
}
|
||||
self.append(&LogEntry::End { at_ms: end_ms })?;
|
||||
self.sync()?;
|
||||
self.finished = true;
|
||||
|
||||
let log_path = self.log_path.clone();
|
||||
drop(self);
|
||||
crate::build_fit_from_log(&log_path, fit_path)
|
||||
}
|
||||
|
||||
/// Close the journal without producing a FIT file. The journal remains on
|
||||
/// disk and can be turned into an activity later.
|
||||
pub fn abandon(mut self) -> PathBuf {
|
||||
self.finished = true;
|
||||
self.log_path.clone()
|
||||
}
|
||||
|
||||
/// Where the journal is being written.
|
||||
pub fn log_path(&self) -> &Path {
|
||||
&self.log_path
|
||||
}
|
||||
|
||||
/// How many samples have been committed to the journal.
|
||||
pub fn samples_written(&self) -> usize {
|
||||
self.samples_written
|
||||
}
|
||||
|
||||
/// The session header written at the top of the journal.
|
||||
pub fn session_start(&self) -> &SessionStart {
|
||||
&self.start
|
||||
}
|
||||
|
||||
/// Build a FIT from the journal *as it currently stands*, without ending
|
||||
/// the ride. Useful for a mid-ride preview or export, and the cheapest way
|
||||
/// to convince yourself the recording is sound before the ride ends.
|
||||
pub fn snapshot_fit(&mut self) -> Result<(Vec<u8>, FitSummary), FitError> {
|
||||
self.sync()?;
|
||||
let log = read_log(&self.log_path)?;
|
||||
encode_activity(&log)
|
||||
}
|
||||
|
||||
fn append(&mut self, entry: &LogEntry) -> Result<(), FitError> {
|
||||
let line = entry_to_line(entry)?;
|
||||
// One `write_all` per entry: a torn write can only ever damage the
|
||||
// final line, which the reader drops.
|
||||
self.file
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|source| FitError::Io {
|
||||
path: self.log_path.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn sync(&mut self) -> Result<(), FitError> {
|
||||
self.since_sync = 0;
|
||||
self.file.flush().map_err(|source| FitError::Io {
|
||||
path: self.log_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
self.file.sync_data().map_err(|source| FitError::Io {
|
||||
path: self.log_path.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Recorder {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
// Best effort: get whatever is buffered onto disk. A ride
|
||||
// interrupted by a panic is still recoverable from the journal.
|
||||
let _ = self.file.flush();
|
||||
let _ = self.file.sync_data();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's current UTC offset in seconds.
|
||||
fn local_utc_offset_secs() -> i32 {
|
||||
Local::now().offset().local_minus_utc()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rawlog::parse_log;
|
||||
use bikecontrol_core::{ControlMode, Telemetry};
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("bikecontrol-fit-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn started_at() -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
fn snapshot(elapsed_ms: u64) -> RideSnapshot {
|
||||
RideSnapshot {
|
||||
elapsed_ms,
|
||||
telemetry: Telemetry {
|
||||
elapsed_ms,
|
||||
power_w: Some(210),
|
||||
cadence_rpm: Some(88.0),
|
||||
..Default::default()
|
||||
},
|
||||
virtual_speed_kph: 32.4,
|
||||
virtual_distance_m: elapsed_ms as f64 * 0.009,
|
||||
gradient_pct: 1.5,
|
||||
elevation_gain_m: elapsed_ms as f32 * 0.000_135,
|
||||
mode: ControlMode::ManualGrade,
|
||||
target: None,
|
||||
profile_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn recorder(name: &str, opts: RecorderOptions) -> (Recorder, PathBuf) {
|
||||
let dir = tmpdir(name);
|
||||
let log = dir.join("ride.jsonl");
|
||||
let rec = Recorder::create_at(&log, opts, started_at(), 7200).unwrap();
|
||||
(rec, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_are_throttled_to_one_hertz() {
|
||||
let (mut rec, dir) = recorder("throttle", RecorderOptions::default());
|
||||
// 10 Hz input for 3 seconds.
|
||||
let mut accepted = 0;
|
||||
for i in 0..30u64 {
|
||||
if rec.record(&snapshot(i * 100)).unwrap() {
|
||||
accepted += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, 3, "0 ms, 1000 ms, 2000 ms");
|
||||
assert_eq!(rec.samples_written(), 3);
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_throttle_can_be_turned_off() {
|
||||
let (mut rec, dir) = recorder("nothrottle", RecorderOptions {
|
||||
sample_interval_ms: 0,
|
||||
auto_gap_after_ms: None,
|
||||
..Default::default()
|
||||
});
|
||||
for i in 0..10u64 {
|
||||
assert!(rec.record(&snapshot(i * 100)).unwrap());
|
||||
}
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sample_is_on_disk_before_the_call_returns() {
|
||||
// The crash-safety claim, tested directly: read the journal back with
|
||||
// the recorder still open and still holding the file.
|
||||
let (mut rec, dir) = recorder("durable", RecorderOptions::default());
|
||||
for i in 0..5u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
let text = std::fs::read_to_string(rec.log_path()).unwrap();
|
||||
let log = parse_log(&text).unwrap();
|
||||
assert_eq!(
|
||||
log.samples().count(),
|
||||
(i + 1) as usize,
|
||||
"sample {i} was not durable when record() returned"
|
||||
);
|
||||
}
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_journal_still_makes_a_fit() {
|
||||
// Simulates a crash: the process dies, nothing calls finish(), and the
|
||||
// journal is later handed to build_fit_from_log.
|
||||
let (mut rec, dir) = recorder("crash", RecorderOptions::default());
|
||||
for i in 0..20u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
let log_path = rec.abandon();
|
||||
|
||||
let fit_path = dir.join("recovered.fit");
|
||||
let summary = crate::build_fit_from_log(&log_path, &fit_path).unwrap();
|
||||
assert_eq!(summary.records, 20);
|
||||
assert!(summary.recovered_from_crash);
|
||||
assert!(crate::encode::verify(&std::fs::read(&fit_path).unwrap()).is_ok());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_writes_a_verifiable_fit_and_a_clean_journal() {
|
||||
let (mut rec, dir) = recorder("finish", RecorderOptions::default());
|
||||
for i in 0..30u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
rec.mark_lap(10_000, true).unwrap();
|
||||
let log_path = rec.log_path().to_path_buf();
|
||||
let fit_path = dir.join("ride.fit");
|
||||
let summary = rec.finish(&fit_path).unwrap();
|
||||
|
||||
assert_eq!(summary.records, 30);
|
||||
assert_eq!(summary.laps, 2);
|
||||
assert!(!summary.recovered_from_crash);
|
||||
assert_eq!(summary.skipped_log_lines, 0);
|
||||
|
||||
let bytes = std::fs::read(&fit_path).unwrap();
|
||||
assert_eq!(bytes.len(), summary.bytes);
|
||||
assert!(crate::encode::verify(&bytes).is_ok());
|
||||
|
||||
// The journal is still there and still describes the same ride.
|
||||
let log = crate::read_log(&log_path).unwrap();
|
||||
assert!(log.clean_shutdown);
|
||||
assert_eq!(log.samples().count(), 30);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_file_is_identical_to_the_clean_one() {
|
||||
// The strongest form of FR-8.4: recovery is not a degraded path, it is
|
||||
// the same path.
|
||||
let (mut rec, dir) = recorder("identical", RecorderOptions::default());
|
||||
for i in 0..15u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
let log_path = rec.log_path().to_path_buf();
|
||||
let clean = dir.join("clean.fit");
|
||||
rec.finish(&clean).unwrap();
|
||||
|
||||
let rebuilt = dir.join("rebuilt.fit");
|
||||
crate::build_fit_from_log(&log_path, &rebuilt).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&clean).unwrap(),
|
||||
std::fs::read(&rebuilt).unwrap()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropout_is_detected_automatically() {
|
||||
let (mut rec, dir) = recorder("autogap", RecorderOptions::default());
|
||||
rec.record(&snapshot(0)).unwrap();
|
||||
rec.record(&snapshot(1000)).unwrap();
|
||||
// Ten seconds of silence, then telemetry returns.
|
||||
rec.record(&snapshot(11_000)).unwrap();
|
||||
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
|
||||
assert_eq!(log.gaps(11_000), vec![(1000, 11_000)]);
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_gap_is_closed_when_telemetry_returns() {
|
||||
let (mut rec, dir) = recorder("explicitgap", RecorderOptions {
|
||||
auto_gap_after_ms: None,
|
||||
..Default::default()
|
||||
});
|
||||
rec.record(&snapshot(0)).unwrap();
|
||||
rec.mark_gap(1000, "peripheral disconnected").unwrap();
|
||||
// A second mark while already in a dropout is a no-op.
|
||||
rec.mark_gap(2000, "still gone").unwrap();
|
||||
rec.record(&snapshot(9000)).unwrap();
|
||||
|
||||
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
|
||||
let gaps = log.gaps(9000);
|
||||
// The unterminated marker written at 1000 ms, plus its closure.
|
||||
assert!(gaps.contains(&(1000, 9000)));
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropout_open_at_the_end_of_the_ride_is_closed_by_finish() {
|
||||
let (mut rec, dir) = recorder("opengap", RecorderOptions {
|
||||
auto_gap_after_ms: None,
|
||||
..Default::default()
|
||||
});
|
||||
rec.record(&snapshot(0)).unwrap();
|
||||
rec.record(&snapshot(5000)).unwrap();
|
||||
rec.mark_gap(6000, "trainer lost").unwrap();
|
||||
let fit = dir.join("ride.fit");
|
||||
let summary = rec.finish(&fit).unwrap();
|
||||
assert!(summary.gaps >= 1);
|
||||
assert!(crate::encode::verify(&std::fs::read(&fit).unwrap()).is_ok());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_resume_are_journalled() {
|
||||
let (mut rec, dir) = recorder("pause", RecorderOptions::default());
|
||||
for i in 0..5u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
rec.pause(5000).unwrap();
|
||||
rec.resume(20_000).unwrap();
|
||||
for i in 20..25u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
let fit = dir.join("ride.fit");
|
||||
let summary = rec.finish(&fit).unwrap();
|
||||
assert_eq!(summary.total_elapsed_s, 24.0);
|
||||
assert_eq!(summary.total_timer_s, 9.0, "24 s elapsed less 15 s paused");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mid_ride_snapshot_is_a_valid_fit() {
|
||||
let (mut rec, dir) = recorder("midride", RecorderOptions::default());
|
||||
for i in 0..8u64 {
|
||||
rec.record(&snapshot(i * 1000)).unwrap();
|
||||
}
|
||||
let (bytes, summary) = rec.snapshot_fit().unwrap();
|
||||
assert!(crate::encode::verify(&bytes).is_ok());
|
||||
assert_eq!(summary.records, 8);
|
||||
assert!(summary.recovered_from_crash, "no end marker yet");
|
||||
// Recording continues afterwards.
|
||||
assert!(rec.record(&snapshot(8000)).unwrap());
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_a_recorder_makes_missing_directories() {
|
||||
let dir = tmpdir("mkdir").join("a").join("b");
|
||||
let log = dir.join("ride.jsonl");
|
||||
let rec = Recorder::create_at(&log, RecorderOptions::default(), started_at(), 0).unwrap();
|
||||
assert!(log.exists());
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(tmpdir("mkdir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_header_captures_the_start_and_offset() {
|
||||
let (rec, dir) = recorder("header", RecorderOptions::default());
|
||||
let start = rec.session_start();
|
||||
assert_eq!(start.start_unix_ms, started_at().timestamp_millis());
|
||||
assert_eq!(start.utc_offset_secs, 7200);
|
||||
assert_eq!(start.log_format, LOG_FORMAT_VERSION);
|
||||
assert_eq!(
|
||||
start.sub_sport,
|
||||
crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY
|
||||
);
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gear_can_be_recorded_alongside_the_snapshot() {
|
||||
let (mut rec, dir) = recorder("gear", RecorderOptions::default());
|
||||
rec.record_sample(Sample::from_snapshot(&snapshot(0)).with_gear(11))
|
||||
.unwrap();
|
||||
let log = parse_log(&std::fs::read_to_string(rec.log_path()).unwrap()).unwrap();
|
||||
let s = log.samples().next().unwrap();
|
||||
assert_eq!(s.gear, Some(11));
|
||||
assert_eq!(s.mode, Some(ControlMode::ManualGrade));
|
||||
let _ = rec.abandon();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! FIT `date_time` conversion.
|
||||
//!
|
||||
//! FIT counts seconds since **1989-12-31 00:00:00 UTC**, not the Unix epoch.
|
||||
//! Feeding a Unix timestamp straight into a FIT file lands the activity in
|
||||
//! 1989, which is one of the more common ways a hand-rolled encoder produces a
|
||||
//! file that parses fine and is still useless.
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use crate::FitError;
|
||||
|
||||
/// The FIT epoch expressed as a Unix timestamp: 1989-12-31T00:00:00Z.
|
||||
pub const FIT_EPOCH_UNIX_SECS: i64 = 631_065_600;
|
||||
|
||||
/// The `date_time` invalid value. Also the boundary below which a raw value is
|
||||
/// interpreted as a system time rather than a UTC timestamp.
|
||||
pub const DATE_TIME_INVALID: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// `date_time` values below this are "system time" (seconds since power-on),
|
||||
/// not wall-clock. We never emit one, but the check keeps us honest.
|
||||
pub const DATE_TIME_MIN: u32 = 0x1000_0000;
|
||||
|
||||
/// Convert a UTC instant to a FIT `date_time`.
|
||||
///
|
||||
/// Fails for instants before the FIT epoch or beyond the `u32` range.
|
||||
pub fn to_fit(dt: DateTime<Utc>) -> Result<u32, FitError> {
|
||||
from_unix_secs(dt.timestamp())
|
||||
}
|
||||
|
||||
/// Convert Unix seconds to a FIT `date_time`.
|
||||
pub fn from_unix_secs(unix_secs: i64) -> Result<u32, FitError> {
|
||||
let secs = unix_secs - FIT_EPOCH_UNIX_SECS;
|
||||
if secs < 0 {
|
||||
return Err(FitError::TimestampOutOfRange { unix_secs });
|
||||
}
|
||||
u32::try_from(secs).map_err(|_| FitError::TimestampOutOfRange { unix_secs })
|
||||
}
|
||||
|
||||
/// Convert Unix milliseconds to a FIT `date_time`, rounding to the nearest
|
||||
/// second. Sub-second resolution has no representation in `date_time`.
|
||||
pub fn from_unix_millis(unix_millis: i64) -> Result<u32, FitError> {
|
||||
from_unix_secs(unix_millis.div_euclid(1000) + i64::from(unix_millis.rem_euclid(1000) >= 500))
|
||||
}
|
||||
|
||||
/// Convert a FIT `date_time` back to a UTC instant. The inverse of [`to_fit`].
|
||||
pub fn to_utc(fit: u32) -> DateTime<Utc> {
|
||||
Utc.timestamp_opt(i64::from(fit) + FIT_EPOCH_UNIX_SECS, 0)
|
||||
.single()
|
||||
.expect("every u32 offset from the FIT epoch is a representable instant")
|
||||
}
|
||||
|
||||
/// Build a `local_timestamp`: the same instant expressed in the rider's local
|
||||
/// time zone, still counted from the FIT epoch. Garmin Connect uses this to
|
||||
/// show the ride at the time of day it actually happened.
|
||||
pub fn to_local(fit_utc: u32, utc_offset_secs: i32) -> u32 {
|
||||
fit_utc.saturating_add_signed(utc_offset_secs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn epoch_constant_is_1989_12_31_utc() {
|
||||
let epoch = Utc.with_ymd_and_hms(1989, 12, 31, 0, 0, 0).unwrap();
|
||||
assert_eq!(epoch.timestamp(), FIT_EPOCH_UNIX_SECS);
|
||||
assert_eq!(to_fit(epoch).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_not_the_unix_epoch() {
|
||||
// The whole point: a Unix timestamp is ~631 million seconds larger
|
||||
// than the FIT value for the same instant.
|
||||
let dt = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap();
|
||||
let fit = to_fit(dt).unwrap();
|
||||
assert_eq!(i64::from(fit), dt.timestamp() - FIT_EPOCH_UNIX_SECS);
|
||||
assert_ne!(i64::from(fit), dt.timestamp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_values() {
|
||||
// 1990-01-01T00:00:00Z is exactly one day after the FIT epoch.
|
||||
assert_eq!(
|
||||
to_fit(Utc.with_ymd_and_hms(1990, 1, 1, 0, 0, 0).unwrap()).unwrap(),
|
||||
86_400
|
||||
);
|
||||
// 2020-01-01T00:00:00Z, computed independently:
|
||||
// (2020-01-01 unix 1577836800) - 631065600 = 946771200.
|
||||
assert_eq!(
|
||||
to_fit(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()).unwrap(),
|
||||
946_771_200
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_over_a_wide_range() {
|
||||
for &unix in &[
|
||||
FIT_EPOCH_UNIX_SECS,
|
||||
FIT_EPOCH_UNIX_SECS + 1,
|
||||
946_684_800, // 2000-01-01
|
||||
1_600_000_000, // 2020-09
|
||||
1_785_000_000, // 2026-07
|
||||
4_000_000_000, // 2096
|
||||
] {
|
||||
let fit = from_unix_secs(unix).unwrap();
|
||||
assert_eq!(to_utc(fit).timestamp(), unix, "round trip failed for {unix}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_datetime() {
|
||||
let dt = Utc.with_ymd_and_hms(2026, 8, 5, 9, 41, 17).unwrap();
|
||||
assert_eq!(to_utc(to_fit(dt).unwrap()), dt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modern_timestamp_is_above_the_system_time_boundary() {
|
||||
// Decoders treat date_time < 0x10000000 as system (uptime) time. Any
|
||||
// ride recorded this decade must be well above it.
|
||||
let fit = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 0, 0, 0).unwrap()).unwrap();
|
||||
assert!(fit > DATE_TIME_MIN);
|
||||
assert!(fit < DATE_TIME_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pre_epoch_and_reports_the_offending_value() {
|
||||
let err = to_fit(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()).unwrap_err();
|
||||
assert!(matches!(err, FitError::TimestampOutOfRange { unix_secs: 0 }));
|
||||
assert!(from_unix_secs(FIT_EPOCH_UNIX_SECS - 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millis_round_to_nearest_second() {
|
||||
let base = FIT_EPOCH_UNIX_SECS * 1000;
|
||||
assert_eq!(from_unix_millis(base).unwrap(), 0);
|
||||
assert_eq!(from_unix_millis(base + 499).unwrap(), 0);
|
||||
assert_eq!(from_unix_millis(base + 500).unwrap(), 1);
|
||||
assert_eq!(from_unix_millis(base + 1499).unwrap(), 1);
|
||||
assert_eq!(from_unix_millis(base + 1500).unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_timestamp_applies_the_offset() {
|
||||
let utc = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()).unwrap();
|
||||
assert_eq!(to_local(utc, 7200), utc + 7200); // CEST
|
||||
assert_eq!(to_local(utc, -18000), utc - 18000); // EST
|
||||
assert_eq!(to_local(utc, 0), utc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user