You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ridein/src-tauri/src/telemetry.rs

238 lines
8.0 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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);
}
}