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
+120
View File
@@ -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));
}
}