Compare commits

...

2 Commits

@ -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,20 +4,24 @@ 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;
use crate::ble::BleError; use crate::ble::BleError;
use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult}; use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult};
use crate::device::Device; use crate::device::Device;
use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, CscAccumulator, Telemetry}; use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, parse_power_crank, CscAccumulator, Telemetry};
#[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,27 @@ 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;
// Логируем сырые байты для диагностики. 0x2AD2 (Indoor Bike Data — там
// каденс/скорость/мощность) логируем дольше (до 500), остальные — до 30,
// чтобы «пустые» уведомления до начала вращения не съели лимит.
let mut bike_logged = 0u32;
let mut other_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);
let is_bike = uuid.to_string().starts_with("00002ad2");
let counter = if is_bike { &mut bike_logged } else { &mut other_logged };
let cap = if is_bike { 500u32 } else { 30u32 };
if *counter < cap {
*counter += 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 +274,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 +282,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 +301,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 +315,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 +351,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()
@ -268,15 +376,50 @@ fn parse_notification(
.into_iter() .into_iter()
.collect() .collect()
} else if n.uuid == POWER_MEASUREMENT { } else if n.uuid == POWER_MEASUREMENT {
parse_power(&n.value).into_iter().collect() // мощность + каденс из crank revolution data (пауэрметр Quarq и т.п.)
let mut out: Vec<Telemetry> = parse_power(&n.value).into_iter().collect();
if let Some(raw) = parse_power_crank(&n.value) {
if let Some(rpm) = acc.push(raw) {
out.push(Telemetry::Cadence { rpm });
}
}
out
} 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)
}
}
}

@ -23,6 +23,19 @@ pub enum DeviceType {
HrSensor, HrSensor,
CadSensor, CadSensor,
Trainer, Trainer,
PowerMeter,
}
impl DeviceType {
/// Категория на фронте ("hr"/"cadence"/"trainer"/"power") для маршрутизации события.
pub fn category(&self) -> &'static str {
match self {
DeviceType::HrSensor => "hr",
DeviceType::CadSensor => "cadence",
DeviceType::Trainer => "trainer",
DeviceType::PowerMeter => "power",
}
}
} }
pub fn get_device_types(device: &Device) -> Vec<DeviceType> { pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
@ -32,9 +45,13 @@ pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
let csc_services = ["1816"]; let csc_services = ["1816"];
let csc_characteristics = ["2a5b"]; let csc_characteristics = ["2a5b"];
// trainer / cycling power / fitness machine / indoor bike // smart trainer / FTMS (indoor bike, fitness machine)
let trainer_services = ["1818", "1826"]; let trainer_services = ["1826"];
let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement let trainer_characteristics = ["2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics
// power meter (Cycling Power без FTMS) — Quarq и т.п.
let power_services = ["1818"];
let power_characteristics = ["2a63"]; // 2A63 = Cycling Power Measurement
// любые 2ADx характеристики (indoor bike related) - учитывать как признак каденса доступного в тренажёре // любые 2ADx характеристики (indoor bike related) - учитывать как признак каденса доступного в тренажёре
let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc.
@ -73,10 +90,13 @@ pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
out.push(DeviceType::CadSensor); out.push(DeviceType::CadSensor);
} }
// Trainer/Cycling power/Indoor bike // Trainer (FTMS). Приоритет: станок рекламирует и 0x1826, и 0x1818 — он всё равно станок.
let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics);
if is_trainer { if is_trainer {
out.push(DeviceType::Trainer); out.push(DeviceType::Trainer);
} else if contains_any(&power_services) || contains_any(&power_characteristics) {
// Power meter (Cycling Power без FTMS) — Quarq и т.п.
out.push(DeviceType::PowerMeter);
} }
// Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence - // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence -
@ -163,13 +183,13 @@ mod tests {
} }
#[test] #[test]
fn test_trainer_by_power_service() { fn test_power_meter_by_service() {
assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::PowerMeter]);
} }
#[test] #[test]
fn test_trainer_by_power_characteristic() { fn test_power_meter_by_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::PowerMeter]);
} }
#[test] #[test]
@ -222,4 +242,12 @@ 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");
assert_eq!(DeviceType::PowerMeter.category(), "power");
}
} }

@ -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 {
@ -9,6 +9,8 @@ pub struct Repo {
cad_sensors: Vec<Device>, cad_sensors: Vec<Device>,
#[serde(default)] #[serde(default)]
trainers: Vec<Device>, trainers: Vec<Device>,
#[serde(default)]
power_meters: Vec<Device>,
} }
impl Repo { impl Repo {
@ -17,118 +19,7 @@ impl Repo {
hr_sensors: Vec::new(), hr_sensors: Vec::new(),
cad_sensors: Vec::new(), cad_sensors: Vec::new(),
trainers: Vec::new(), trainers: Vec::new(),
power_meters: Vec::new(),
} }
} }
} }
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
@ -245,6 +274,13 @@ fn get_workout(path: String) -> Result<workout::Workout, String> {
workout::read_workout(&path).map_err(|e| e.to_string()) workout::read_workout(&path).map_err(|e| e.to_string())
} }
/// Развёрнутые линейные сегменты воркаута (для отрисовки профиля).
#[tauri::command]
fn get_segments(path: String) -> Result<Vec<workout::Segment>, String> {
let w = workout::read_workout(&path).map_err(|e| e.to_string())?;
Ok(workout::flatten(&w.steps))
}
/// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. /// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты.
#[tauri::command] #[tauri::command]
fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> {
@ -274,7 +310,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 +333,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 +351,10 @@ 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() { // Контроль станка уже запрошен при подключении (run_watchdog). Повторный
return false; // request_control здесь срывает тренировку на Magene (отказ/таймаут при уже
} // выданном контроле) — воркаут мгновенно «останавливается» с ok: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 +364,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 +390,7 @@ async fn run_workout_engine(
} }
} }
let _ = source.command(device, Command::FtmsStop).await; send_command(source, device, Command::FtmsStop).await;
true true
} }
@ -386,6 +430,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 +465,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 +482,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 +648,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 +667,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),
@ -606,11 +684,13 @@ pub fn run() {
trainer_stop, trainer_stop,
list_workouts, list_workouts,
get_workout, get_workout,
get_segments,
set_ftp, set_ftp,
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 +744,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(""));
}
}

@ -35,6 +35,35 @@ pub(crate) fn parse_power(bytes: &[u8]) -> Option<Telemetry> {
Some(Telemetry::Power { watts }) Some(Telemetry::Power { watts })
} }
/// Cycling Power Measurement (0x2A63): crank revolution data (кумулятивные обороты
/// шатуна + время) для вычисления каденса. Поле опциональное (флаг bit 5 = 0x0020),
/// после мгновенной мощности и предшествующих опциональных полей.
pub(crate) fn parse_power_crank(bytes: &[u8]) -> Option<CscRaw> {
if bytes.len() < 4 {
return None;
}
let flags = u16::from_le_bytes([bytes[0], bytes[1]]);
if flags & 0x0020 == 0 {
return None; // нет crank revolution data
}
let mut idx = 4; // flags(2) + instantaneous power(2)
if flags & 0x0001 != 0 {
idx += 1; // pedal power balance (uint8)
}
if flags & 0x0004 != 0 {
idx += 2; // accumulated torque (uint16)
}
if flags & 0x0010 != 0 {
idx += 6; // wheel revolution data (uint32 + uint16)
}
if bytes.len() < idx + 4 {
return None;
}
let crank_revs = u16::from_le_bytes([bytes[idx], bytes[idx + 1]]);
let crank_time = u16::from_le_bytes([bytes[idx + 2], bytes[idx + 3]]);
Some(CscRaw { crank_revs, crank_time })
}
/// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags. /// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags.
/// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5). /// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5).
pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> { pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> {
@ -44,6 +73,11 @@ 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: НЕстандартный Indoor Bike Data — флаги всегда 0x0040, раскладка
// speed(2-3, 0.01 km/h), power(4-5, W), flywheel speed(6-7, rpm). Каденса в 0x2AD2
// НЕТ: станок не отдаёт обороты шатунов по стандартному BLE. Поэтому здесь ничего
// не извлекаем — стандартный разбор ниже вернёт пусто (флаги 0x0040 = avg power).
// 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
@ -165,6 +199,21 @@ mod tests {
assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None); assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None);
} }
#[test]
fn test_parse_power_crank_extracts_crank_data() {
// flags=0x0021 (bit0 pedal balance + bit5 crank), power=200, balance=50,
// crank revs=10, crank time=2048
let bytes = [0x21, 0x00, 0xC8, 0x00, 0x32, 0x0A, 0x00, 0x00, 0x08];
assert_eq!(parse_power_crank(&bytes), Some(CscRaw { crank_revs: 10, crank_time: 2048 }));
}
#[test]
fn test_parse_power_crank_no_crank_data() {
// flags=0x0001 (только pedal balance) — crank revolution data нет
let bytes = [0x01, 0x00, 0xC8, 0x00, 0x32];
assert_eq!(parse_power_crank(&bytes), None);
}
#[test] #[test]
fn test_indoor_bike_cadence_and_power() { fn test_indoor_bike_cadence_and_power() {
// flags: bit 3 (cadence) + bit 5 (power) = 0x0028 // flags: bit 3 (cadence) + bit 5 (power) = 0x0028
@ -185,6 +234,14 @@ mod tests {
assert!(parse_indoor_bike_data(&[]).is_empty()); assert!(parse_indoor_bike_data(&[]).is_empty());
} }
#[test]
fn test_magene_indoor_bike_has_no_cadence() {
// Magene T110 0x2AD2: flags=0x0040, speed(2-3), power(4-5), flywheel(6-7).
// Каденса нет — стандартный разбор по флагам (0x0040 = avg power) пуст.
let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03];
assert!(parse_indoor_bike_data(&bytes).is_empty());
}
#[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 }));

@ -25,12 +25,21 @@
<h2 class="device-group__title">каденс</h2> <h2 class="device-group__title">каденс</h2>
<ul class="device-list" id="cadence-list"></ul> <ul class="device-list" id="cadence-list"></ul>
</section> </section>
<section class="device-group">
<h2 class="device-group__title">мощность</h2>
<ul class="device-list" id="power-list"></ul>
</section>
<section class="device-group"> <section class="device-group">
<h2 class="device-group__title">станок</h2> <h2 class="device-group__title">станок</h2>
<ul class="device-list" id="trainer-list"></ul> <ul class="device-list" id="trainer-list"></ul>
</section> </section>
</section> </section>
<label class="power-source">
<input type="checkbox" id="power-source-check" />
<span>мощность с пауэрметра</span>
</label>
<button class="btn btn--primary" id="start-btn" disabled>начать</button> <button class="btn btn--primary" id="start-btn" disabled>начать</button>
</main> </main>
@ -53,6 +62,8 @@
<p class="workout-meta" id="workout-meta"></p> <p class="workout-meta" id="workout-meta"></p>
</section> </section>
<section class="workout-profile" id="workout-profile" aria-hidden="true"></section>
<section class="metrics"> <section class="metrics">
<div class="metric metric--power" id="power-metric"> <div class="metric metric--power" id="power-metric">
<span class="metric__label">мощность</span> <span class="metric__label">мощность</span>

@ -4,11 +4,13 @@ const { invoke } = window.__TAURI__.core;
const connectScreen = document.querySelector("#connect"); const connectScreen = document.querySelector("#connect");
const workoutScreen = document.querySelector("#workout"); const workoutScreen = document.querySelector("#workout");
const startBtn = document.querySelector("#start-btn"); const startBtn = document.querySelector("#start-btn");
const powerSourceCheck = document.querySelector("#power-source-check");
const groups = { const groups = {
hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" }, hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" },
cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" }, cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" },
trainers: { list: document.querySelector("#trainer-list"), category: "trainer" }, trainers: { list: document.querySelector("#trainer-list"), category: "trainer" },
power_meters: { list: document.querySelector("#power-list"), category: "power" },
}; };
const deviceCards = new Map(); // device_id -> li const deviceCards = new Map(); // device_id -> li
@ -108,7 +110,7 @@ async function connect(deviceId, btn, category, card) {
resetSelected(category); resetSelected(category);
btn.textContent = "✓"; btn.textContent = "✓";
btn.classList.add("device__connect--done"); btn.classList.add("device__connect--done");
selected[category] = { btn, card }; selected[category] = { btn, card, deviceId };
if (pendingStatus[category]) { if (pendingStatus[category]) {
applyStatus(selected[category], category, pendingStatus[category]); applyStatus(selected[category], category, pendingStatus[category]);
delete pendingStatus[category]; delete pendingStatus[category];
@ -139,6 +141,19 @@ once("devices-loaded", (event) => {
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
renderGroup(groups.trainers, repo.trainers ?? []); renderGroup(groups.trainers, repo.trainers ?? []);
renderGroup(groups.power_meters, repo.power_meters ?? []);
});
// Непрерывный скан: новые датчики добавляются в список по мере появления.
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) => {
@ -152,17 +167,25 @@ listen("battery", (event) => {
listen("telemetry", (event) => { listen("telemetry", (event) => {
const t = event.payload.telemetry; const t = event.payload.telemetry;
const devId = event.payload.device_id;
if (t.Power) { if (t.Power) {
// источник мощности: пауэрметр (чекбокс + он подключён) или станок
const pmId = selected.power?.deviceId;
const trainerId = selected.trainer?.deviceId;
const expectedId = powerSourceCheck.checked && pmId ? pmId : trainerId;
if (!expectedId || devId === expectedId) {
actualWatts = t.Power.watts; actualWatts = t.Power.watts;
setMetric("power-value", `${actualWatts} W`); setMetric("power-value", `${actualWatts} W`);
updatePowerDeviation(); updatePowerDeviation();
samplePower(actualWatts); samplePower(actualWatts);
scheduleDraw(); scheduleDraw();
}
} else if (t.Cadence) { } else if (t.Cadence) {
actualCadence = Math.round(t.Cadence.rpm); actualCadence = Math.round(t.Cadence.rpm);
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();
@ -204,6 +227,7 @@ const workoutMeta = document.querySelector("#workout-meta");
const workoutName = document.querySelector("#workout-name"); const workoutName = document.querySelector("#workout-name");
const workoutStep = document.querySelector("#workout-step"); const workoutStep = document.querySelector("#workout-step");
const workoutBar = document.querySelector("#workout-bar"); const workoutBar = document.querySelector("#workout-bar");
const workoutProfile = document.querySelector("#workout-profile");
const workoutStartBtn = document.querySelector("#workout-start"); const workoutStartBtn = document.querySelector("#workout-start");
const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutPauseBtn = document.querySelector("#workout-pause");
const workoutStopBtn = document.querySelector("#workout-stop"); const workoutStopBtn = document.querySelector("#workout-stop");
@ -222,6 +246,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));
@ -274,8 +299,7 @@ const powerHistory = [];
const hrHistory = []; const hrHistory = [];
let graphDirty = false; let graphDirty = false;
function powerZone(watts, ftp) { function percentZone(pct) {
const pct = (watts / ftp) * 100;
if (pct < 55) return 0; if (pct < 55) return 0;
if (pct < 76) return 1; if (pct < 76) return 1;
if (pct < 91) return 2; if (pct < 91) return 2;
@ -285,6 +309,10 @@ function powerZone(watts, ftp) {
return 6; return 6;
} }
function powerZone(watts, ftp) {
return percentZone((watts / ftp) * 100);
}
function currentFtp() { function currentFtp() {
const v = Number(ftpInput.value); const v = Number(ftpInput.value);
return Number.isFinite(v) && v > 0 ? v : 250; return Number.isFinite(v) && v > 0 ? v : 250;
@ -410,6 +438,64 @@ 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 segmentPowerPct(seg) {
const p = seg.power;
if (!p) return null;
if (p.type === "fixed") return p.percent;
if (p.type === "ramp") return (p.from + p.to) / 2;
if (p.type === "range") return (p.low + p.high) / 2;
return null;
}
function segmentColor(seg) {
const pct = segmentPowerPct(seg);
return pct == null ? "var(--c-surface-2)" : ZONE_COLORS[percentZone(pct)];
}
function segmentLabel(seg) {
const name = seg.text || "без имени";
const dur = formatClock(seg.duration_secs);
let power = "";
if (seg.power) {
const p = seg.power;
if (p.type === "fixed") power = `${p.percent}%`;
else if (p.type === "ramp") power = `${p.from}→${p.to}%`;
else if (p.type === "range") power = `${p.low}–${p.high}%`;
}
const cad = seg.cadence_rpm ? ` · ${seg.cadence_rpm} rpm` : "";
return `${name} · ${dur}${power ? ` · ${power}` : ""}${cad}`;
}
async function renderWorkoutProfile(path) {
try {
const segments = await invoke("get_segments", { path });
workoutProfile.replaceChildren();
if (!segments.length) return;
for (const seg of segments) {
const block = el("div", "workout-profile__seg");
block.style.flexGrow = String(Math.max(seg.duration_secs, 1));
block.style.background = segmentColor(seg);
block.title = segmentLabel(seg);
workoutProfile.append(block);
}
} catch (e) {
console.error(e);
}
}
function highlightSegment(idx) {
const blocks = workoutProfile.children;
for (let i = 0; i < blocks.length; i++) {
blocks[i].classList.toggle("workout-profile__seg--current", i === idx);
}
}
function formatClock(sec) { function formatClock(sec) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
@ -443,6 +529,11 @@ workoutSelect.addEventListener("change", () => {
const w = selectedWorkout(); const w = selectedWorkout();
workoutName.textContent = w ? w.name : "—"; workoutName.textContent = w ? w.name : "—";
workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : "";
if (w) {
renderWorkoutProfile(w.path);
} else {
workoutProfile.replaceChildren();
}
}); });
async function setRunning(active) { async function setRunning(active) {
@ -458,6 +549,7 @@ async function setRunning(active) {
workoutBar.style.width = "0%"; workoutBar.style.width = "0%";
timerEl.textContent = "00:00"; timerEl.textContent = "00:00";
clearTargets(); clearTargets();
highlightSegment(-1);
} }
} }
@ -465,6 +557,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 });
@ -496,6 +595,7 @@ workoutStopBtn.addEventListener("click", async () => {
listen("workout-progress", (event) => { listen("workout-progress", (event) => {
const p = event.payload; const p = event.payload;
workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`;
highlightSegment(p.step_index);
targetWatts = p.target_watts; targetWatts = p.target_watts;
targetCadence = p.target_cadence; targetCadence = p.target_cadence;
updatePowerDeviation(); updatePowerDeviation();
@ -503,8 +603,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);
}
}

@ -53,7 +53,7 @@ body {
/* --- Подключение датчиков --- */ /* --- Подключение датчиков --- */
#connect { #connect {
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto auto;
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-4); padding: var(--space-4);
width: 100%; width: 100%;
@ -257,6 +257,20 @@ body {
align-self: end; align-self: end;
} }
.power-source {
display: flex;
align-items: center;
gap: var(--space-2);
justify-self: center;
font-size: var(--text-sm);
color: var(--c-text-muted);
cursor: pointer;
}
.power-source input {
accent-color: var(--c-accent);
}
/* ============================================================ /* ============================================================
Экран упражнения Экран упражнения
============================================================ */ ============================================================ */
@ -455,3 +469,27 @@ body {
.controls--workout { .controls--workout {
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
} }
/* --- Профиль воркаута (сегменты) --- */
.workout-profile {
display: flex;
gap: 2px;
height: 28px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.workout-profile__seg {
flex-basis: 0;
min-width: 2px;
opacity: 0.75;
transition: opacity var(--duration-fast) var(--ease);
}
.workout-profile__seg--current {
opacity: 1;
box-shadow: inset 0 0 0 2px var(--c-text);
}

Loading…
Cancel
Save