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:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+578
View File
@@ -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"
);
}
}
}