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.

172 lines
6.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 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, "тренер должен эмитить каденс");
}
}