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.

277 lines
9.9 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::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()
}
}