|
|
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())),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Записанные команды — читаются только в тестах движка воркаута.
|
|
|
#[allow(dead_code)]
|
|
|
pub(crate) fn commands(&self) -> Vec<Command> {
|
|
|
self.commands.lock().unwrap().clone()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#[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 Вт + каденс 85..95 rpm (как FTMS 0x2AD2 — оба поля)
|
|
|
stream::unfold(0u16, |i| async move {
|
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
|
let item = if i % 2 == 0 {
|
|
|
let watts = 150 + ((i / 2 % 16) * 10) as i16;
|
|
|
Telemetry::Power { watts }
|
|
|
} else {
|
|
|
let rpm = 85.0 + (i / 2 % 10) as f32;
|
|
|
Telemetry::Cadence { rpm }
|
|
|
};
|
|
|
Some((item, 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(())
|
|
|
}
|
|
|
|
|
|
async fn request_control(&self, _device: &Device) -> Result<(), BleError> {
|
|
|
Ok(())
|
|
|
}
|
|
|
|
|
|
async fn battery(&self, device: &Device) -> Result<u8, BleError> {
|
|
|
let percent = match device.id.as_str() {
|
|
|
"mock-hr" => 15,
|
|
|
"mock-cad" => 60,
|
|
|
"mock-trainer" => 85,
|
|
|
_ => 100,
|
|
|
};
|
|
|
Ok(percent)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#[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 { .. })));
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_mock_trainer_emits_power_and_cadence() {
|
|
|
let src = MockSource::new();
|
|
|
let devices = src.scan(0).await.unwrap();
|
|
|
let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap();
|
|
|
let mut stream = src.stream(trainer).await.unwrap();
|
|
|
let mut saw_power = false;
|
|
|
let mut saw_cadence = false;
|
|
|
for _ in 0..8 {
|
|
|
match stream.next().await {
|
|
|
Some(Telemetry::Power { .. }) => saw_power = true,
|
|
|
Some(Telemetry::Cadence { .. }) => saw_cadence = true,
|
|
|
_ => {}
|
|
|
}
|
|
|
}
|
|
|
assert!(saw_power, "тренер должен эмитить мощность");
|
|
|
assert!(saw_cadence, "тренер должен эмитить каденс");
|
|
|
}
|
|
|
}
|