|
|
use std::pin::Pin;
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
use std::task::{Context, Poll};
|
|
|
use std::time::Duration;
|
|
|
|
|
|
use async_trait::async_trait;
|
|
|
use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType};
|
|
|
use btleplug::platform::{Adapter, Peripheral};
|
|
|
use futures::{Stream, StreamExt};
|
|
|
use tokio::sync::oneshot;
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
use crate::ble::BleError;
|
|
|
use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult};
|
|
|
use crate::device::Device;
|
|
|
use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, 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>;
|
|
|
|
|
|
/// Запросить контроль у FTMS-станка и дождаться подтверждения (0x80).
|
|
|
async fn request_control(&self, device: &Device) -> Result<(), BleError>;
|
|
|
|
|
|
/// Прочитать уровень заряда батареи (0..=100). Best-effort.
|
|
|
async fn battery(&self, device: &Device) -> Result<u8, 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);
|
|
|
const INDOOR_BIKE_DATA: Uuid = uuid_from_u16(0x2AD2);
|
|
|
const FITNESS_MACHINE_STATUS: Uuid = uuid_from_u16(0x2ADA);
|
|
|
|
|
|
// How long to wait for the trainer to confirm control (Control Point Response 0x80).
|
|
|
const CONTROL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
|
|
|
|
/// Реальный источник данных через btleplug.
|
|
|
pub(crate) struct BleSource {
|
|
|
adapter: Adapter,
|
|
|
/// Last control result (set by the notification router, awaited by `request_control`).
|
|
|
control: Arc<Mutex<Option<ControlResult>>>,
|
|
|
}
|
|
|
|
|
|
impl BleSource {
|
|
|
pub(crate) fn new(adapter: Adapter) -> Self {
|
|
|
Self {
|
|
|
adapter,
|
|
|
control: Arc::new(Mutex::new(None)),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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()))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Stream wrapper that cancels the background notification router on drop, so a
|
|
|
/// reconnect does not leak the previous cycle's routing task.
|
|
|
struct CancellableStream {
|
|
|
inner: tokio_stream::wrappers::UnboundedReceiverStream<Telemetry>,
|
|
|
cancel: Option<oneshot::Sender<()>>,
|
|
|
}
|
|
|
|
|
|
impl Stream for CancellableStream {
|
|
|
type Item = Telemetry;
|
|
|
|
|
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Telemetry>> {
|
|
|
Pin::new(&mut self.inner).poll_next(cx)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
impl Drop for CancellableStream {
|
|
|
fn drop(&mut self) {
|
|
|
if let Some(cancel) = self.cancel.take() {
|
|
|
let _ = cancel.send(());
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#[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,
|
|
|
INDOOR_BIKE_DATA,
|
|
|
FITNESS_MACHINE_STATUS,
|
|
|
];
|
|
|
for ch in peripheral.characteristics().iter() {
|
|
|
if targets.contains(&ch.uuid) {
|
|
|
// Unsubscribe first (best-effort) so a stale reconnect does not double-subscribe.
|
|
|
let _ = peripheral.unsubscribe(ch).await;
|
|
|
peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let notifications = peripheral
|
|
|
.notifications()
|
|
|
.await
|
|
|
.map_err(BleError::NotificationsFailed)?;
|
|
|
|
|
|
// Route notifications on a background task: telemetry -> mpsc, control result -> shared state.
|
|
|
// This keeps the control-result flow concurrent with the telemetry consumer so that
|
|
|
// `request_control` can await confirmation while telemetry is still being consumed.
|
|
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Telemetry>();
|
|
|
let control = self.control.clone();
|
|
|
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
let mut notifications = notifications;
|
|
|
let mut acc = CscAccumulator::new();
|
|
|
let mut cancel_rx = cancel_rx;
|
|
|
loop {
|
|
|
tokio::select! {
|
|
|
n = notifications.next() => {
|
|
|
match n {
|
|
|
Some(n) => {
|
|
|
for t in parse_notification(n, &mut acc, &control) {
|
|
|
let _ = tx.send(t);
|
|
|
}
|
|
|
}
|
|
|
None => break,
|
|
|
}
|
|
|
}
|
|
|
_ = &mut cancel_rx => break,
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
|
|
|
let stream: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = Box::pin(CancellableStream {
|
|
|
inner: tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
|
|
|
cancel: Some(cancel_tx),
|
|
|
});
|
|
|
Ok(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 request_control(&self, device: &Device) -> Result<(), BleError> {
|
|
|
let peripheral = self.find_peripheral(device).await?;
|
|
|
ensure_connected(&peripheral).await?;
|
|
|
|
|
|
let cp_uuid = uuid_from_u16(ControlPoint::Ftms.short_uuid());
|
|
|
let ch = peripheral
|
|
|
.characteristics()
|
|
|
.into_iter()
|
|
|
.find(|c| c.uuid == cp_uuid)
|
|
|
.ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?;
|
|
|
|
|
|
// Reset and send Request Control (opcode 0x00).
|
|
|
*self.control.lock().unwrap() = None;
|
|
|
let (_, payload) = encode_command(Command::FtmsRequestControl);
|
|
|
peripheral
|
|
|
.write(&ch, &payload, WriteType::WithoutResponse)
|
|
|
.await
|
|
|
.map_err(BleError::WriteFailed)?;
|
|
|
|
|
|
// Wait for the Control Point Response (routed by the stream's notification task).
|
|
|
let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async {
|
|
|
loop {
|
|
|
let current = *self.control.lock().unwrap();
|
|
|
if let Some(r) = current {
|
|
|
return r;
|
|
|
}
|
|
|
tokio::time::sleep(CONTROL_POLL_INTERVAL).await;
|
|
|
}
|
|
|
})
|
|
|
.await
|
|
|
.map_err(|_| BleError::ControlTimeout)?;
|
|
|
|
|
|
match result {
|
|
|
ControlResult::Success => Ok(()),
|
|
|
other => Err(BleError::ControlRejected(other)),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async fn battery(&self, device: &Device) -> Result<u8, BleError> {
|
|
|
let peripheral = self.find_peripheral(device).await?;
|
|
|
ensure_connected(&peripheral).await?;
|
|
|
|
|
|
let batt_uuid = uuid_from_u16(0x2A19);
|
|
|
let ch = peripheral
|
|
|
.characteristics()
|
|
|
.into_iter()
|
|
|
.find(|c| c.uuid == batt_uuid)
|
|
|
.ok_or_else(|| BleError::DeviceNotFound("0x2A19".into()))?;
|
|
|
|
|
|
let value = peripheral.read(&ch).await.map_err(BleError::ReadFailed)?;
|
|
|
value
|
|
|
.first()
|
|
|
.copied()
|
|
|
.filter(|p| *p <= 100)
|
|
|
.ok_or_else(|| BleError::DeviceNotFound("уровень батареи".into()))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
control: &Arc<Mutex<Option<ControlResult>>>,
|
|
|
) -> Vec<Telemetry> {
|
|
|
if n.uuid == HR_MEASUREMENT {
|
|
|
parse_hr(&n.value).into_iter().collect()
|
|
|
} else if n.uuid == CSC_MEASUREMENT {
|
|
|
parse_csc(&n.value)
|
|
|
.and_then(|raw| acc.push(raw))
|
|
|
.map(|rpm| Telemetry::Cadence { rpm })
|
|
|
.into_iter()
|
|
|
.collect()
|
|
|
} else if n.uuid == POWER_MEASUREMENT {
|
|
|
parse_power(&n.value).into_iter().collect()
|
|
|
} else if n.uuid == INDOOR_BIKE_DATA {
|
|
|
parse_indoor_bike_data(&n.value)
|
|
|
} else if n.uuid == FITNESS_MACHINE_STATUS {
|
|
|
if let Some(result) = parse_control_result(&n.value) {
|
|
|
*control.lock().unwrap() = Some(result);
|
|
|
}
|
|
|
Vec::new()
|
|
|
} else {
|
|
|
Vec::new()
|
|
|
}
|
|
|
}
|