diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 685b087..823d982 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -57,6 +57,12 @@ impl MockSource { commands: Arc::new(Mutex::new(Vec::new())), } } + + /// Записанные команды — читаются только в тестах движка воркаута. + #[allow(dead_code)] + pub(crate) fn commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } } #[async_trait] @@ -70,11 +76,17 @@ impl SensorSource for MockSource { { let types = get_device_types(device); let s: Pin + Send>> = if types.contains(&DeviceType::Trainer) { - // мощность рампой 150..300 Вт + // мощность 150..300 Вт + каденс 85..95 rpm (как FTMS 0x2AD2 — оба поля) stream::unfold(0u16, |i| async move { - tokio::time::sleep(Duration::from_millis(500)).await; - let watts = 150 + ((i % 16) * 10) as i16; - Some((Telemetry::Power { watts }, i.wrapping_add(1))) + tokio::time::sleep(Duration::from_millis(250)).await; + let item = if i % 2 == 0 { + let watts = 150 + ((i / 2 % 16) * 10) as i16; + Telemetry::Power { watts } + } else { + let rpm = 85.0 + (i / 2 % 10) as f32; + Telemetry::Cadence { rpm } + }; + Some((item, i.wrapping_add(1))) }) .boxed() } else if types.contains(&DeviceType::CadSensor) { @@ -137,4 +149,23 @@ mod tests { let first = stream.next().await; assert!(matches!(first, Some(Telemetry::HeartRate { .. }))); } + + #[tokio::test] + async fn test_mock_trainer_emits_power_and_cadence() { + let src = MockSource::new(); + let devices = src.scan(0).await.unwrap(); + let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap(); + let mut stream = src.stream(trainer).await.unwrap(); + let mut saw_power = false; + let mut saw_cadence = false; + for _ in 0..8 { + match stream.next().await { + Some(Telemetry::Power { .. }) => saw_power = true, + Some(Telemetry::Cadence { .. }) => saw_cadence = true, + _ => {} + } + } + assert!(saw_power, "тренер должен эмитить мощность"); + assert!(saw_cadence, "тренер должен эмитить каденс"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c76843..d169bc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,8 +4,10 @@ mod device_repo; mod state; mod telemetry; mod commands; +mod workout; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -18,7 +20,7 @@ use crate::ble::{BleSource, MockSource, SensorSource}; use crate::commands::Command; use crate::device::Device; use crate::device_repo::{repo_from_devices, Repo}; -use crate::state::{State, TrainerState}; +use crate::state::{State, TrainerState, WorkoutHandle}; use crate::telemetry::Telemetry; use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; @@ -52,6 +54,21 @@ struct ConnectionEvent { status: &'static str, // "reconnecting" | "reconnected" | "gave_up" } +#[derive(Clone, Serialize)] +struct WorkoutProgressEvent { + step_index: usize, + step_name: String, + target_watts: Option, + target_cadence: Option, + elapsed_secs: u64, + total_secs: u64, +} + +#[derive(Clone, Serialize)] +struct WorkoutFinishedEvent { + ok: bool, +} + #[tauri::command] async fn init(app: AppHandle) -> Result<(), String> { let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; @@ -216,6 +233,207 @@ fn trainer_device(state: &State) -> Result { .ok_or_else(|| "станок не найден".to_string()) } +/// Список доступных Ride-воркаутов (сводки). +#[tauri::command] +fn list_workouts() -> Result, String> { + workout::list_workouts(&workout::workouts_dir()).map_err(|e| e.to_string()) +} + +/// Полный воркаут по имени файла (из каталога воркаутов). +#[tauri::command] +fn get_workout(path: String) -> Result { + workout::read_workout(&path).map_err(|e| e.to_string()) +} + +/// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. +#[tauri::command] +fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { + if ftp == 0 { + return Err("FTP должен быть больше нуля".to_string()); + } + *app.state::().ftp.lock().unwrap() = ftp; + Ok(()) +} + +/// Текущий FTP гонщика (Вт). +#[tauri::command] +fn get_ftp(app: AppHandle) -> u16 { + *app.state::().ftp.lock().unwrap() +} + +/// Вычисляет и отправляет целевую мощность сегмента, если она изменилась. +async fn apply_power( + source: &Arc, + device: &Device, + seg: &workout::Segment, + elapsed_secs: f64, + ftp: u16, + last_watts: Option, +) -> Option { + let watts = seg.power.as_ref().map(|p| { + 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) { + let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; + } + watts +} + +/// Собирает событие прогресса воркаута. +fn progress_event( + idx: usize, + seg: &workout::Segment, + watts: Option, + elapsed_total: u64, + total: u64, +) -> WorkoutProgressEvent { + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: watts, + target_cadence: seg.cadence_rpm, + elapsed_secs: elapsed_total, + total_secs: total, + } +} + +/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource. +async fn run_workout_engine( + source: &Arc, + device: &Device, + segments: &[workout::Segment], + ftp: u16, + paused: &AtomicBool, + tick_duration: Duration, + mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, +) -> bool { + if source.request_control(device).await.is_err() { + return false; + } + let _ = source.command(device, Command::FtmsStart).await; + + let total = workout::total_duration_secs(segments); + let mut elapsed_total: u64 = 0; + + for (idx, seg) in segments.iter().enumerate() { + let dur = seg.duration_secs as u64; + + // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). + if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { + let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; + } + + let mut last_watts: Option = None; + let mut tick = time::interval(tick_duration); + tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); + + // t = 0 + let w = apply_power(source, device, seg, 0.0, ftp, last_watts).await; + last_watts = w; + on_progress(progress_event(idx, seg, w, elapsed_total, total)); + + let mut elapsed_in_seg: u64 = 0; + while elapsed_in_seg < dur { + tick.tick().await; + if paused.load(Ordering::SeqCst) { + continue; + } + elapsed_in_seg += 1; + let w = apply_power(source, device, seg, elapsed_in_seg as f64, ftp, last_watts).await; + last_watts = w; + elapsed_total += 1; + on_progress(progress_event(idx, seg, w, elapsed_total, total)); + } + } + + let _ = source.command(device, Command::FtmsStop).await; + true +} + +/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение. +async fn run_workout( + app: AppHandle, + source: Arc, + device: Device, + segments: Vec, + ftp: u16, + paused: Arc, +) { + let app_progress = app.clone(); + let ok = run_workout_engine( + &source, + &device, + &segments, + ftp, + paused.as_ref(), + Duration::from_secs(1), + move |p| { + let _ = app_progress.emit("workout-progress", p); + }, + ) + .await; + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok }); +} + +/// Запустить воркаут (по имени файла из каталога воркаутов). +#[tauri::command] +fn start_workout(app: AppHandle, path: String) -> Result<(), String> { + let workout = workout::read_workout(&path).map_err(|e| e.to_string())?; + let segments = workout::flatten(&workout.steps); + if segments.is_empty() { + return Err("воркаут пустой".to_string()); + } + + let state = app.state::(); + let ftp = *state.ftp.lock().unwrap(); + let device = trainer_device(&state)?; + let source = state.source.clone(); + + // остановить предыдущий воркаут, если был + if let Some(old) = state.workout.lock().unwrap().take() { + old.task.abort(); + } + + let paused = Arc::new(AtomicBool::new(false)); + let app_t = app.clone(); + let source_t = source.clone(); + let device_t = device.clone(); + let paused_t = paused.clone(); + let handle = async_runtime::spawn(async move { + run_workout(app_t, source_t, device_t, segments, ftp, paused_t).await; + }); + + *state.workout.lock().unwrap() = Some(WorkoutHandle { task: handle, paused }); + Ok(()) +} + +/// Остановить текущий воркаут. +#[tauri::command] +async fn stop_workout(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + // взять ручку (guard отпускается сразу, до await) + let handle = { + let mut guard = state.workout.lock().unwrap(); + guard.take() + }; + if let Some(h) = handle { + h.task.abort(); + let device = trainer_device(&state)?; + let _ = state.source.command(&device, Command::FtmsStop).await; + } + Ok(()) +} + +/// Пауза / возобновление текущего воркаута. +#[tauri::command] +fn pause_workout(app: AppHandle, paused: bool) -> Result<(), String> { + let state = app.state::(); + let guard = state.workout.lock().unwrap(); + let handle = guard.as_ref().ok_or("воркаут не запущен")?; + handle.paused.store(paused, Ordering::SeqCst); + Ok(()) +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -373,6 +591,8 @@ pub fn run() { trainer: Mutex::new(None), streams: Mutex::new(std::collections::HashMap::new()), trainer_state: Mutex::new(TrainerState::default()), + ftp: Mutex::new(state::DEFAULT_FTP), + workout: Mutex::new(None), }); Ok::<(), Box>(()) }) @@ -383,7 +603,14 @@ pub fn run() { set_target_power, set_target_cadence, trainer_start, - trainer_stop + trainer_stop, + list_workouts, + get_workout, + set_ftp, + get_ftp, + start_workout, + stop_workout, + pause_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -432,4 +659,54 @@ mod tests { assert!(saw); assert_eq!(seen.len(), 2); } + + #[tokio::test] + async fn workout_engine_sends_expected_commands() { + let mock = Arc::new(MockSource::new()); + let source: Arc = mock.clone(); + let devices = source.scan(0).await.unwrap(); + let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone(); + + // короткий воркаут: фикс-сегмент + ramp-сегмент + let segments = vec![ + workout::Segment { + text: Some("Warmup".into()), + duration_secs: 1, + power: Some(workout::PowerTarget::Fixed { percent: 50.0 }), + cadence_rpm: Some(85), + }, + workout::Segment { + text: Some("Ramp".into()), + duration_secs: 2, + power: Some(workout::PowerTarget::Ramp { from: 100.0, to: 200.0 }), + cadence_rpm: None, + }, + ]; + + let paused = Arc::new(AtomicBool::new(false)); + let ok = run_workout_engine( + &source, + &trainer, + &segments, + 250, + paused.as_ref(), + Duration::from_millis(20), + |_| {}, + ) + .await; + assert!(ok); + + assert_eq!( + mock.commands(), + vec![ + Command::FtmsStart, + Command::FtmsSetTargetCadence(170), // 85 rpm * 2 + Command::FtmsSetTargetPower(125), // 50% of 250 + Command::FtmsSetTargetPower(250), // 100% (ramp start) + Command::FtmsSetTargetPower(375), // 150% + Command::FtmsSetTargetPower(500), // 200% (ramp end) + Command::FtmsStop, + ] + ); + } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7871228..e7a6fcf 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, RwLock}; use tauri::async_runtime::JoinHandle; @@ -6,6 +7,9 @@ use tauri::async_runtime::JoinHandle; use crate::ble::SensorSource; use crate::device::Device; +/// FTP по умолчанию (Вт) — для перевода %FTP воркаутов в ватты. +pub const DEFAULT_FTP: u16 = 250; + /// Trainer control state, restored after a reconnect. #[derive(Default)] pub struct TrainerState { @@ -14,6 +18,12 @@ pub struct TrainerState { pub running: bool, } +/// Ручка запущенного воркаута: задача + флаг паузы. +pub struct WorkoutHandle { + pub task: JoinHandle<()>, + pub paused: Arc, +} + pub struct State { pub source: Arc, /// Все найденные при скане устройства (для поиска по id при подключении). @@ -24,4 +34,8 @@ pub struct State { pub streams: Mutex>>, /// Trainer control state, restored after a reconnect. pub trainer_state: Mutex, + /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. + pub ftp: Mutex, + /// Запущенный воркаут (если идёт). + pub workout: Mutex>, } diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs new file mode 100644 index 0000000..1889a7d --- /dev/null +++ b/src-tauri/src/workout.rs @@ -0,0 +1,465 @@ +//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание +//! вложенных повторов в линейный список сегментов для исполнения на станке. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +// ===== Чистые (доменные) типы ===== + +/// Целевая мощность шага. Значения — в % от FTP (все скачанные воркауты +/// используют `units: "%ftp"`). Перевод %FTP -> ватты делается на этапе +/// исполнения, зная FTP. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PowerTarget { + /// Фиксированная мощность, % FTP. + Fixed { percent: f64 }, + /// Линейный ramp от `from` до `to` % FTP за время шага (`ramp: true`). + Ramp { from: f64, to: f64 }, + /// Свободный диапазон `low..high` % FTP (не ramp) — гонщик едет внутри. + Range { low: f64, high: f64 }, +} + +/// Один шаг воркаута до разворачивания повторов. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkoutStep { + pub text: Option, + pub duration_secs: u32, + pub power: Option, + pub cadence_rpm: Option, + /// Сколько раз повторить (для блока с `children`); для листа — 1. + pub repeats: u32, + pub children: Vec, +} + +/// Развёрнутый линейный сегмент — атом исполнения. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Segment { + pub text: Option, + pub duration_secs: u32, + pub power: Option, + pub cadence_rpm: Option, +} + +/// Воркаут целиком. +#[derive(Debug, Clone, Serialize)] +pub struct Workout { + pub name: String, + pub start_date_local: Option, + pub icu_training_load: Option, + pub steps: Vec, +} + +/// Сводка воркаута для списка. +#[derive(Debug, Clone, Serialize)] +pub struct WorkoutSummary { + pub name: String, + pub date: String, + pub duration_min: u64, + pub load: Option, + pub path: String, +} + +// ===== Сырые serde-структуры (повторяют формат /events intervals.icu) ===== + +#[derive(Deserialize)] +struct RawEvent { + name: Option, + #[serde(rename = "start_date_local")] + start_date_local: Option, + #[serde(rename = "icu_training_load")] + icu_training_load: Option, + workout_doc: Option, +} + +#[derive(Deserialize)] +struct RawWorkoutDoc { + #[serde(default)] + steps: Vec, +} + +#[derive(Deserialize)] +struct RawStep { + #[serde(default)] + text: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + ramp: bool, + #[serde(default)] + reps: Option, + #[serde(default)] + power: Option, + #[serde(default)] + cadence: Option, + #[serde(default)] + steps: Vec, +} + +#[derive(Deserialize)] +struct RawPower { + #[serde(default)] + units: Option, + #[serde(default)] + value: Option, + #[serde(default)] + start: Option, + #[serde(default)] + end: Option, +} + +#[derive(Deserialize)] +struct RawCadence { + #[serde(default)] + value: Option, +} + +// ===== Ошибки ===== + +#[derive(Debug, thiserror::Error)] +pub enum WorkoutError { + #[error("invalid workout JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("unsafe workout path: {0}")] + InvalidPath(String), +} + +// ===== Парсинг ===== + +/// Разбирает event-JSON intervals.icu в `Workout`. +pub fn parse_workout(json: &str) -> Result { + let raw: RawEvent = serde_json::from_str(json)?; + let steps = raw + .workout_doc + .map(|d| d.steps) + .unwrap_or_default() + .into_iter() + .map(convert_step) + .collect(); + Ok(Workout { + name: raw.name.unwrap_or_default(), + start_date_local: raw.start_date_local, + icu_training_load: raw.icu_training_load, + steps, + }) +} + +fn convert_step(raw: RawStep) -> WorkoutStep { + WorkoutStep { + text: raw.text, + duration_secs: raw.duration.unwrap_or(0), + power: raw.power.and_then(|p| convert_power(p, raw.ramp)), + cadence_rpm: raw.cadence.and_then(|c| c.value.map(|v| v.round() as u16)), + repeats: raw.reps.unwrap_or(1), + children: raw.steps.into_iter().map(convert_step).collect(), + } +} + +/// Переводит сырую мощность в `PowerTarget`. Флаг `ramp` (с шага) отличает +/// линейный ramp от свободного диапазона. Предполагаем `units == "%ftp"` +/// (все скачанные воркауты используют %ftp); абсолютные ватты не поддерживаются. +fn convert_power(p: RawPower, ramp: bool) -> Option { + // Отвергаем воркауты в абсолютных ваттах: перевод watts → %FTP требует FTP, + // которого нет на этапе парсинга. Принимаем только %ftp (units == None + // трактуем как %ftp — все скачанные воркауты используют %ftp). + if matches!(p.units.as_deref(), Some(u) if u != "%ftp") { + return None; + } + if let Some(v) = p.value { + return Some(PowerTarget::Fixed { percent: v }); + } + match (p.start, p.end) { + (Some(s), Some(e)) if ramp => Some(PowerTarget::Ramp { from: s, to: e }), + (Some(s), Some(e)) => Some(PowerTarget::Range { low: s, high: e }), + _ => None, + } +} + +// ===== Flatten ===== + +/// Разворачивает шаги (включая вложенные `repeats`) в линейный список сегментов. +pub fn flatten(steps: &[WorkoutStep]) -> Vec { + let mut out = Vec::new(); + for step in steps { + let reps = step.repeats.max(1) as usize; + for _ in 0..reps { + if step.children.is_empty() { + out.push(Segment { + text: step.text.clone(), + duration_secs: step.duration_secs, + power: step.power.clone(), + cadence_rpm: step.cadence_rpm, + }); + } else { + out.extend(flatten(&step.children)); + } + } + } + out +} + +/// Суммарная длительность линейного списка сегментов, секунды. +pub fn total_duration_secs(segments: &[Segment]) -> u64 { + segments.iter().map(|s| s.duration_secs as u64).sum() +} + +/// Целевая мощность (%FTP) в момент `elapsed_secs` внутри шага. +/// Fixed — константа; Ramp — линейная интерполяция from→to. +/// +/// Range (свободный диапазон low..high) сводится к середине: в ERG-режиме +/// нужна конкретная цель, середина — осознанный дефолт. Альтернативы: не слать +/// ERG вовсе (свободный ход) или слать low как «пол». +pub fn target_percent(power: &PowerTarget, elapsed_secs: f64, duration_secs: u32) -> f64 { + match power { + PowerTarget::Fixed { percent } => *percent, + PowerTarget::Ramp { from, to } => { + if duration_secs == 0 { + *to + } else { + let t = (elapsed_secs / duration_secs as f64).clamp(0.0, 1.0); + from + (to - from) * t + } + } + PowerTarget::Range { low, high } => (low + high) / 2.0, + } +} + +/// %FTP → ватты (округление до целого). +pub fn percent_to_watts(percent: f64, ftp: u16) -> i16 { + (percent / 100.0 * ftp as f64).round() as i16 +} + +/// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd. +pub fn workouts_dir() -> PathBuf { + std::env::var("RIDEIN_WORKOUTS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("workouts")) +} + +/// Сканирует каталог и возвращает сводки Ride-воркаутов (с непустыми шагами), +/// отсортированные по дате. `events.json` и неразбираемые файлы пропускаются. +pub fn list_workouts(dir: &Path) -> Result, WorkoutError> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if path.file_name().and_then(|f| f.to_str()) == Some("events.json") { + continue; + } + let workout = match read_workout_file(&path) { + Ok(w) => w, + Err(e) => { + eprintln!("skip unparseable workout {}: {e}", path.display()); + continue; + } + }; + if workout.steps.is_empty() { + continue; // не Ride (нет workout_doc.steps) + } + let duration_min = total_duration_secs(&flatten(&workout.steps)) / 60; + out.push(WorkoutSummary { + name: workout.name.clone(), + date: workout.start_date_local.clone().unwrap_or_default(), + duration_min, + load: workout.icu_training_load, + path: path.file_name().unwrap().to_string_lossy().into_owned(), + }); + } + out.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(out) +} + +/// Читает воркаут по имени файла (без пути) из каталога воркаутов. +pub fn read_workout(filename: &str) -> Result { + let fname = Path::new(filename); + if filename.contains("..") || fname.components().count() != 1 { + return Err(WorkoutError::InvalidPath(filename.to_string())); + } + let path = workouts_dir().join(fname); + read_workout_file(&path) +} + +fn read_workout_file(path: &Path) -> Result { + let json = std::fs::read_to_string(path)?; + parse_workout(&json) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVENT: &str = r#"{ + "name": "Test", + "start_date_local": "2026-10-28T09:00:00", + "icu_training_load": 91, + "workout_doc": { + "steps": [ + {"ramp": true, "text": "Warmup", "power": {"units":"%ftp","start":45,"end":75}, "cadence": {"units":"rpm","value":85}, "duration": 600}, + {"text": "Endurance", "power": {"units":"%ftp","start":65,"end":75}, "duration": 300}, + {"text": "Primer", "power": {"units":"%ftp","value":95}, "cadence": {"units":"rpm","value":90}, "duration": 60}, + {"reps": 4, "text": "Main", "steps": [ + {"text": "Under", "power": {"units":"%ftp","value":90}, "duration": 180}, + {"text": "Over", "power": {"units":"%ftp","value":105}, "duration": 120} + ], "duration": 1200} + ] + } + }"#; + + #[test] + fn parses_workout_metadata() { + let w = parse_workout(EVENT).unwrap(); + assert_eq!(w.name, "Test"); + assert_eq!(w.start_date_local.as_deref(), Some("2026-10-28T09:00:00")); + assert_eq!(w.icu_training_load, Some(91.0)); + assert_eq!(w.steps.len(), 4); + } + + #[test] + fn parses_power_targets() { + let w = parse_workout(EVENT).unwrap(); + // Warmup -> ramp + assert_eq!( + w.steps[0].power, + Some(PowerTarget::Ramp { from: 45.0, to: 75.0 }) + ); + // Endurance (start/end, no ramp flag) -> range + assert_eq!( + w.steps[1].power, + Some(PowerTarget::Range { low: 65.0, high: 75.0 }) + ); + // Primer -> fixed + assert_eq!( + w.steps[2].power, + Some(PowerTarget::Fixed { percent: 95.0 }) + ); + // cadence: Warmup 85rpm + assert_eq!(w.steps[0].cadence_rpm, Some(85)); + } + + #[test] + fn rejects_non_ftp_power_units() { + // units:"watts" нельзя перевести в %FTP без FTP на этапе парсинга — + // шаг должен терять цель мощности, а не трактовать ватты как проценты. + let json = r#"{"name":"W","workout_doc":{"steps":[ + {"text":"X","duration":60,"power":{"units":"watts","value":200}} + ]}}"#; + let w = parse_workout(json).unwrap(); + assert_eq!(w.steps[0].power, None); + } + + #[test] + fn flatten_expands_repeats() { + let w = parse_workout(EVENT).unwrap(); + let segs = flatten(&w.steps); + // Warmup + Endurance + Primer + 4x(Under,Over) = 3 + 8 = 11 + assert_eq!(segs.len(), 11); + // первая четвёрка после первых трёх шагов — Under/Over/Under/Over + let main = &segs[3..11]; + assert_eq!(main.len(), 8); + assert_eq!(main[0].text.as_deref(), Some("Under")); + assert_eq!(main[1].text.as_deref(), Some("Over")); + assert_eq!(main[6].text.as_deref(), Some("Under")); + assert_eq!(main[7].text.as_deref(), Some("Over")); + } + + #[test] + fn total_duration_matches() { + let w = parse_workout(EVENT).unwrap(); + let segs = flatten(&w.steps); + // 600 + 300 + 60 + 4*(180+120) = 2160 + assert_eq!(total_duration_secs(&segs), 2160); + } + + #[test] + #[ignore] + fn parses_real_downloaded_workouts() { + let cases = [ + ("2026-10-28_threshold-over-under-2x20.json", 4380), + ("2026-10-30_vo2max-5x4min.json", 3600), + ("2026-11-01_long-z2-endurance.json", 7200), + ]; + for (file, expected_secs) in cases { + let path = format!("/home/dude/code/ridein/workouts/{file}"); + let json = std::fs::read_to_string(&path).unwrap(); + let w = parse_workout(&json).unwrap(); + let segs = flatten(&w.steps); + assert_eq!( + total_duration_secs(&segs), + expected_secs, + "workout {file}: flat total mismatch" + ); + } + + // list_workouts на реальном каталоге: 3 Ride-воркаута, по дате. + let dir = std::path::Path::new("/home/dude/code/ridein/workouts"); + let list = list_workouts(dir).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].name, "Threshold Over-Under 2x20"); + assert_eq!(list[1].name, "VO2max 5x4min"); + assert_eq!(list[2].name, "Long Z2 (Endurance)"); + } + + #[test] + fn lists_ride_workouts_skipping_non_ride() { + let dir = std::env::temp_dir().join("ridein-workout-list-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Ride-воркаут (с шагами) + std::fs::write(dir.join("2026-10-28_test-a.json"), EVENT).unwrap(); + // events.json — пропускается по имени + std::fs::write(dir.join("events.json"), "[{}]").unwrap(); + // не-Ride (пустой workout_doc) — пропускается + std::fs::write( + dir.join("2026-10-30_yoga.json"), + r#"{"name":"Yoga","workout_doc":{"steps":[]}}"#, + ) + .unwrap(); + + let list = list_workouts(&dir).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Test"); + assert_eq!(list[0].duration_min, 36); // 2160s / 60 + assert_eq!(list[0].load, Some(91.0)); + assert_eq!(list[0].path, "2026-10-28_test-a.json"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn read_workout_rejects_traversal() { + assert!(read_workout("../etc/passwd").is_err()); + assert!(read_workout("a/b.json").is_err()); + } + + #[test] + fn percent_to_watts_rounds() { + assert_eq!(percent_to_watts(100.0, 250), 250); + assert_eq!(percent_to_watts(90.0, 250), 225); + assert_eq!(percent_to_watts(105.0, 250), 263); // 262.5 → 263 + assert_eq!(percent_to_watts(55.0, 250), 138); // 137.5 → 138 + } + + #[test] + fn ramp_interpolates_linearly() { + let ramp = PowerTarget::Ramp { from: 45.0, to: 75.0 }; + assert_eq!(target_percent(&ramp, 0.0, 600), 45.0); + assert_eq!(target_percent(&ramp, 300.0, 600), 60.0); + assert_eq!(target_percent(&ramp, 600.0, 600), 75.0); + } + + #[test] + fn fixed_and_range_targets() { + let fixed = PowerTarget::Fixed { percent: 95.0 }; + assert_eq!(target_percent(&fixed, 0.0, 60), 95.0); + assert_eq!(target_percent(&fixed, 30.0, 60), 95.0); + + let range = PowerTarget::Range { low: 65.0, high: 75.0 }; + assert_eq!(target_percent(&range, 0.0, 300), 70.0); + } +} diff --git a/src/index.html b/src/index.html index 60329cb..9d72778 100644 --- a/src/index.html +++ b/src/index.html @@ -38,19 +38,31 @@
00:00
- +
—
+
+ + +

+
+
-
+
мощность - — - W + — + —
-
+
каденс - — - rpm + — + —
чсс @@ -59,16 +71,23 @@
-
- - - +
+ +
+ +
+
+ — +
+
+
+
+
+ +
+ + +
diff --git a/src/main.js b/src/main.js index 7eb5121..79bb870 100644 --- a/src/main.js +++ b/src/main.js @@ -122,9 +122,11 @@ async function connect(deviceId, btn, category, card) { } // --- Переход на экран упражнения --- -startBtn.addEventListener("click", () => { +startBtn.addEventListener("click", async () => { connectScreen.hidden = true; workoutScreen.hidden = false; + sizeGraph(); + await loadWorkouts(); }); // --- Телеметрия --- @@ -150,9 +152,21 @@ listen("battery", (event) => { listen("telemetry", (event) => { const t = event.payload.telemetry; - if (t.Power) setMetric("power-value", t.Power.watts); - else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm)); - else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm); + if (t.Power) { + actualWatts = t.Power.watts; + setMetric("power-value", `${actualWatts} W`); + updatePowerDeviation(); + samplePower(actualWatts); + scheduleDraw(); + } else if (t.Cadence) { + actualCadence = Math.round(t.Cadence.rpm); + setMetric("cadence-value", `${actualCadence} rpm`); + updateCadenceDeviation(); + } else if (t.HeartRate) { + setMetric("hr-value", t.HeartRate.bpm); + sampleHr(t.HeartRate.bpm); + scheduleDraw(); + } }); function applyStatus(sel, category, status) { @@ -182,62 +196,315 @@ listen("connection", (event) => { applyStatus(sel, category, status); }); -// --- Таймер --- +// --- Воркаут --- const timerEl = document.querySelector("#timer"); -const timerBtn = document.querySelector("#timer-btn"); -let running = false; -let startTs = 0; -let elapsed = 0; -let raf = 0; - -function formatTime(ms) { - const sec = Math.floor(ms / 1000); - const m = Math.floor(sec / 60); - const s = sec % 60; - return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +const workoutSelect = document.querySelector("#workout-select"); +const ftpInput = document.querySelector("#ftp-input"); +const workoutMeta = document.querySelector("#workout-meta"); +const workoutName = document.querySelector("#workout-name"); +const workoutStep = document.querySelector("#workout-step"); +const workoutBar = document.querySelector("#workout-bar"); +const workoutStartBtn = document.querySelector("#workout-start"); +const workoutPauseBtn = document.querySelector("#workout-pause"); +const workoutStopBtn = document.querySelector("#workout-stop"); + +// --- Отклонение от цели --- +const powerMetric = document.querySelector("#power-metric"); +const powerTarget = document.querySelector("#power-target"); +const cadenceMetric = document.querySelector("#cadence-metric"); +const cadenceTarget = document.querySelector("#cadence-target"); + +const POWER_TOL_PCT = 0.1; +const POWER_TOL_ABS = 15; // Вт +const CADENCE_TOL_ABS = 5; // rpm + +let targetWatts = null; +let targetCadence = null; +let actualWatts = null; +let actualCadence = null; + +function powerTolerance(target) { + return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); +} + +function updateDeviation(metricEl, targetEl, actual, target, tolerance, unit) { + targetEl.textContent = target != null ? `${target} ${unit}` : "—"; + if (actual == null || target == null) { + metricEl.classList.remove("metric--off"); + return; + } + const diff = actual - target; + if (Math.abs(diff) > tolerance) { + metricEl.classList.add("metric--off"); + } else { + metricEl.classList.remove("metric--off"); + } +} + +function updatePowerDeviation() { + updateDeviation(powerMetric, powerTarget, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W"); +} + +function updateCadenceDeviation() { + updateDeviation(cadenceMetric, cadenceTarget, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm"); +} + +function clearTargets() { + targetWatts = null; + targetCadence = null; + updatePowerDeviation(); + updateCadenceDeviation(); +} + +// --- График мощности и пульса --- +const graph = document.querySelector("#graph"); + +const ZONE_COLORS = [ + "#b0bec5", // Z1 — восстановление + "#42a5f5", // Z2 — выносливость + "#66bb6a", // Z3 — темп + "#ffca28", // Z4 — порог + "#ff9800", // Z5 — VO2max + "#ef5350", // Z6 — анаэробная + "#ab47bc", // Z7 — нейромышечная +]; + +const GRAPH_WINDOW_MS = 30 * 1000; +const powerHistory = []; +const hrHistory = []; +let graphDirty = false; + +function powerZone(watts, ftp) { + const pct = (watts / ftp) * 100; + if (pct < 55) return 0; + if (pct < 76) return 1; + if (pct < 91) return 2; + if (pct < 106) return 3; + if (pct < 121) return 4; + if (pct < 151) return 5; + return 6; +} + +function currentFtp() { + const v = Number(ftpInput.value); + return Number.isFinite(v) && v > 0 ? v : 250; +} + +function sizeGraph() { + const dpr = window.devicePixelRatio || 1; + const w = graph.clientWidth; + const h = graph.clientHeight; + if (w === 0 || h === 0) return; + graph.width = Math.round(w * dpr); + graph.height = Math.round(h * dpr); +} + +function samplePower(watts) { + const now = performance.now(); + powerHistory.push({ t: now, watts }); + while (powerHistory.length && powerHistory[0].t < now - GRAPH_WINDOW_MS) powerHistory.shift(); } -function tick() { - elapsed = performance.now() - startTs; - timerEl.textContent = formatTime(elapsed); - if (running) raf = requestAnimationFrame(tick); +function sampleHr(bpm) { + const now = performance.now(); + hrHistory.push({ t: now, bpm }); + while (hrHistory.length && hrHistory[0].t < now - GRAPH_WINDOW_MS) hrHistory.shift(); } -timerBtn.addEventListener("click", async () => { - running = !running; - if (running) { - startTs = performance.now() - elapsed; - timerBtn.textContent = "стоп"; - tick(); - try { - await invoke("trainer_start"); - } catch (e) { - console.error(e); +function scheduleDraw() { + if (graphDirty) return; + graphDirty = true; + requestAnimationFrame(() => { + graphDirty = false; + drawGraph(); + }); +} + +function drawGraph() { + const ctx = graph.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = graph.clientWidth; + const h = graph.clientHeight; + if (w === 0 || h === 0) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const now = performance.now(); + const cutoff = now - GRAPH_WINDOW_MS; + const power = powerHistory.filter((s) => s.t >= cutoff); + const hr = hrHistory.filter((s) => s.t >= cutoff); + + const maxPower = Math.max(300, Math.ceil(Math.max(...power.map((s) => s.watts), 0) / 50) * 50); + const maxHr = Math.max(160, Math.ceil(Math.max(...hr.map((s) => s.bpm), 0) / 10) * 10); + + const xFor = (t) => w - ((now - t) / GRAPH_WINDOW_MS) * w; + const yPower = (v) => h - (v / maxPower) * h; + const yHr = (v) => h - (v / maxHr) * h; + + // сетка мощности + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.fillStyle = "rgba(255,255,255,0.35)"; + ctx.font = "10px Inter, system-ui, sans-serif"; + ctx.lineWidth = 1; + for (let g = 0; g <= maxPower; g += 50) { + const y = yPower(g); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + ctx.fillText(String(g), 4, y - 2); + } + + // пульс — пунктирная линия + if (hr.length >= 2) { + ctx.strokeStyle = "#ef5350"; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + hr.forEach((s, i) => { + const x = xFor(s.t); + const y = yHr(s.bpm); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.stroke(); + ctx.setLineDash([]); + } + + // мощность — заливка по зонам + if (power.length >= 2) { + const ftp = currentFtp(); + for (let i = 0; i < power.length - 1; i++) { + const a = power[i]; + const b = power[i + 1]; + const color = ZONE_COLORS[powerZone(a.watts, ftp)]; + const xa = xFor(a.t); + const ya = yPower(a.watts); + const xb = xFor(b.t); + const yb = yPower(b.watts); + + ctx.fillStyle = color; + ctx.globalAlpha = 0.3; + ctx.beginPath(); + ctx.moveTo(xa, h); + ctx.lineTo(xa, ya); + ctx.lineTo(xb, yb); + ctx.lineTo(xb, h); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xa, ya); + ctx.lineTo(xb, yb); + ctx.stroke(); } - } else { - cancelAnimationFrame(raf); - timerBtn.textContent = "старт"; - try { - await invoke("trainer_stop"); - } catch (e) { - console.error(e); + } +} + +window.addEventListener("resize", () => { + sizeGraph(); + scheduleDraw(); +}); + +let workouts = []; +let workoutPaused = false; + +function formatClock(sec) { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +async function loadWorkouts() { + try { + workouts = await invoke("list_workouts"); + workoutSelect.replaceChildren(); + const empty = el("option", null, "— выбери воркаут —"); + empty.value = ""; + workoutSelect.append(empty); + for (const w of workouts) { + const opt = el("option", null, `${w.date.slice(0, 10)} · ${w.name} · ${w.duration_min} мин`); + opt.value = w.path; + workoutSelect.append(opt); } + ftpInput.value = await invoke("get_ftp"); + } catch (e) { + console.error(e); } +} + +function selectedWorkout() { + return workouts.find((w) => w.path === workoutSelect.value); +} + +workoutSelect.addEventListener("change", () => { + const w = selectedWorkout(); + workoutName.textContent = w ? w.name : "—"; + workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; }); -// --- Целевая мощность/каденс --- -document.querySelector("#apply-btn").addEventListener("click", async () => { - const powerRaw = document.querySelector("#power-input").value.trim(); - const cadenceRaw = document.querySelector("#cadence-input").value.trim(); - if (powerRaw === "" || cadenceRaw === "") return; - const watts = Number(powerRaw); - const rpm = Number(cadenceRaw); - if (!Number.isFinite(watts) || !Number.isFinite(rpm)) return; - if (watts < 0 || rpm < 0 || !Number.isInteger(watts) || !Number.isInteger(rpm)) return; +async function setRunning(active) { + workoutStartBtn.disabled = active; + workoutPauseBtn.disabled = !active; + workoutStopBtn.disabled = !active; + workoutSelect.disabled = active; + ftpInput.disabled = active; + if (!active) { + workoutPaused = false; + workoutPauseBtn.textContent = "пауза"; + workoutStep.textContent = "—"; + workoutBar.style.width = "0%"; + timerEl.textContent = "00:00"; + clearTargets(); + } +} + +workoutStartBtn.addEventListener("click", async () => { + const w = selectedWorkout(); + const ftp = Number(ftpInput.value); + if (!w || !Number.isFinite(ftp) || ftp <= 0) return; + try { + await invoke("set_ftp", { ftp }); + await invoke("start_workout", { path: w.path }); + await setRunning(true); + } catch (e) { + console.error(e); + } +}); + +workoutPauseBtn.addEventListener("click", async () => { + workoutPaused = !workoutPaused; + workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза"; try { - await invoke("set_target_power", { watts }); - await invoke("set_target_cadence", { rpm }); + await invoke("pause_workout", { paused: workoutPaused }); } catch (e) { console.error(e); } }); + +workoutStopBtn.addEventListener("click", async () => { + try { + await invoke("stop_workout"); + } catch (e) { + console.error(e); + } + await setRunning(false); +}); + +listen("workout-progress", (event) => { + const p = event.payload; + workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; + targetWatts = p.target_watts; + targetCadence = p.target_cadence; + updatePowerDeviation(); + updateCadenceDeviation(); + timerEl.textContent = formatClock(p.elapsed_secs); + const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; + workoutBar.style.width = `${Math.min(100, pct)}%`; +}); + +listen("workout-finished", () => { + setRunning(false); +}); diff --git a/src/styles.css b/src/styles.css index ddbee3d..b65f82a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -316,22 +316,48 @@ body { } .metric__value { - font-size: var(--text-3xl); + font-size: var(--text-xl); font-weight: var(--fw-semibold); line-height: 1; font-variant-numeric: tabular-nums; } -.metric--cadence .metric__value, -.metric--hr .metric__value { - font-size: var(--text-xl); -} - .metric__unit { font-size: var(--text-sm); color: var(--c-text-muted); } +/* целевое значение — главное */ +.metric__target { + font-size: var(--text-2xl); + font-weight: var(--fw-semibold); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +/* фактическое значение — вторичное, краснеет при отклонении */ +.metric__actual { + font-size: var(--text-base); + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +.metric--off .metric__actual { + color: var(--c-danger); + font-weight: var(--fw-semibold); +} + +/* --- График мощности/пульса --- */ + +.graph canvas { + display: block; + width: 100%; + height: 140px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); +} + /* --- Управление --- */ .controls { @@ -367,3 +393,65 @@ body { .field__input:focus { border-color: var(--c-text); } + +/* --- Выбор воркаута --- */ + +.workout-name { + font-size: var(--text-sm); + font-weight: var(--fw-medium); + color: var(--c-text-muted); + text-align: right; +} + +.workout-picker { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-4); + align-items: end; +} + +.workout-meta { + grid-column: 1 / -1; + font-size: var(--text-sm); + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +/* --- Прогресс воркаута --- */ + +.workout-progress { + display: grid; + gap: var(--space-2); +} + +.workout-progress__head { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: var(--text-sm); +} + +.workout-progress__step { + font-weight: var(--fw-medium); +} + +.workout-progress__track { + height: 6px; + background: var(--c-surface-2); + border-radius: var(--radius-full); + overflow: hidden; +} + +.workout-progress__bar { + height: 100%; + width: 0%; + background: var(--c-accent); + border-radius: var(--radius-full); + transition: width var(--duration-fast) var(--ease); +} + +/* --- Кнопки воркаута --- */ + +.controls--workout { + grid-template-columns: 1fr 1fr 1fr; +} diff --git a/src/tokens.css b/src/tokens.css index e4e66ef..6deebc6 100644 --- a/src/tokens.css +++ b/src/tokens.css @@ -25,6 +25,7 @@ --c-text-faint: hsl(0 0% 40%); /* едва заметный */ --c-accent: hsl(0 0% 93%); /* акцент (инверсия) */ --c-accent-contrast: hsl(0 0% 4%); + --c-danger: hsl(0 72% 58%); /* отклонение от цели / ошибка */ /* --- Отступы (база 4px) --- */ --space-1: 0.25rem; /* 4px */