//! 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. Variant names are the FIT type names. #[allow(missing_docs)] #[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. Variant /// names mirror the FIT base types they encode to. #[allow(missing_docs)] #[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) { 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) -> &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. fn shape(&self) -> Shape { self.fields .iter() .map(|(n, v)| (*n, v.size(), v.base_type() as u8)) .collect() } } /// A definition-message shape: `(field number, size in bytes, base type)` per /// field. Two messages sharing a shape can share a definition. type Shape = Vec<(u8, u8, u8)>; /// Accumulates data records and emits a complete FIT file. /// /// Definitions are cached per local message type, so a definition is re-emitted /// 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, /// Cached definition shape per local message type (0..16). defs: [Option<(u16, Shape)>; 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 { 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 { /// Shorter than the smallest legal file (header plus CRC). #[error("file is {0} bytes, too short to be a FIT file")] TooShort( /// Actual file length. usize, ), /// Byte 0 is neither 12 nor 14. #[error("header size {0} is neither 12 nor 14")] BadHeaderSize( /// The declared header size. u8, ), /// Bytes 8..12 are not `.FIT`. #[error("data type signature is {0:?}, expected \".FIT\"")] BadSignature( /// The bytes found where the signature should be. [u8; 4], ), /// The header's data size does not match the bytes actually present. #[error("header declares {declared} data bytes but the file carries {actual}")] DataSizeMismatch { /// Data size from the header. declared: usize, /// Data bytes actually present. actual: usize, }, /// The header CRC does not check out. #[error("header CRC is {stored:#06x}, computed {computed:#06x}")] HeaderCrc { /// CRC read from the file. stored: u16, /// CRC computed over bytes 0..12. computed: u16, }, /// The trailing file CRC does not check out. #[error("file CRC is {stored:#06x}, computed {computed:#06x}")] FileCrc { /// CRC read from the end of the file. stored: u16, /// CRC computed over header plus data. 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" ); } } }