Files
BikeControl/crates/fit/src/encode.rs
T
dtourolleandClaude Opus 5 3a2a787b7d Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

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

615 lines
21 KiB
Rust

//! 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<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.
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<u8>,
/// 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<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 {
/// 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"
);
}
}
}