Compare commits
No commits in common. 'd8049e89c317cff6a1eb73537a10d43fcf1189d3' and 'b187811649fe69101ae22914fc69862d1f54c12a' have entirely different histories.
d8049e89c3
...
b187811649
@ -1,126 +0,0 @@
|
|||||||
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,124 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
#[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,7 +1,6 @@
|
|||||||
|
use btleplug::platform::Adapter;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::ble::SensorSource;
|
|
||||||
|
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub source: Arc<dyn SensorSource>,
|
pub adapter: Arc<Adapter>
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in new issue