Compare commits
2 Commits
b187811649
...
d8049e89c3
| Author | SHA1 | Date |
|---|---|---|
|
|
d8049e89c3 | 1 week ago |
|
|
a979f5dda7 | 1 week ago |
@ -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 utils;
|
||||
mod scanner;
|
||||
mod source;
|
||||
mod mock;
|
||||
|
||||
pub use error::BleError;
|
||||
pub use scanner::scan_devices;
|
||||
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 crate::ble::SensorSource;
|
||||
|
||||
pub struct State {
|
||||
pub adapter: Arc<Adapter>
|
||||
pub source: Arc<dyn SensorSource>,
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue