fix: fix connecting sensors + magene cadence fix

workout-saving
lambda 5 days ago
parent 043bdcf9aa
commit 0c1b9e70d7
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -67,8 +67,10 @@ impl MockSource {
#[async_trait] #[async_trait]
impl SensorSource for MockSource { impl SensorSource for MockSource {
async fn scan(&self, _seconds: u64) -> Result<Vec<Device>, BleError> { async fn scan_stream(&self) -> Result<Pin<Box<dyn Stream<Item = Device> + Send>>, BleError> {
Ok(self.devices.clone()) // мок сразу отдаёт три устройства и завершает поток
let s: Pin<Box<dyn Stream<Item = Device> + Send>> = stream::iter(self.devices.clone()).boxed();
Ok(s)
} }
async fn stream(&self, device: &Device) async fn stream(&self, device: &Device)
@ -133,17 +135,26 @@ impl SensorSource for MockSource {
mod tests { mod tests {
use super::*; use super::*;
async fn mock_devices(src: &MockSource) -> Vec<Device> {
let mut stream = src.scan_stream().await.unwrap();
let mut out = Vec::new();
while let Some(d) = stream.next().await {
out.push(d);
}
out
}
#[tokio::test] #[tokio::test]
async fn test_mock_scan_returns_three_devices() { async fn test_mock_scan_returns_three_devices() {
let src = MockSource::new(); let src = MockSource::new();
let devices = src.scan(0).await.unwrap(); let devices = mock_devices(&src).await;
assert_eq!(devices.len(), 3); assert_eq!(devices.len(), 3);
} }
#[tokio::test] #[tokio::test]
async fn test_mock_stream_yields_telemetry() { async fn test_mock_stream_yields_telemetry() {
let src = MockSource::new(); let src = MockSource::new();
let devices = src.scan(0).await.unwrap(); let devices = mock_devices(&src).await;
let hr = devices.iter().find(|d| d.id == "mock-hr").unwrap(); let hr = devices.iter().find(|d| d.id == "mock-hr").unwrap();
let mut stream = src.stream(hr).await.unwrap(); let mut stream = src.stream(hr).await.unwrap();
let first = stream.next().await; let first = stream.next().await;
@ -153,7 +164,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_mock_trainer_emits_power_and_cadence() { async fn test_mock_trainer_emits_power_and_cadence() {
let src = MockSource::new(); let src = MockSource::new();
let devices = src.scan(0).await.unwrap(); let devices = mock_devices(&src).await;
let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap(); let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap();
let mut stream = src.stream(trainer).await.unwrap(); let mut stream = src.stream(trainer).await.unwrap();
let mut saw_power = false; let mut saw_power = false;

@ -1,11 +1,9 @@
mod error; mod error;
mod utils; mod utils;
mod scanner;
mod source; mod source;
mod mock; mod mock;
pub use error::BleError; pub use error::BleError;
pub use scanner::scan_devices;
pub use utils::normalize_uuid; pub use utils::normalize_uuid;
pub(crate) use source::{BleSource, SensorSource}; pub(crate) use source::{BleSource, SensorSource};
pub(crate) use mock::MockSource; pub(crate) use mock::MockSource;

@ -1,98 +0,0 @@
use btleplug::api::{Central, Peripheral, ScanFilter, bleuuid::uuid_from_u16};
use btleplug::platform::Adapter;
use std::time::Duration;
use tokio::time;
use crate::ble::error::BleError;
use crate::device::Device;
use uuid::Uuid;
/// Сервисы, по которым ищем датчики. Только целевые (HR / CSC / FTMS / мощность).
/// Battery (0x180F) и Device Info (0x180A) убраны: их рекламирует почти любое
/// BLE-устройство, из-за чего скан подхватывал много лишнего и подключался к нему.
static TARGET_UUIDS: &[Uuid] = &[
uuid_from_u16(0x180D), // Heart Rate
uuid_from_u16(0x1816), // Cycling Speed and Cadence
uuid_from_u16(0x1826), // Fitness Machine (FTMS)
uuid_from_u16(0x1818), // Cycling Power
];
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const DISCOVER_TIMEOUT: Duration = Duration::from_secs(5);
const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub async fn scan_devices(adapter: &Adapter, scan_seconds: u64) -> Result<Vec<Device>, BleError> {
adapter
.start_scan(ScanFilter { services: TARGET_UUIDS.to_vec() })
.await
.map_err(BleError::ScanFailed)?;
time::sleep(Duration::from_secs(scan_seconds)).await;
let peripherals = adapter.peripherals().await.map_err(BleError::PeripheralsFailed)?;
let mut devices = Vec::new();
for peripheral in peripherals {
// Свойства берём из рекламы; если устройство уже пропало - пропускаем.
let Some(props) = peripheral.properties().await.unwrap_or(None) else {
continue;
};
let svc_match = props.services.iter().any(|s| TARGET_UUIDS.contains(s));
if !svc_match {
continue;
}
// Для классификации по типу нужны характеристики - подключаемся, читаем,
// отключаемся (best-effort: при сбое устройство всё равно попадёт в список,
// просто без характеристик).
let characteristics = discover_characteristics(&peripheral).await;
let manuf = props.manufacturer_data.iter().next().map(|(_, v)| hex::encode(v));
devices.push(Device {
id: peripheral.id().to_string(),
address: props.address.to_string(),
display_name: props.local_name,
rssi: props.rssi,
service_uuids: props.services.iter().map(|u| u.to_string()).collect(),
manufacturer_data: manuf,
metadata: None,
characteristics,
});
}
adapter.stop_scan().await.ok();
Ok(devices)
}
/// Подключается к устройству, читает характеристики и отключается.
/// Возвращает список характеристик в формате "service::characteristic".
/// Best-effort: неудачи connect/discover игнорируются.
async fn discover_characteristics(peripheral: &impl Peripheral) -> Vec<String> {
let was_connected = peripheral.is_connected().await.unwrap_or(false);
if !was_connected {
let _ = time::timeout(CONNECT_TIMEOUT, peripheral.connect()).await;
}
let _ = time::timeout(DISCOVER_TIMEOUT, peripheral.discover_services()).await;
let mut characteristics = Vec::new();
let chars = peripheral.characteristics();
if !chars.is_empty() {
for ch in chars.iter() {
characteristics.push(format!("{}::{}", ch.service_uuid, ch.uuid));
}
} else {
for svc in peripheral.services().iter() {
for ch in svc.characteristics.iter() {
characteristics.push(format!("{}::{}", svc.uuid, ch.uuid));
}
}
}
if !was_connected {
let _ = time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
}
characteristics
}

@ -4,9 +4,10 @@ use std::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType}; use btleplug::api::{bleuuid::uuid_from_u16, Central, Characteristic, Peripheral as _, ScanFilter, ValueNotification, WriteType};
use btleplug::platform::{Adapter, Peripheral}; use btleplug::platform::{Adapter, Peripheral};
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
use std::collections::HashSet;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use uuid::Uuid; use uuid::Uuid;
@ -17,7 +18,10 @@ use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power,
#[async_trait] #[async_trait]
pub(crate) trait SensorSource: Send + Sync { pub(crate) trait SensorSource: Send + Sync {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError>; /// Начать непрерывный скан. Возвращает поток найденных устройств (по мере
/// появления, только из рекламы — без подключения и чтения характеристик).
/// Дроп потока останавливает скан.
async fn scan_stream(&self) -> Result<Pin<Box<dyn Stream<Item = Device> + Send>>, BleError>;
/// Подключиться и вернуть поток распарсенных измерений. /// Подключиться и вернуть поток распарсенных измерений.
async fn stream(&self, device: &Device) async fn stream(&self, device: &Device)
@ -38,6 +42,18 @@ const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B);
const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63); const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63);
const INDOOR_BIKE_DATA: Uuid = uuid_from_u16(0x2AD2); const INDOOR_BIKE_DATA: Uuid = uuid_from_u16(0x2AD2);
const FITNESS_MACHINE_STATUS: Uuid = uuid_from_u16(0x2ADA); const FITNESS_MACHINE_STATUS: Uuid = uuid_from_u16(0x2ADA);
const CONTROL_POINT: Uuid = uuid_from_u16(0x2AD9);
/// Сервисы, по которым сканируем (только целевые: HR/CSC/FTMS/мощность).
static TARGET_UUIDS: &[Uuid] = &[
uuid_from_u16(0x180D), // Heart Rate
uuid_from_u16(0x1816), // Cycling Speed and Cadence
uuid_from_u16(0x1826), // Fitness Machine (FTMS)
uuid_from_u16(0x1818), // Cycling Power
];
/// Интервал опроса перифериалов при непрерывном скане.
const SCAN_POLL_INTERVAL: Duration = Duration::from_millis(1500);
// How long to wait for the trainer to confirm control (Control Point Response 0x80). // 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_CONFIRM_TIMEOUT: Duration = Duration::from_secs(3);
@ -75,20 +91,20 @@ impl BleSource {
/// Stream wrapper that cancels the background notification router on drop, so a /// Stream wrapper that cancels the background notification router on drop, so a
/// reconnect does not leak the previous cycle's routing task. /// reconnect does not leak the previous cycle's routing task.
struct CancellableStream { struct CancellableStream<T> {
inner: tokio_stream::wrappers::UnboundedReceiverStream<Telemetry>, inner: tokio_stream::wrappers::UnboundedReceiverStream<T>,
cancel: Option<oneshot::Sender<()>>, cancel: Option<oneshot::Sender<()>>,
} }
impl Stream for CancellableStream { impl<T> Stream for CancellableStream<T> {
type Item = Telemetry; type Item = T;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Telemetry>> { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<T>> {
Pin::new(&mut self.inner).poll_next(cx) Pin::new(&mut self.inner).poll_next(cx)
} }
} }
impl Drop for CancellableStream { impl<T> Drop for CancellableStream<T> {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() { if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(()); let _ = cancel.send(());
@ -98,14 +114,85 @@ impl Drop for CancellableStream {
#[async_trait] #[async_trait]
impl SensorSource for BleSource { impl SensorSource for BleSource {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError> { async fn scan_stream(&self) -> Result<Pin<Box<dyn Stream<Item = Device> + Send>>, BleError> {
crate::ble::scan_devices(&self.adapter, seconds).await // Сканируем без сервис-фильтра: часть станков не рекламирует сервисы в ADV,
// и фильтр на уровне адаптера их прячет. Фильтруем уже в коде ниже.
self.adapter
.start_scan(ScanFilter { services: Vec::new() })
.await
.map_err(|e| {
crate::logging::log("scan", &format!("start_scan failed: {e}"));
BleError::ScanFailed(e)
})?;
crate::logging::log("scan", "scan started");
let adapter = self.adapter.clone();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Device>();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let mut seen: HashSet<String> = HashSet::new();
let mut cancel_rx = cancel_rx;
loop {
tokio::select! {
_ = &mut cancel_rx => break,
_ = tokio::time::sleep(SCAN_POLL_INTERVAL) => {
let peripherals = match adapter.peripherals().await {
Ok(p) => p,
Err(e) => {
crate::logging::log("scan", &format!("peripherals() failed: {e}"));
continue;
}
};
for peripheral in peripherals {
let Some(props) = peripheral.properties().await.unwrap_or(None) else {
continue;
};
let id = peripheral.id().to_string();
let is_new = seen.insert(id.clone());
// логируем каждый новый перифериал (с его сервисами), чтобы
// видеть, что реально видит адаптер — даже до фильтрации.
if is_new {
crate::logging::log("scan", &format!("peripheral: name={:?} services={:?}", props.local_name, props.services));
}
if !props.services.iter().any(|s| TARGET_UUIDS.contains(s)) {
continue;
}
if !is_new {
continue;
}
let device = Device {
id,
address: props.address.to_string(),
display_name: props.local_name,
rssi: props.rssi,
service_uuids: props.services.iter().map(|u| u.to_string()).collect(),
characteristics: Vec::new(),
manufacturer_data: props.manufacturer_data.iter().next().map(|(_, v)| hex::encode(v)),
metadata: None,
};
crate::logging::log("scan", &format!("device found: name={:?} id={}", device.display_name, device.id));
let _ = tx.send(device);
}
}
}
}
crate::logging::log("scan", "scan stopped");
let _ = adapter.stop_scan().await;
});
let stream: Pin<Box<dyn Stream<Item = Device> + Send>> = Box::pin(CancellableStream {
inner: tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
cancel: Some(cancel_tx),
});
Ok(stream)
} }
async fn stream(&self, device: &Device) async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError> -> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>
{ {
let peripheral = self.find_peripheral(device).await?; let peripheral = self.find_peripheral(device).await?;
crate::logging::log("ble", &format!("stream for '{}' ({})", device.display_name.as_deref().unwrap_or("?"), device.id));
ensure_connected(&peripheral).await?; ensure_connected(&peripheral).await?;
let targets = [ let targets = [
@ -114,12 +201,14 @@ impl SensorSource for BleSource {
POWER_MEASUREMENT, POWER_MEASUREMENT,
INDOOR_BIKE_DATA, INDOOR_BIKE_DATA,
FITNESS_MACHINE_STATUS, FITNESS_MACHINE_STATUS,
CONTROL_POINT, // 0x2AD9 — сюда приходит Control Point Response (opcode 0x80)
]; ];
for ch in peripheral.characteristics().iter() { for ch in peripheral.characteristics().iter() {
if targets.contains(&ch.uuid) { if targets.contains(&ch.uuid) {
// Unsubscribe first (best-effort) so a stale reconnect does not double-subscribe. // Unsubscribe first (best-effort) so a stale reconnect does not double-subscribe.
let _ = peripheral.unsubscribe(ch).await; let _ = peripheral.unsubscribe(ch).await;
peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?; peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?;
crate::logging::log("ble", &format!("subscribed {}", ch.uuid));
} }
} }
@ -142,12 +231,22 @@ impl SensorSource for BleSource {
let mut notifications = notifications; let mut notifications = notifications;
let mut acc = CscAccumulator::new(); let mut acc = CscAccumulator::new();
let mut cancel_rx = cancel_rx; let mut cancel_rx = cancel_rx;
// Первые 30 уведомлений логируем с сырыми байтами — чтобы видеть, что
// реально шлёт устройство (какие флаги/поля в 0x2AD2 и т.п.).
let mut logged = 0u32;
loop { loop {
tokio::select! { tokio::select! {
n = notifications.next() => { n = notifications.next() => {
match n { match n {
Some(n) => { Some(n) => {
for t in parse_notification(n, &mut acc, &control) { let uuid = n.uuid;
let raw = hex::encode(&n.value);
let parsed = parse_notification(n, &mut acc, &control);
if logged < 30 {
logged += 1;
crate::logging::log("telemetry", &format!("uuid={uuid} raw={raw} parsed={parsed:?}"));
}
for t in parsed {
let _ = tx.send(t); let _ = tx.send(t);
} }
} }
@ -170,7 +269,7 @@ impl SensorSource for BleSource {
let peripheral = self.find_peripheral(device).await?; let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?; ensure_connected(&peripheral).await?;
let (control_point, payload) = encode_command(cmd); let (control_point, payload) = encode_command(cmd.clone());
let cp_uuid = uuid_from_u16(control_point.short_uuid()); let cp_uuid = uuid_from_u16(control_point.short_uuid());
let ch = peripheral let ch = peripheral
.characteristics() .characteristics()
@ -178,10 +277,8 @@ impl SensorSource for BleSource {
.find(|c| c.uuid == cp_uuid) .find(|c| c.uuid == cp_uuid)
.ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?; .ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?;
peripheral crate::logging::log("control", &format!("command {:?} payload={}", cmd, hex::encode(&payload)));
.write(&ch, &payload, WriteType::WithoutResponse) write_control_point(&peripheral, &ch, &payload).await
.await
.map_err(BleError::WriteFailed)
} }
async fn request_control(&self, device: &Device) -> Result<(), BleError> { async fn request_control(&self, device: &Device) -> Result<(), BleError> {
@ -199,10 +296,8 @@ impl SensorSource for BleSource {
let slot = self.control.lock().unwrap().clone(); let slot = self.control.lock().unwrap().clone();
*slot.lock().unwrap() = None; *slot.lock().unwrap() = None;
let (_, payload) = encode_command(Command::FtmsRequestControl); let (_, payload) = encode_command(Command::FtmsRequestControl);
peripheral crate::logging::log("control", "request_control sent (opcode 0x00)");
.write(&ch, &payload, WriteType::WithoutResponse) write_control_point(&peripheral, &ch, &payload).await?;
.await
.map_err(BleError::WriteFailed)?;
// Wait for the Control Point Response (routed by the stream's notification task). // Wait for the Control Point Response (routed by the stream's notification task).
let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async move { let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async move {
@ -215,11 +310,17 @@ impl SensorSource for BleSource {
} }
}) })
.await .await
.map_err(|_| BleError::ControlTimeout)?; .map_err(|_| {
crate::logging::log("control", "request_control: no confirmation (timeout)");
BleError::ControlTimeout
})?;
match result { match result {
ControlResult::Success => Ok(()), ControlResult::Success => Ok(()),
other => Err(BleError::ControlRejected(other)), other => {
crate::logging::log("control", &format!("control rejected: {:?}", other));
Err(BleError::ControlRejected(other))
}
} }
} }
@ -245,7 +346,9 @@ impl SensorSource for BleSource {
async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> { async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> {
if !peripheral.is_connected().await.unwrap_or(false) { if !peripheral.is_connected().await.unwrap_or(false) {
crate::logging::log("ble", &format!("connecting {}", peripheral.id()));
peripheral.connect().await.map_err(BleError::ConnectFailed)?; peripheral.connect().await.map_err(BleError::ConnectFailed)?;
crate::logging::log("ble", "connected");
} }
peripheral peripheral
.discover_services() .discover_services()
@ -271,12 +374,40 @@ fn parse_notification(
parse_power(&n.value).into_iter().collect() parse_power(&n.value).into_iter().collect()
} else if n.uuid == INDOOR_BIKE_DATA { } else if n.uuid == INDOOR_BIKE_DATA {
parse_indoor_bike_data(&n.value) parse_indoor_bike_data(&n.value)
} else if n.uuid == FITNESS_MACHINE_STATUS { } else if n.uuid == CONTROL_POINT {
// Control Point Response (opcode 0x80) приходит indication-ом на сам
// Control Point (0x2AD9), а НЕ на Fitness Machine Status (0x2ADA).
if let Some(result) = parse_control_result(&n.value) { if let Some(result) = parse_control_result(&n.value) {
crate::logging::log("control", &format!("control response: {:?}", result));
*control.lock().unwrap() = Some(result); *control.lock().unwrap() = Some(result);
} }
Vec::new() Vec::new()
} else if n.uuid == FITNESS_MACHINE_STATUS {
// статус станка (spin down и т.п.) — для ERG пока не нужен
Vec::new()
} else { } else {
Vec::new() Vec::new()
} }
} }
/// Пишет payload на Control Point: пробуем write-with-response (часть станков
/// требует именно его), при неудаче — write-without-response.
async fn write_control_point(
peripheral: &Peripheral,
ch: &Characteristic,
payload: &[u8],
) -> Result<(), BleError> {
match peripheral.write(ch, payload, WriteType::WithResponse).await {
Ok(()) => Ok(()),
Err(with_resp) => {
crate::logging::log(
"control",
&format!("with-response failed ({with_resp}), falling back to without-response"),
);
peripheral
.write(ch, payload, WriteType::WithoutResponse)
.await
.map_err(BleError::WriteFailed)
}
}
}

@ -25,6 +25,17 @@ pub enum DeviceType {
Trainer, Trainer,
} }
impl DeviceType {
/// Категория на фронте ("hr"/"cadence"/"trainer") для маршрутизации события.
pub fn category(&self) -> &'static str {
match self {
DeviceType::HrSensor => "hr",
DeviceType::CadSensor => "cadence",
DeviceType::Trainer => "trainer",
}
}
}
pub fn get_device_types(device: &Device) -> Vec<DeviceType> { pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
let hr_services = ["180d"]; let hr_services = ["180d"];
let hr_characteristics = ["2a37"]; let hr_characteristics = ["2a37"];
@ -222,4 +233,11 @@ mod tests {
fn test_uuid_case_and_whitespace_are_normalized() { fn test_uuid_case_and_whitespace_are_normalized() {
assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]); assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]);
} }
#[test]
fn test_category_mapping() {
assert_eq!(DeviceType::HrSensor.category(), "hr");
assert_eq!(DeviceType::CadSensor.category(), "cadence");
assert_eq!(DeviceType::Trainer.category(), "trainer");
}
} }

@ -1,3 +1,3 @@
mod repo; mod repo;
pub use repo::{repo_from_devices, Repo}; pub use repo::Repo;

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::device::{Device, DeviceType, get_device_types}; use crate::device::Device;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Repo { pub struct Repo {
@ -20,115 +20,3 @@ impl Repo {
} }
} }
} }
pub fn repo_from_devices(devices: Vec<Device>) -> Repo {
let mut repo = Repo::new();
for dev in devices.into_iter() {
let types = get_device_types(&dev);
for t in types {
match t {
DeviceType::HrSensor => repo.hr_sensors.push(dev.clone()),
DeviceType::CadSensor => repo.cad_sensors.push(dev.clone()),
DeviceType::Trainer => repo.trainers.push(dev.clone()),
}
}
}
fn sort_by_rssi_desc(v: &mut [Device]) {
v.sort_by(|a, b| {
let ra = a.rssi;
let rb = b.rssi;
match (ra, rb) {
(Some(va), Some(vb)) => vb.cmp(&va),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
sort_by_rssi_desc(&mut repo.hr_sensors);
sort_by_rssi_desc(&mut repo.cad_sensors);
sort_by_rssi_desc(&mut repo.trainers);
repo
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: a cadence device (CSC service 0x1816) with the given RSSI.
fn cadence_device(rssi: Option<i16>) -> Device {
Device {
id: format!("cad-{rssi:?}"),
address: "00:00:00:00:00:00".into(),
display_name: None,
rssi,
service_uuids: vec!["1816".into()],
characteristics: vec![],
manufacturer_data: None,
metadata: None,
}
}
#[test]
fn test_repo_classifies_magene_as_cadence_sensor() {
let magene = Device {
id: "hci0/dev_F1_F2_7C_77_14_BE".into(),
address: "F1:F2:7C:77:14:BE".into(),
display_name: Some("10911-17".into()),
rssi: Some(-42),
service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec![],
manufacturer_data: Some("6b009f2a110300030072".into()),
metadata: None,
};
let repo = repo_from_devices(vec![magene]);
assert_eq!(repo.cad_sensors.len(), 1);
assert!(repo.hr_sensors.is_empty());
assert!(repo.trainers.is_empty());
}
#[test]
fn test_sorts_devices_by_rssi_descending() {
let devices = vec![
cadence_device(Some(-80)),
cadence_device(Some(-42)),
cadence_device(Some(-60)),
];
let repo = repo_from_devices(devices);
let rssis: Vec<_> = repo.cad_sensors.iter().map(|d| d.rssi).collect();
assert_eq!(rssis, vec![Some(-42), Some(-60), Some(-80)]);
}
#[test]
fn test_devices_without_rssi_go_last() {
let devices = vec![cadence_device(None), cadence_device(Some(-50))];
let repo = repo_from_devices(devices);
assert_eq!(repo.cad_sensors[0].rssi, Some(-50));
assert_eq!(repo.cad_sensors[1].rssi, None);
}
#[test]
fn test_indoor_bike_lands_in_trainer_and_cadence_buckets() {
let indoor_bike = Device {
id: "indoor-bike".into(),
address: "00:00:00:00:00:00".into(),
display_name: None,
rssi: Some(-70),
service_uuids: vec![],
characteristics: vec!["2ad2".into()],
manufacturer_data: None,
metadata: None,
};
let repo = repo_from_devices(vec![indoor_bike]);
assert_eq!(repo.trainers.len(), 1);
assert_eq!(repo.cad_sensors.len(), 1);
assert!(repo.hr_sensors.is_empty());
}
}

@ -5,6 +5,8 @@ mod state;
mod telemetry; mod telemetry;
mod commands; mod commands;
mod workout; mod workout;
mod tcx;
mod logging;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@ -18,15 +20,13 @@ use tokio::time;
use crate::ble::{BleSource, MockSource, SensorSource}; use crate::ble::{BleSource, MockSource, SensorSource};
use crate::commands::Command; use crate::commands::Command;
use crate::device::Device; use crate::device::{get_device_types, Device};
use crate::device_repo::{repo_from_devices, Repo}; use crate::device_repo::Repo;
use crate::state::{State, TrainerState, WorkoutHandle}; use crate::state::{State, TrainerState, WorkoutHandle};
use crate::telemetry::Telemetry; use crate::telemetry::Telemetry;
use btleplug::api::Manager as ApiManager; use btleplug::api::Manager as ApiManager;
use btleplug::platform::Manager as BleManager; use btleplug::platform::Manager as BleManager;
/// Сколько секунд сканируем BLE на старте.
const SCAN_SECONDS: u64 = 5;
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
struct TelemetryEvent { struct TelemetryEvent {
@ -69,20 +69,43 @@ struct WorkoutFinishedEvent {
ok: bool, ok: bool,
} }
#[derive(Clone, Serialize)]
struct DeviceFoundEvent {
device: Device,
categories: Vec<String>,
}
#[tauri::command] #[tauri::command]
async fn init(app: AppHandle) -> Result<(), String> { async fn init(app: AppHandle) -> Result<(), String> {
let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; // показать главное окно (пока пустое — датчики подтянутся скан-потоком)
present_repo(&app, repo).map_err(|e| e.to_string())?; present_repo(&app, Repo::new()).map_err(|e| e.to_string())?;
// запустить непрерывный скан (устройства приходят событием device-found)
start_scan(&app).await.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
/// Этап 1: скан BLE-устройств и раскладка по типам датчиков. /// Запускает непрерывный скан: каждое найденное устройство эмитится событием
async fn scan_and_classify(app: &AppHandle) -> Result<Repo, ble::BleError> { /// `device-found` и добавляется в `state.devices` для последующего connect.
async fn start_scan(app: &AppHandle) -> Result<(), ble::BleError> {
let state = app.state::<State>(); let state = app.state::<State>();
let source = state.source.clone(); let source = state.source.clone();
let devices = source.scan(SCAN_SECONDS).await?; let mut stream = source.scan_stream().await?;
*state.devices.write().unwrap() = devices.clone();
Ok(repo_from_devices(devices)) let app_t = app.clone();
let handle = async_runtime::spawn(async move {
while let Some(device) = stream.next().await {
let categories: Vec<String> = get_device_types(&device)
.iter()
.map(|t| t.category().to_string())
.collect();
crate::logging::log("scan", &format!("emit device-found: {:?} categories={:?}", device.display_name, categories));
app_t.state::<State>().devices.write().unwrap().push(device.clone());
let _ = app_t.emit("device-found", DeviceFoundEvent { device, categories });
}
});
*state.scan.lock().unwrap() = Some(handle);
Ok(())
} }
/// Этап 2: закрываем сплэш, показываем главное окно, отдаём список датчиков на фронт. /// Этап 2: закрываем сплэш, показываем главное окно, отдаём список датчиков на фронт.
@ -106,6 +129,12 @@ fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> {
async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> { async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> {
let state = app.state::<State>(); let state = app.state::<State>();
// Остановить непрерывный скан перед подключением: адаптер один, и постоянный
// скан во время соединения — источник нестабильности/обрывов.
if let Some(handle) = state.scan.lock().unwrap().take() {
handle.abort();
}
let device = { let device = {
let devices = state.devices.read().unwrap(); let devices = state.devices.read().unwrap();
devices devices
@ -274,7 +303,7 @@ async fn apply_power(
workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp) workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp)
}); });
if let Some(w) = watts.filter(|w| Some(*w) != last_watts) { if let Some(w) = watts.filter(|w| Some(*w) != last_watts) {
let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; send_command(source, device, Command::FtmsSetTargetPower(w)).await;
} }
watts watts
} }
@ -297,6 +326,14 @@ fn progress_event(
} }
} }
/// Шлёт команду на станок; ошибку логирует, но не прерывает воркаут (ERG-команды
/// — best-effort: пропущенная цель не должна ронять всю тренировку).
async fn send_command(source: &Arc<dyn SensorSource>, device: &Device, cmd: Command) {
if let Err(e) = source.command(device, cmd.clone()).await {
crate::logging::log("workout", &format!("command {:?} failed: {e}", cmd));
}
}
/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource. /// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource.
async fn run_workout_engine( async fn run_workout_engine(
source: &Arc<dyn SensorSource>, source: &Arc<dyn SensorSource>,
@ -307,10 +344,11 @@ async fn run_workout_engine(
tick_duration: Duration, tick_duration: Duration,
mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, mut on_progress: impl FnMut(WorkoutProgressEvent) + Send,
) -> bool { ) -> bool {
if source.request_control(device).await.is_err() { if let Err(e) = source.request_control(device).await {
crate::logging::log("workout", &format!("request_control failed: {e}"));
return false; return false;
} }
let _ = source.command(device, Command::FtmsStart).await; send_command(source, device, Command::FtmsStart).await;
let total = workout::total_duration_secs(segments); let total = workout::total_duration_secs(segments);
let mut elapsed_total: u64 = 0; let mut elapsed_total: u64 = 0;
@ -320,7 +358,7 @@ async fn run_workout_engine(
// каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS).
if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) {
let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; send_command(source, device, Command::FtmsSetTargetCadence(half)).await;
} }
let mut last_watts: Option<i16> = None; let mut last_watts: Option<i16> = None;
@ -346,7 +384,7 @@ async fn run_workout_engine(
} }
} }
let _ = source.command(device, Command::FtmsStop).await; send_command(source, device, Command::FtmsStop).await;
true true
} }
@ -386,6 +424,7 @@ fn start_workout(app: AppHandle, path: String) -> Result<(), String> {
let state = app.state::<State>(); let state = app.state::<State>();
let ftp = *state.ftp.lock().unwrap(); let ftp = *state.ftp.lock().unwrap();
crate::logging::log("workout", &format!("start workout '{}': {} segments, ftp {}", workout.name, segments.len(), ftp));
let device = trainer_device(&state)?; let device = trainer_device(&state)?;
let source = state.source.clone(); let source = state.source.clone();
@ -420,6 +459,9 @@ async fn stop_workout(app: AppHandle) -> Result<(), String> {
h.task.abort(); h.task.abort();
let device = trainer_device(&state)?; let device = trainer_device(&state)?;
let _ = state.source.command(&device, Command::FtmsStop).await; let _ = state.source.command(&device, Command::FtmsStop).await;
// Ручная остановка тоже сохраняет накопленный (частичный) воркаут:
// после abort задача не дойдёт до emit, поэтому шлём событие здесь.
let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true });
} }
Ok(()) Ok(())
} }
@ -434,6 +476,34 @@ fn pause_workout(app: AppHandle, paused: bool) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Сохранить завершённый воркаут в TCX (для ручной загрузки в intervals.icu).
/// Возвращает путь к созданному файлу.
#[tauri::command]
fn save_workout(name: String, samples: Vec<tcx::ActivitySample>) -> Result<String, String> {
if samples.is_empty() {
return Err("нет данных для сохранения".to_string());
}
let xml = tcx::serialize_tcx(&name, &samples);
// Имя файла из валидированного ISO-префикса первой точки:
// "2026-10-28T10:00:00..." -> date "2026-10-28", time "10-00-00".
// Валидация закрывает path traversal (нельзя всунуть ".."), а суффикс
// времени — коллизию двух воркаутов в один день с одним именем.
let start = &samples[0].time;
if !tcx::is_iso_timestamp(start) {
return Err(format!("некорректное время сэмпла: {}", start));
}
let date = &start[0..10];
let time_slug = start[11..19].replace(':', "-");
let filename = format!("{}_{}_{}.tcx", date, time_slug, tcx::slugify(&name));
let dir = tcx::activities_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&filename);
std::fs::write(&path, xml).map_err(|e| e.to_string())?;
crate::logging::log("save", &format!("saved {}", path.display()));
Ok(path.to_string_lossy().into_owned())
}
/// Why the watchdog loop stopped consuming a stream. /// Why the watchdog loop stopped consuming a stream.
enum StreamOutcome { enum StreamOutcome {
/// The stream ended (peripheral disconnected / notifications closed). /// The stream ended (peripheral disconnected / notifications closed).
@ -572,6 +642,7 @@ async fn reapply_trainer_state(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
crate::logging::init();
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
async_runtime::block_on(async move { async_runtime::block_on(async move {
@ -590,6 +661,7 @@ pub fn run() {
devices: RwLock::new(Vec::new()), devices: RwLock::new(Vec::new()),
trainer: Mutex::new(None), trainer: Mutex::new(None),
streams: Mutex::new(std::collections::HashMap::new()), streams: Mutex::new(std::collections::HashMap::new()),
scan: Mutex::new(None),
trainer_state: Mutex::new(TrainerState::default()), trainer_state: Mutex::new(TrainerState::default()),
ftp: Mutex::new(state::DEFAULT_FTP), ftp: Mutex::new(state::DEFAULT_FTP),
workout: Mutex::new(None), workout: Mutex::new(None),
@ -610,7 +682,8 @@ pub fn run() {
get_ftp, get_ftp,
start_workout, start_workout,
stop_workout, stop_workout,
pause_workout pause_workout,
save_workout
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
@ -664,7 +737,11 @@ mod tests {
async fn workout_engine_sends_expected_commands() { async fn workout_engine_sends_expected_commands() {
let mock = Arc::new(MockSource::new()); let mock = Arc::new(MockSource::new());
let source: Arc<dyn SensorSource> = mock.clone(); let source: Arc<dyn SensorSource> = mock.clone();
let devices = source.scan(0).await.unwrap(); let mut devices = Vec::new();
let mut scan_stream = source.scan_stream().await.unwrap();
while let Some(d) = scan_stream.next().await {
devices.push(d);
}
let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone(); let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone();
// короткий воркаут: фикс-сегмент + ramp-сегмент // короткий воркаут: фикс-сегмент + ramp-сегмент

@ -0,0 +1,67 @@
//! Минимальный файловый лог для пост-анализа реального прогона.
//!
//! Логируется «управляющая плоскость»: BLE-события (подключение/подписка),
//! команды на Control Point и их результат, подтверждение контроля, жизненный
//! цикл воркаута и ошибки. Данные телеметрии сюда НЕ пишутся — они уходят в
//! .tcx. Так лог остаётся читаемым и пригодным для разбора после запуска.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static LOG_FILE: Mutex<Option<File>> = Mutex::new(None);
/// Открывает лог-файл на дозапись (env `RIDEIN_LOG`, иначе `ridein.log` в cwd).
/// Вызывается один раз при старте приложения.
pub(crate) fn init() {
let path = std::env::var("RIDEIN_LOG").unwrap_or_else(|_| "ridein.log".to_string());
let file = OpenOptions::new().create(true).append(true).open(&path).ok();
*LOG_FILE.lock().unwrap() = file;
log("logging", &format!("log initialized -> {path}"));
}
/// Timestamp в секундах.millis с начала эпохи (монотонный, сортируемый).
fn timestamp() -> String {
let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
format!("{}.{:03}", d.as_secs(), d.subsec_millis())
}
/// Пишет строку в лог. Best-effort: если лог не инициализирован или запись не
/// удалась — молча пропускается (в юнит-тестах лог не инициализируется).
pub(crate) fn log(component: &str, msg: &str) {
let mut guard = LOG_FILE.lock().unwrap();
if let Some(f) = guard.as_mut() {
let _ = writeln!(f, "{} [{}] {}", timestamp(), component, msg);
let _ = f.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn logs_timestamped_line_to_file() {
// сбрасываем глобальное состояние (тесты идут параллельно)
*LOG_FILE.lock().unwrap() = None;
let path = std::env::temp_dir().join(format!("ridein-log-test-{}.log", std::process::id()));
std::env::set_var("RIDEIN_LOG", &path);
init();
log("test", "hello world");
let mut s = String::new();
std::fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap();
assert!(s.contains("[test] hello world"), "log line missing: {s}");
// timestamp — «seconds.millis» (цифры и одна точка)
let first = s.lines().next().unwrap_or("");
let head = first.split(' ').next().unwrap_or("");
assert!(head.chars().all(|c| c.is_ascii_digit() || c == '.'), "bad timestamp: {head}");
let _ = std::fs::remove_file(&path);
*LOG_FILE.lock().unwrap() = None;
std::env::remove_var("RIDEIN_LOG");
}
}

@ -32,6 +32,8 @@ pub struct State {
pub trainer: Mutex<Option<String>>, pub trainer: Mutex<Option<String>>,
/// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer") /// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer")
pub streams: Mutex<HashMap<String, JoinHandle<()>>>, pub streams: Mutex<HashMap<String, JoinHandle<()>>>,
/// Задача непрерывного скана (обрывается при подключении устройства).
pub scan: Mutex<Option<JoinHandle<()>>>,
/// Trainer control state, restored after a reconnect. /// Trainer control state, restored after a reconnect.
pub trainer_state: Mutex<TrainerState>, pub trainer_state: Mutex<TrainerState>,
/// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты.

@ -0,0 +1,205 @@
//! Сохранение завершённого воркаута в TCX (Garmin Training Center XML).
//!
//! intervals.icu принимает TCX при ручной загрузке активности и читает из него
//! мощность (через расширение TPX), каденс и пульс. Формат выбран потому, что
//! это открытый XML, который просто генерировать строкой и легко проверить
//! глазами (в отличие от бинарного FIT).
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Одна точка измерения во время воркаута (~1 Гц).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivitySample {
/// Абсолютное время точки, ISO 8601 UTC (напр. "2026-10-28T10:00:05Z").
pub time: String,
pub watts: Option<i16>,
pub cadence: Option<u16>,
pub hr: Option<u16>,
}
/// Экранирует XML-спецсимволы.
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Сериализует воркаут в TCX-документ. `samples` должны идти по возрастанию
/// времени; первая точка задаёт `Id` и `Lap StartTime`.
pub fn serialize_tcx(name: &str, samples: &[ActivitySample]) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(
"<TrainingCenterDatabase \
xmlns=\"http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2\" \
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" \
xsi:schemaLocation=\"http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 \
http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd\">\n",
);
out.push_str(" <Activities>\n");
out.push_str(" <Activity Sport=\"Biking\">\n");
let start = samples.first().map(|s| s.time.as_str()).unwrap_or("");
// после дедупликации на фронте первая точка — t=0, остальные 1..N,
// поэтому длительность = число точек минус один.
let total = samples.len().saturating_sub(1) as u64;
out.push_str(&format!(" <Id>{}</Id>\n", start));
out.push_str(&format!(" <Lap StartTime=\"{}\">\n", start));
out.push_str(&format!(" <TotalTimeSeconds>{}</TotalTimeSeconds>\n", total));
out.push_str(" <DistanceMeters>0</DistanceMeters>\n");
out.push_str(" <Intensity>Active</Intensity>\n");
out.push_str(" <TriggerMethod>Manual</TriggerMethod>\n");
out.push_str(" <Track>\n");
for s in samples {
out.push_str(" <Trackpoint>\n");
out.push_str(&format!(" <Time>{}</Time>\n", s.time));
if let Some(hr) = s.hr {
out.push_str(&format!(
" <HeartRateBpm><Value>{}</Value></HeartRateBpm>\n",
hr
));
}
if let Some(cad) = s.cadence {
out.push_str(&format!(" <Cadence>{}</Cadence>\n", cad));
}
if let Some(w) = s.watts {
out.push_str(" <Extensions>\n");
out.push_str(
" <TPX xmlns=\"http://www.garmin.com/xmlschemas/ActivityExtension/v2\">\n",
);
out.push_str(&format!(" <Watts>{}</Watts>\n", w));
out.push_str(" </TPX>\n");
out.push_str(" </Extensions>\n");
}
out.push_str(" </Trackpoint>\n");
}
out.push_str(" </Track>\n");
out.push_str(" </Lap>\n");
out.push_str(&format!(" <Notes>{}</Notes>\n", esc(name)));
out.push_str(" </Activity>\n");
out.push_str(" </Activities>\n");
out.push_str("</TrainingCenterDatabase>\n");
out
}
/// Каталог сохранённых активностей: env `RIDEIN_ACTIVITIES_DIR`, иначе
/// `activities/` рядом с cwd (как `workouts_dir` для запланированных).
pub fn activities_dir() -> PathBuf {
std::env::var("RIDEIN_ACTIVITIES_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("activities"))
}
/// Имя → безопасный слаг для имени файла (ASCII alnum, остальное — `_`).
pub fn slugify(name: &str) -> String {
let mut out = String::new();
let mut prev_underscore = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if !prev_underscore {
out.push('_');
prev_underscore = true;
}
}
let trimmed = out.trim_matches('_').to_string();
if trimmed.is_empty() {
"workout".to_string()
} else {
trimmed
}
}
/// Проверяет, что строка начинается с валидного ISO-префикса
/// `YYYY-MM-DDTHH:MM:SS` — достаточно для безопасного извлечения даты/времени
/// в имя файла (не даёт протащить `..` или `/`).
pub fn is_iso_timestamp(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 19 {
return false;
}
let d = |i: usize| b[i].is_ascii_digit();
d(0) && d(1) && d(2) && d(3)
&& b[4] == b'-'
&& d(5) && d(6)
&& b[7] == b'-'
&& d(8) && d(9)
&& b[10] == b'T'
&& d(11) && d(12)
&& b[13] == b':'
&& d(14) && d(15)
&& b[16] == b':'
&& d(17) && d(18)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(time: &str, watts: Option<i16>, cadence: Option<u16>, hr: Option<u16>) -> ActivitySample {
ActivitySample { time: time.to_string(), watts, cadence, hr }
}
#[test]
fn serializes_full_trackpoint() {
let samples = vec![sample("2026-10-28T10:00:00Z", Some(150), Some(85), Some(120))];
let xml = serialize_tcx("Threshold", &samples);
assert!(xml.contains("<TrainingCenterDatabase"));
assert!(xml.contains("<Activity Sport=\"Biking\">"));
assert!(xml.contains("<Id>2026-10-28T10:00:00Z</Id>"));
assert!(xml.contains("<Lap StartTime=\"2026-10-28T10:00:00Z\">"));
assert!(xml.contains("<HeartRateBpm><Value>120</Value></HeartRateBpm>"));
assert!(xml.contains("<Cadence>85</Cadence>"));
assert!(xml.contains("<Watts>150</Watts>"));
assert!(xml.contains("<TPX"));
}
#[test]
fn omits_absent_metrics() {
let samples = vec![sample("2026-10-28T10:00:00Z", None, None, Some(140))];
let xml = serialize_tcx("W", &samples);
assert!(xml.contains("<HeartRateBpm><Value>140</Value></HeartRateBpm>"));
assert!(!xml.contains("<Cadence>"));
assert!(!xml.contains("<Watts>"));
}
#[test]
fn escapes_name() {
let samples = vec![sample("2026-10-28T10:00:00Z", Some(1), None, None)];
let xml = serialize_tcx("A & B < C", &samples);
assert!(xml.contains("<Notes>A &amp; B &lt; C</Notes>"));
}
#[test]
fn slugifies_filenames() {
assert_eq!(slugify("Threshold Over-Under 2x20"), "threshold_over_under_2x20");
assert_eq!(slugify(" !! "), "workout");
assert_eq!(slugify("VO2max 5x4min"), "vo2max_5x4min");
}
#[test]
fn notes_go_after_lap() {
let samples = vec![sample("2026-10-28T10:00:00Z", Some(1), None, None)];
let xml = serialize_tcx("W", &samples);
let notes = xml.find("<Notes>").expect("Notes present");
let lap_end = xml.find("</Lap>").expect("Lap close present");
assert!(notes > lap_end, "Notes must come after </Lap>");
}
#[test]
fn validates_iso_timestamp() {
assert!(is_iso_timestamp("2026-10-28T10:00:00.000Z"));
assert!(is_iso_timestamp("2026-10-28T10:00:00"));
assert!(!is_iso_timestamp("../../../../etc"));
assert!(!is_iso_timestamp("2026-10-28"));
assert!(!is_iso_timestamp(""));
}
}

@ -44,6 +44,16 @@ pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> {
let flags = u16::from_le_bytes([bytes[0], bytes[1]]); let flags = u16::from_le_bytes([bytes[0], bytes[1]]);
let mut out = Vec::new(); let mut out = Vec::new();
// Magene T110 (и, возможно, другие Magene): шлёт НЕстандартный Indoor Bike Data —
// флаги всегда 0x0040, а раскладка фиксированная: speed(2-3), power(4-5),
// cadence(6-7, 0.1 rpm). Стандартный разбор по флагам тут не срабатывает.
// Единица 0.1 rpm (не стандартные 0.5): проверено по соотношению скорость/каденс.
if flags == 0x0040 && bytes.len() >= 8 {
let raw_cad = u16::from_le_bytes([bytes[6], bytes[7]]);
out.push(Telemetry::Cadence { rpm: raw_cad as f32 / 10.0 });
return out;
}
// Byte offset of instantaneous cadence (after the 2-byte flags). // Byte offset of instantaneous cadence (after the 2-byte flags).
let cadence_off = 2 let cadence_off = 2
+ if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed + if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed
@ -185,6 +195,17 @@ mod tests {
assert!(parse_indoor_bike_data(&[]).is_empty()); assert!(parse_indoor_bike_data(&[]).is_empty());
} }
#[test]
fn test_magene_indoor_bike_cadence() {
// Реальный пакет Magene T110 (ручное вращение): flags=0x0040, speed=0x02f2,
// power=8 (4-5), cadence=0x01fd (6-7) = 509 * 0.1 = 50.9 rpm.
let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03];
assert_eq!(
parse_indoor_bike_data(&bytes),
vec![Telemetry::Cadence { rpm: 50.9 }]
);
}
#[test] #[test]
fn test_parse_battery() { fn test_parse_battery() {
assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 })); assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 }));

@ -141,6 +141,18 @@ once("devices-loaded", (event) => {
renderGroup(groups.trainers, repo.trainers ?? []); renderGroup(groups.trainers, repo.trainers ?? []);
}); });
// Непрерывный скан: новые датчики добавляются в список по мере появления.
listen("device-found", (event) => {
const { device, categories } = event.payload;
for (const cat of categories) {
const group = Object.values(groups).find((g) => g.category === cat);
if (!group) continue;
const empty = group.list.querySelector(".device--empty");
if (empty) empty.remove();
group.list.appendChild(renderDevice(device, cat));
}
});
listen("battery", (event) => { listen("battery", (event) => {
const { device_id, percent } = event.payload; const { device_id, percent } = event.payload;
const card = deviceCards.get(device_id); const card = deviceCards.get(device_id);
@ -163,6 +175,7 @@ listen("telemetry", (event) => {
setMetric("cadence-value", `${actualCadence} rpm`); setMetric("cadence-value", `${actualCadence} rpm`);
updateCadenceDeviation(); updateCadenceDeviation();
} else if (t.HeartRate) { } else if (t.HeartRate) {
actualHr = t.HeartRate.bpm;
setMetric("hr-value", t.HeartRate.bpm); setMetric("hr-value", t.HeartRate.bpm);
sampleHr(t.HeartRate.bpm); sampleHr(t.HeartRate.bpm);
scheduleDraw(); scheduleDraw();
@ -222,6 +235,7 @@ let targetWatts = null;
let targetCadence = null; let targetCadence = null;
let actualWatts = null; let actualWatts = null;
let actualCadence = null; let actualCadence = null;
let actualHr = null;
function powerTolerance(target) { function powerTolerance(target) {
return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT));
@ -410,6 +424,10 @@ window.addEventListener("resize", () => {
let workouts = []; let workouts = [];
let workoutPaused = false; let workoutPaused = false;
let workoutSamples = [];
let workoutStartMs = 0;
let workoutSaveName = "workout";
let lastSampleSecs = -1;
function formatClock(sec) { function formatClock(sec) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
@ -465,6 +483,13 @@ workoutStartBtn.addEventListener("click", async () => {
const w = selectedWorkout(); const w = selectedWorkout();
const ftp = Number(ftpInput.value); const ftp = Number(ftpInput.value);
if (!w || !Number.isFinite(ftp) || ftp <= 0) return; if (!w || !Number.isFinite(ftp) || ftp <= 0) return;
workoutSamples = [];
workoutStartMs = Date.now();
workoutSaveName = w.name;
actualWatts = null;
actualCadence = null;
actualHr = null;
lastSampleSecs = -1;
try { try {
await invoke("set_ftp", { ftp }); await invoke("set_ftp", { ftp });
await invoke("start_workout", { path: w.path }); await invoke("start_workout", { path: w.path });
@ -503,8 +528,33 @@ listen("workout-progress", (event) => {
timerEl.textContent = formatClock(p.elapsed_secs); timerEl.textContent = formatClock(p.elapsed_secs);
const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0;
workoutBar.style.width = `${Math.min(100, pct)}%`; workoutBar.style.width = `${Math.min(100, pct)}%`;
if (p.elapsed_secs > lastSampleSecs) {
lastSampleSecs = p.elapsed_secs;
workoutSamples.push({
time: new Date(workoutStartMs + p.elapsed_secs * 1000).toISOString(),
watts: actualWatts,
cadence: actualCadence,
hr: actualHr,
});
}
}); });
listen("workout-finished", () => { listen("workout-finished", (event) => {
setRunning(false); setRunning(false);
if (event.payload?.ok && workoutSamples.length) {
saveWorkout();
}
}); });
async function saveWorkout() {
try {
const path = await invoke("save_workout", {
name: workoutSaveName,
samples: workoutSamples,
});
console.log("saved workout:", path);
workoutMeta.textContent = `сохранено: ${path}`;
} catch (e) {
console.error(e);
}
}

Loading…
Cancel
Save