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 { 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 { 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 { 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 { let percent = *bytes.first()?; (percent <= 100).then_some(Telemetry::Battery { percent }) } /// CSC Measurement (0x2A5B): извлекаем кумулятивные счётчики шатуна. pub(crate) fn parse_csc(bytes: &[u8]) -> Option { 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, last_time: Option, } 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 { 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); } }