Чтение данных с датчиков и запись + моки устройств

pull/3/head
lambda 1 week ago
parent a979f5dda7
commit d8049e89c3
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

58
src-tauri/Cargo.lock generated

@ -178,13 +178,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn 3.0.9",
] ]
[[package]] [[package]]
@ -1114,9 +1114,9 @@ dependencies = [
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -1129,9 +1129,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -1139,15 +1139,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -1156,9 +1156,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]] [[package]]
name = "futures-lite" name = "futures-lite"
@ -1175,32 +1175,32 @@ dependencies = [
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn 3.0.9",
] ]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.31" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -1210,7 +1210,6 @@ dependencies = [
"futures-task", "futures-task",
"memchr", "memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils",
"slab", "slab",
] ]
@ -3190,7 +3189,9 @@ dependencies = [
name = "rideinn" name = "rideinn"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"btleplug", "btleplug",
"futures",
"hex", "hex",
"serde", "serde",
"serde_json", "serde_json",
@ -3709,6 +3710,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"

@ -27,6 +27,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
hex = "0.4" hex = "0.4"
uuid = "1.16.0" uuid = "1.16.0"
thiserror = "1" thiserror = "1"
async-trait = "0.1.92"
futures = "0.3.34"
[profile.dev] [profile.dev]
incremental = true # Compile your binary in smaller steps. incremental = true # Compile your binary in smaller steps.

@ -11,4 +11,28 @@ pub enum BleError {
/// Не удалось получить список найденных устройств. /// Не удалось получить список найденных устройств.
#[error("не удалось получить список BLE-устройств: {0}")] #[error("не удалось получить список BLE-устройств: {0}")]
PeripheralsFailed(btleplug::Error), PeripheralsFailed(btleplug::Error),
/// Устройство не найдено по id.
#[error("устройство не найдено: {0}")]
DeviceNotFound(String),
/// Не удалось подключиться к устройству.
#[error("не удалось подключиться к устройству: {0}")]
ConnectFailed(btleplug::Error),
/// Не удалось обнаружить сервисы/характеристики.
#[error("не удалось обнаружить сервисы: {0}")]
DiscoverFailed(btleplug::Error),
/// Не удалось подписаться на уведомления.
#[error("не удалось подписаться на уведомления: {0}")]
SubscribeFailed(btleplug::Error),
/// Не удалось получить поток уведомлений.
#[error("не удалось получить поток уведомлений: {0}")]
NotificationsFailed(btleplug::Error),
/// Не удалось записать команду.
#[error("не удалось записать команду: {0}")]
WriteFailed(btleplug::Error),
} }

@ -0,0 +1,126 @@
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use futures::stream::{self, Stream};
use futures::StreamExt;
use crate::ble::source::SensorSource;
use crate::ble::BleError;
use crate::commands::Command;
use crate::device::{get_device_types, Device, DeviceType};
use crate::telemetry::Telemetry;
/// Виртуальный источник: scripted-поток, без реального BLE.
pub(crate) struct MockSource {
devices: Vec<Device>,
commands: Arc<Mutex<Vec<Command>>>,
}
impl MockSource {
pub(crate) fn new() -> Self {
let devices = vec![
Device {
id: "mock-hr".into(),
address: "00:00:00:00:00:01".into(),
display_name: Some("Mock HR".into()),
rssi: Some(-45),
service_uuids: vec!["0000180d-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["180d::2a37".into()],
manufacturer_data: None,
metadata: None,
},
Device {
id: "mock-cad".into(),
address: "00:00:00:00:00:02".into(),
display_name: Some("Mock Cadence".into()),
rssi: Some(-50),
service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["1816::2a5b".into()],
manufacturer_data: None,
metadata: None,
},
Device {
id: "mock-trainer".into(),
address: "00:00:00:00:00:03".into(),
display_name: Some("Mock Trainer".into()),
rssi: Some(-55),
service_uuids: vec!["00001826-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["1826::2ad2".into()],
manufacturer_data: None,
metadata: None,
},
];
Self {
devices,
commands: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl SensorSource for MockSource {
async fn scan(&self, _seconds: u64) -> Result<Vec<Device>, BleError> {
Ok(self.devices.clone())
}
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>
{
let types = get_device_types(device);
let s: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = if types.contains(&DeviceType::Trainer) {
// мощность рампой 150..300 Вт
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let watts = 150 + ((i % 16) * 10) as i16;
Some((Telemetry::Power { watts }, i.wrapping_add(1)))
})
.boxed()
} else if types.contains(&DeviceType::CadSensor) {
// каденс 85..95 rpm
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let rpm = 85.0 + (i % 10) as f32;
Some((Telemetry::Cadence { rpm }, i.wrapping_add(1)))
})
.boxed()
} else {
// пульс 120..169
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(1000)).await;
let bpm = 120 + (i % 50);
Some((Telemetry::HeartRate { bpm }, i.wrapping_add(1)))
})
.boxed()
};
Ok(s)
}
async fn command(&self, _device: &Device, cmd: Command) -> Result<(), BleError> {
self.commands.lock().unwrap().push(cmd);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_scan_returns_three_devices() {
let src = MockSource::new();
let devices = src.scan(0).await.unwrap();
assert_eq!(devices.len(), 3);
}
#[tokio::test]
async fn test_mock_stream_yields_telemetry() {
let src = MockSource::new();
let devices = src.scan(0).await.unwrap();
let hr = devices.iter().find(|d| d.id == "mock-hr").unwrap();
let mut stream = src.stream(hr).await.unwrap();
let first = stream.next().await;
assert!(matches!(first, Some(Telemetry::HeartRate { .. })));
}
}

@ -1,7 +1,11 @@
mod error; mod error;
mod utils; mod utils;
mod scanner; mod scanner;
mod source;
mod mock;
pub use error::BleError; pub use error::BleError;
pub use scanner::scan_devices; pub use scanner::scan_devices;
pub use utils::normalize_uuid; pub use utils::normalize_uuid;
pub(crate) use source::{BleSource, SensorSource};
pub(crate) use mock::MockSource;

@ -0,0 +1,124 @@
use std::pin::Pin;
use async_trait::async_trait;
use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::{future, Stream, StreamExt};
use uuid::Uuid;
use crate::ble::BleError;
use crate::commands::{encode_command, Command};
use crate::device::Device;
use crate::telemetry::{parse_csc, parse_hr, parse_power, CscAccumulator, Telemetry};
#[async_trait]
pub(crate) trait SensorSource: Send + Sync {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError>;
/// Подключиться и вернуть поток распарсенных измерений.
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>;
/// Отправить команду на управляющую точку устройства (ERG и т.п.).
async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>;
}
const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37);
const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B);
const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63);
/// Реальный источник данных через btleplug.
pub(crate) struct BleSource {
adapter: Adapter,
}
impl BleSource {
pub(crate) fn new(adapter: Adapter) -> Self {
Self { adapter }
}
async fn find_peripheral(&self, device: &Device) -> Result<Peripheral, BleError> {
let peripherals = self
.adapter
.peripherals()
.await
.map_err(BleError::PeripheralsFailed)?;
peripherals
.into_iter()
.find(|p| p.id().to_string() == device.id)
.ok_or_else(|| BleError::DeviceNotFound(device.id.clone()))
}
}
#[async_trait]
impl SensorSource for BleSource {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError> {
crate::ble::scan_devices(&self.adapter, seconds).await
}
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>
{
let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?;
let targets = [HR_MEASUREMENT, CSC_MEASUREMENT, POWER_MEASUREMENT];
for ch in peripheral.characteristics().iter() {
if targets.contains(&ch.uuid) {
peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?;
}
}
let notifications = peripheral
.notifications()
.await
.map_err(BleError::NotificationsFailed)?;
let mut acc = CscAccumulator::new();
let stream = notifications.filter_map(move |n| future::ready(parse_notification(n, &mut acc)));
Ok(Box::pin(stream))
}
async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError> {
let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?;
let (control_point, payload) = encode_command(cmd);
let cp_uuid = uuid_from_u16(control_point.short_uuid());
let ch = peripheral
.characteristics()
.into_iter()
.find(|c| c.uuid == cp_uuid)
.ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?;
peripheral
.write(&ch, &payload, WriteType::WithoutResponse)
.await
.map_err(BleError::WriteFailed)
}
}
async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> {
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await.map_err(BleError::ConnectFailed)?;
}
peripheral
.discover_services()
.await
.map_err(BleError::DiscoverFailed)?;
Ok(())
}
fn parse_notification(n: ValueNotification, acc: &mut CscAccumulator) -> Option<Telemetry> {
if n.uuid == HR_MEASUREMENT {
parse_hr(&n.value)
} else if n.uuid == CSC_MEASUREMENT {
parse_csc(&n.value)
.and_then(|raw| acc.push(raw))
.map(|rpm| Telemetry::Cadence { rpm })
} else if n.uuid == POWER_MEASUREMENT {
parse_power(&n.value)
} else {
None
}
}

@ -0,0 +1,110 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Command {
// FTMS Fitness Machine Control Point (0x2AD9)
FtmsRequestControl, // opcode 0x00
FtmsReset, // opcode 0x01
FtmsSetTargetResistance(u8), // opcode 0x04
FtmsSetTargetPower(i16), // opcode 0x05 ERG
FtmsStart, // opcode 0x07
FtmsStop, // opcode 0x08 (0x01=stop, 0x02=pause)
// CSC SC Control Point (0x2A55)
CscSetCumulativeValue(u32), // opcode 0x01
CscSetWheelCircumference(u16), // opcode 0x10
}
/// Управляющая точка, на которую пишется команда.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ControlPoint {
Ftms,
Csc,
}
impl ControlPoint {
/// Короткий UUID характеристики (16-бит).
pub(crate) fn short_uuid(self) -> u16 {
match self {
ControlPoint::Ftms => 0x2AD9,
ControlPoint::Csc => 0x2A55,
}
}
}
/// Чистая функция: команда → (точка, байты payload).
pub(crate) fn encode_command(cmd: Command) -> (ControlPoint, Vec<u8>) {
match cmd {
Command::FtmsRequestControl => (ControlPoint::Ftms, vec![0x00]),
Command::FtmsReset => (ControlPoint::Ftms, vec![0x01]),
Command::FtmsSetTargetResistance(level) => (ControlPoint::Ftms, vec![0x04, level]),
Command::FtmsSetTargetPower(watts) => {
let mut p = vec![0x05];
p.extend_from_slice(&watts.to_le_bytes());
(ControlPoint::Ftms, p)
}
Command::FtmsStart => (ControlPoint::Ftms, vec![0x07]),
Command::FtmsStop => (ControlPoint::Ftms, vec![0x08, 0x01]),
Command::CscSetCumulativeValue(val) => {
let mut p = vec![0x01];
p.extend_from_slice(&val.to_le_bytes());
(ControlPoint::Csc, p)
}
Command::CscSetWheelCircumference(mm) => {
let mut p = vec![0x10];
p.extend_from_slice(&mm.to_le_bytes());
(ControlPoint::Csc, p)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_request_control() {
assert_eq!(
encode_command(Command::FtmsRequestControl),
(ControlPoint::Ftms, vec![0x00])
);
}
#[test]
fn test_encode_set_target_power() {
// 200 Вт = 0x00C8 → LE [0xC8, 0x00]
assert_eq!(
encode_command(Command::FtmsSetTargetPower(200)),
(ControlPoint::Ftms, vec![0x05, 0xC8, 0x00])
);
}
#[test]
fn test_encode_set_target_resistance() {
assert_eq!(
encode_command(Command::FtmsSetTargetResistance(3)),
(ControlPoint::Ftms, vec![0x04, 0x03])
);
}
#[test]
fn test_encode_stop() {
assert_eq!(
encode_command(Command::FtmsStop),
(ControlPoint::Ftms, vec![0x08, 0x01])
);
}
#[test]
fn test_encode_csc_cumulative_value() {
// 1000 = 0x000003E8 → LE [0xE8, 0x03, 0x00, 0x00]
assert_eq!(
encode_command(Command::CscSetCumulativeValue(1000)),
(ControlPoint::Csc, vec![0x01, 0xE8, 0x03, 0x00, 0x00])
);
}
#[test]
fn test_control_point_uuids() {
assert_eq!(ControlPoint::Ftms.short_uuid(), 0x2AD9);
assert_eq!(ControlPoint::Csc.short_uuid(), 0x2A55);
}
}

@ -2,10 +2,15 @@ mod device;
mod ble; mod ble;
mod device_repo; mod device_repo;
mod state; mod state;
mod telemetry;
mod commands;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, async_runtime}; use tauri::{AppHandle, Emitter, Manager, async_runtime};
use crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}};
use crate::ble::{BleSource, MockSource, SensorSource};
use crate::device_repo::{repo_from_devices, Repo};
use crate::state::State; use crate::state::State;
use btleplug::api::Manager as ApiManager; use btleplug::api::Manager as ApiManager;
use btleplug::platform::Manager as BleManager; use btleplug::platform::Manager as BleManager;
@ -15,19 +20,16 @@ const SCAN_SECONDS: u64 = 5;
#[tauri::command] #[tauri::command]
async fn init(app: AppHandle) -> Result<(), String> { async fn init(app: AppHandle) -> Result<(), String> {
println!("init rideinn app");
let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?;
present_repo(&app, repo).map_err(|e| e.to_string())?; present_repo(&app, repo).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
/// Этап 1: скан BLE-устройств и раскладка по типам датчиков. /// Этап 1: скан BLE-устройств и раскладка по типам датчиков.
async fn scan_and_classify(app: &AppHandle) -> Result<Repo, ble::BleError> { async fn scan_and_classify(app: &AppHandle) -> Result<Repo, ble::BleError> {
let state = app.state::<State>(); let state = app.state::<State>();
let adapter = state.adapter.clone(); let source = state.source.clone();
let devices = scan_devices(&adapter, SCAN_SECONDS).await?; let devices = source.scan(SCAN_SECONDS).await?;
Ok(repo_from_devices(devices)) Ok(repo_from_devices(devices))
} }
@ -53,12 +55,17 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
async_runtime::block_on(async move { async_runtime::block_on(async move {
let source: Arc<dyn SensorSource> = if std::env::var("RIDEIN_MOCK").is_ok() {
Arc::new(MockSource::new())
} else {
let manager = BleManager::new().await?; let manager = BleManager::new().await?;
let adapters = manager.adapters().await?; let adapters = manager.adapters().await?;
let adapter = adapters.into_iter().next() let adapter = adapters.into_iter().next()
.ok_or_else(|| Box::<dyn std::error::Error>::from("No BLE adapter found"))?; .ok_or_else(|| Box::<dyn std::error::Error>::from("No BLE adapter found"))?;
Arc::new(BleSource::new(adapter))
};
app.manage(State { adapter: Arc::new(adapter) }); app.manage(State { source });
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) })
}) })

@ -1,6 +1,7 @@
use btleplug::platform::Adapter;
use std::sync::Arc; use std::sync::Arc;
use crate::ble::SensorSource;
pub struct State { pub struct State {
pub adapter: Arc<Adapter> pub source: Arc<dyn SensorSource>,
} }

@ -0,0 +1,184 @@
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 })
}
/// 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_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);
}
}
Loading…
Cancel
Save