parent
a979f5dda7
commit
d8049e89c3
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in new issue