|
|
use serde::Serialize;
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
pub(crate) enum Telemetry {
|
|
|
HeartRate { bpm: u16 },
|
|
|
Cadence { rpm: f32 },
|
|
|
Power { watts: i16 },
|
|
|
Battery { percent: u8 },
|
|
|
}
|
|
|
|
|
|
/// Сырые данные CSC: кумулятивные счётчики шатуна.
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
|
pub(crate) struct CscRaw {
|
|
|
pub crank_revs: u16,
|
|
|
pub crank_time: u16,
|
|
|
}
|
|
|
|
|
|
/// Heart Rate Measurement (0x2A37): флаг + значение (uint8 или uint16).
|
|
|
pub(crate) fn parse_hr(bytes: &[u8]) -> Option<Telemetry> {
|
|
|
let flags = *bytes.first()?;
|
|
|
if flags & 0x01 != 0 {
|
|
|
let bpm = u16::from_le_bytes([*bytes.get(1)?, *bytes.get(2)?]);
|
|
|
Some(Telemetry::HeartRate { bpm })
|
|
|
} else {
|
|
|
Some(Telemetry::HeartRate { bpm: *bytes.get(1)? as u16 })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Cycling Power Measurement (0x2A63): мгновенная мощность sint16 (LE).
|
|
|
pub(crate) fn parse_power(bytes: &[u8]) -> Option<Telemetry> {
|
|
|
if bytes.len() < 4 {
|
|
|
return None;
|
|
|
}
|
|
|
let watts = i16::from_le_bytes([bytes[2], bytes[3]]);
|
|
|
Some(Telemetry::Power { watts })
|
|
|
}
|
|
|
|
|
|
/// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags.
|
|
|
/// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5).
|
|
|
pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> {
|
|
|
if bytes.len() < 2 {
|
|
|
return Vec::new();
|
|
|
}
|
|
|
let flags = u16::from_le_bytes([bytes[0], bytes[1]]);
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
// Byte offset of instantaneous cadence (after the 2-byte flags).
|
|
|
let cadence_off = 2
|
|
|
+ if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed
|
|
|
+ if flags & 0x0002 != 0 { 2 } else { 0 }; // average speed
|
|
|
|
|
|
// Byte offset of instantaneous power.
|
|
|
let power_off = cadence_off
|
|
|
+ if flags & 0x0008 != 0 { 2 } else { 0 } // instantaneous cadence
|
|
|
+ if flags & 0x0010 != 0 { 2 } else { 0 } // average cadence
|
|
|
+ if flags & 0x0004 != 0 { 3 } else { 0 } // total distance (uint24)
|
|
|
+ if flags & 0x0080 != 0 { 2 } else { 0 }; // resistance level
|
|
|
|
|
|
if flags & 0x0008 != 0 && cadence_off + 2 <= bytes.len() {
|
|
|
let raw = u16::from_le_bytes([bytes[cadence_off], bytes[cadence_off + 1]]);
|
|
|
out.push(Telemetry::Cadence { rpm: raw as f32 / 2.0 });
|
|
|
}
|
|
|
if flags & 0x0020 != 0 && power_off + 2 <= bytes.len() {
|
|
|
let raw = u16::from_le_bytes([bytes[power_off], bytes[power_off + 1]]);
|
|
|
out.push(Telemetry::Power { watts: raw as i16 });
|
|
|
}
|
|
|
|
|
|
out
|
|
|
}
|
|
|
|
|
|
/// Battery Level (0x2A19): один байт 0..=100.
|
|
|
pub(crate) fn parse_battery(bytes: &[u8]) -> Option<Telemetry> {
|
|
|
let percent = *bytes.first()?;
|
|
|
(percent <= 100).then_some(Telemetry::Battery { percent })
|
|
|
}
|
|
|
|
|
|
/// CSC Measurement (0x2A5B): извлекаем кумулятивные счётчики шатуна.
|
|
|
pub(crate) fn parse_csc(bytes: &[u8]) -> Option<CscRaw> {
|
|
|
let flags = *bytes.first()?;
|
|
|
if flags & 0x02 == 0 {
|
|
|
return None; // нет данных шатуна
|
|
|
}
|
|
|
let mut idx = 1;
|
|
|
if flags & 0x01 != 0 {
|
|
|
idx += 6; // пропускаем данные колеса (uint32 + uint16)
|
|
|
}
|
|
|
if bytes.len() < idx + 4 {
|
|
|
return None;
|
|
|
}
|
|
|
let crank_revs = u16::from_le_bytes([bytes[idx], bytes[idx + 1]]);
|
|
|
let crank_time = u16::from_le_bytes([bytes[idx + 2], bytes[idx + 3]]);
|
|
|
Some(CscRaw { crank_revs, crank_time })
|
|
|
}
|
|
|
|
|
|
/// Превращает кумулятивные счётчики CSC в обороты в минуту.
|
|
|
pub(crate) struct CscAccumulator {
|
|
|
last_revs: Option<u16>,
|
|
|
last_time: Option<u16>,
|
|
|
}
|
|
|
|
|
|
impl CscAccumulator {
|
|
|
pub(crate) fn new() -> Self {
|
|
|
Self { last_revs: None, last_time: None }
|
|
|
}
|
|
|
|
|
|
/// Возвращает rpm или None (нужно минимум два измерения).
|
|
|
pub(crate) fn push(&mut self, raw: CscRaw) -> Option<f32> {
|
|
|
let (Some(prev_revs), Some(prev_time)) = (self.last_revs, self.last_time) else {
|
|
|
self.last_revs = Some(raw.crank_revs);
|
|
|
self.last_time = Some(raw.crank_time);
|
|
|
return None;
|
|
|
};
|
|
|
self.last_revs = Some(raw.crank_revs);
|
|
|
self.last_time = Some(raw.crank_time);
|
|
|
|
|
|
let d_revs = raw.crank_revs.wrapping_sub(prev_revs) as f32;
|
|
|
let d_time = raw.crank_time.wrapping_sub(prev_time) as f32; // 1/1024 с
|
|
|
if d_time == 0.0 {
|
|
|
return None;
|
|
|
}
|
|
|
Some(d_revs * 60.0 * 1024.0 / d_time)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
|
use super::*;
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_hr_8bit() {
|
|
|
// flags=0x00 (8-бит), значение 120
|
|
|
assert_eq!(parse_hr(&[0x00, 0x78]), Some(Telemetry::HeartRate { bpm: 120 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_hr_16bit() {
|
|
|
// flags=0x01 (16-бит), значение 180 = 0x00B4
|
|
|
assert_eq!(parse_hr(&[0x01, 0xB4, 0x00]), Some(Telemetry::HeartRate { bpm: 180 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_hr_empty() {
|
|
|
assert_eq!(parse_hr(&[]), None);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_hr_missing_value() {
|
|
|
assert_eq!(parse_hr(&[0x00]), None); // 8-бит, но значения нет
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_power() {
|
|
|
// flags=0x0000, мощность 200 = 0x00C8 (LE)
|
|
|
assert_eq!(parse_power(&[0x00, 0x00, 0xC8, 0x00]), Some(Telemetry::Power { watts: 200 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_power_negative() {
|
|
|
// -200 = 0xFF38 (sint16 LE)
|
|
|
assert_eq!(parse_power(&[0x00, 0x00, 0x38, 0xFF]), Some(Telemetry::Power { watts: -200 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_power_too_short() {
|
|
|
assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_indoor_bike_cadence_and_power() {
|
|
|
// flags: bit 3 (cadence) + bit 5 (power) = 0x0028
|
|
|
// cadence uint16 = 180 (0.5 rpm) => 90 rpm; power sint16 = 250 W
|
|
|
let bytes = [0x28, 0x00, 0xB4, 0x00, 0xFA, 0x00];
|
|
|
assert_eq!(
|
|
|
parse_indoor_bike_data(&bytes),
|
|
|
vec![
|
|
|
Telemetry::Cadence { rpm: 90.0 },
|
|
|
Telemetry::Power { watts: 250 },
|
|
|
]
|
|
|
);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_indoor_bike_too_short() {
|
|
|
assert!(parse_indoor_bike_data(&[0x28]).is_empty());
|
|
|
assert!(parse_indoor_bike_data(&[]).is_empty());
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_battery() {
|
|
|
assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 }));
|
|
|
assert_eq!(parse_battery(&[0]), Some(Telemetry::Battery { percent: 0 }));
|
|
|
assert_eq!(parse_battery(&[101]), None); // вне диапазона
|
|
|
assert_eq!(parse_battery(&[]), None);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_csc_crank_only() {
|
|
|
// flags=0x02 (только шатун), revs=5, time=256
|
|
|
let bytes = [0x02, 0x05, 0x00, 0x00, 0x01];
|
|
|
assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_csc_with_wheel() {
|
|
|
// flags=0x03 (колесо+шатун), колесо 6 байт, затем revs=5, time=256
|
|
|
let bytes = [0x03, 0, 0, 0, 0, 0, 0, 0x05, 0x00, 0x00, 0x01];
|
|
|
assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 }));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_parse_csc_no_crank() {
|
|
|
// flags=0x01 (только колесо) — данных шатуна нет
|
|
|
let bytes = [0x01, 0, 0, 0, 0, 0, 0];
|
|
|
assert_eq!(parse_csc(&bytes), None);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_accumulator_needs_two_samples() {
|
|
|
let mut acc = CscAccumulator::new();
|
|
|
assert_eq!(acc.push(CscRaw { crank_revs: 0, crank_time: 0 }), None);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_accumulator_computes_rpm() {
|
|
|
let mut acc = CscAccumulator::new();
|
|
|
acc.push(CscRaw { crank_revs: 0, crank_time: 0 });
|
|
|
// 1 оборот за 1024 тика (1 с) = 60 rpm
|
|
|
assert_eq!(acc.push(CscRaw { crank_revs: 1, crank_time: 1024 }), Some(60.0));
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_accumulator_zero_time_returns_none() {
|
|
|
let mut acc = CscAccumulator::new();
|
|
|
acc.push(CscRaw { crank_revs: 0, crank_time: 0 });
|
|
|
assert_eq!(acc.push(CscRaw { crank_revs: 5, crank_time: 0 }), None);
|
|
|
}
|
|
|
}
|