From b3f42d1f33404bc10da1b92cb25f127a850b000f Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:05:12 +0300 Subject: [PATCH] feat: workout execution engine (phase 4) --- src-tauri/src/lib.rs | 185 ++++++++++++++++++++++++++++++++++++++- src-tauri/src/state.rs | 9 ++ src-tauri/src/workout.rs | 48 ++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51f67e7..55da7b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod commands; mod workout; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -19,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; @@ -53,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())?; @@ -245,6 +261,167 @@ fn get_ftp(app: AppHandle) -> u16 { *app.state::().ftp.lock().unwrap() } +/// Вычисляет и отправляет целевые мощность/каденс сегмента, если изменились. +async fn apply_target( + source: &Arc, + device: &Device, + seg: &workout::Segment, + elapsed_secs: f64, + ftp: u16, + last_watts: Option, + last_cadence: Option, +) -> (Option, Option) { + let watts = seg.power.as_ref().map(|p| { + workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp) + }); + let cadence = seg.cadence_rpm; + + if watts != last_watts { + if let Some(w) = watts { + let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; + } + } + if cadence != last_cadence { + if let Some(rpm) = cadence { + if let Some(half) = rpm.checked_mul(2) { + let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; + } + } + } + (watts, cadence) +} + +/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. +async fn run_workout( + app: AppHandle, + source: Arc, + device: Device, + segments: Vec, + ftp: u16, + paused: Arc, +) { + if source.request_control(&device).await.is_err() { + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false }); + return; + } + 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; + let mut last_watts: Option = None; + let mut last_cadence: Option = None; + let mut tick = time::interval(Duration::from_secs(1)); + tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); + + // t = 0 + let (w, c) = apply_target(&source, &device, seg, 0.0, ftp, last_watts, last_cadence).await; + last_watts = w; + last_cadence = c; + let _ = app.emit( + "workout-progress", + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: w, + target_cadence: c, + elapsed_secs: elapsed_total, + total_secs: 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, c) = + apply_target(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts, last_cadence) + .await; + last_watts = w; + last_cadence = c; + elapsed_total += 1; + let _ = app.emit( + "workout-progress", + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: w, + target_cadence: c, + elapsed_secs: elapsed_total, + total_secs: total, + }, + ); + } + } + + let _ = source.command(&device, Command::FtmsStop).await; + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); +} + +/// Запустить воркаут (по имени файла из каталога воркаутов). +#[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). @@ -403,6 +580,7 @@ pub fn run() { 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>(()) }) @@ -417,7 +595,10 @@ pub fn run() { list_workouts, get_workout, set_ftp, - get_ftp + get_ftp, + start_workout, + stop_workout, + pause_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 02f098c..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; @@ -17,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 при подключении). @@ -29,4 +36,6 @@ pub struct State { 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 index 2055f62..2549362 100644 --- a/src-tauri/src/workout.rs +++ b/src-tauri/src/workout.rs @@ -198,6 +198,28 @@ 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 — середина. +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") @@ -391,4 +413,30 @@ mod tests { 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); + } }