feat: workout execution engine (phase 4)

pull/4/head
lambda 6 days ago
parent 981747fc50
commit b3f42d1f33
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -7,6 +7,7 @@ mod commands;
mod workout; mod workout;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration; use std::time::Duration;
@ -19,7 +20,7 @@ use crate::ble::{BleSource, MockSource, SensorSource};
use crate::commands::Command; use crate::commands::Command;
use crate::device::Device; use crate::device::Device;
use crate::device_repo::{repo_from_devices, Repo}; use crate::device_repo::{repo_from_devices, Repo};
use crate::state::{State, TrainerState}; 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;
@ -53,6 +54,21 @@ struct ConnectionEvent {
status: &'static str, // "reconnecting" | "reconnected" | "gave_up" status: &'static str, // "reconnecting" | "reconnected" | "gave_up"
} }
#[derive(Clone, Serialize)]
struct WorkoutProgressEvent {
step_index: usize,
step_name: String,
target_watts: Option<i16>,
target_cadence: Option<u16>,
elapsed_secs: u64,
total_secs: u64,
}
#[derive(Clone, Serialize)]
struct WorkoutFinishedEvent {
ok: bool,
}
#[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())?; 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::<State>().ftp.lock().unwrap() *app.state::<State>().ftp.lock().unwrap()
} }
/// Вычисляет и отправляет целевые мощность/каденс сегмента, если изменились.
async fn apply_target(
source: &Arc<dyn SensorSource>,
device: &Device,
seg: &workout::Segment,
elapsed_secs: f64,
ftp: u16,
last_watts: Option<i16>,
last_cadence: Option<u16>,
) -> (Option<i16>, Option<u16>) {
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<dyn SensorSource>,
device: Device,
segments: Vec<workout::Segment>,
ftp: u16,
paused: Arc<AtomicBool>,
) {
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<i16> = None;
let mut last_cadence: Option<u16> = 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::<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::<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::<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. /// 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).
@ -403,6 +580,7 @@ pub fn run() {
streams: Mutex::new(std::collections::HashMap::new()), streams: Mutex::new(std::collections::HashMap::new()),
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),
}); });
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) })
@ -417,7 +595,10 @@ pub fn run() {
list_workouts, list_workouts,
get_workout, get_workout,
set_ftp, set_ftp,
get_ftp get_ftp,
start_workout,
stop_workout,
pause_workout
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
@ -17,6 +18,12 @@ pub struct TrainerState {
pub running: bool, pub running: bool,
} }
/// Ручка запущенного воркаута: задача + флаг паузы.
pub struct WorkoutHandle {
pub task: JoinHandle<()>,
pub paused: Arc<AtomicBool>,
}
pub struct State { pub struct State {
pub source: Arc<dyn SensorSource>, pub source: Arc<dyn SensorSource>,
/// Все найденные при скане устройства (для поиска по id при подключении). /// Все найденные при скане устройства (для поиска по id при подключении).
@ -29,4 +36,6 @@ pub struct State {
pub trainer_state: Mutex<TrainerState>, pub trainer_state: Mutex<TrainerState>,
/// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты.
pub ftp: Mutex<u16>, pub ftp: Mutex<u16>,
/// Запущенный воркаут (если идёт).
pub workout: Mutex<Option<WorkoutHandle>>,
} }

@ -198,6 +198,28 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 {
segments.iter().map(|s| s.duration_secs as u64).sum() 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. /// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd.
pub fn workouts_dir() -> PathBuf { pub fn workouts_dir() -> PathBuf {
std::env::var("RIDEIN_WORKOUTS_DIR") std::env::var("RIDEIN_WORKOUTS_DIR")
@ -391,4 +413,30 @@ mod tests {
assert!(read_workout("../etc/passwd").is_err()); assert!(read_workout("../etc/passwd").is_err());
assert!(read_workout("a/b.json").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);
}
} }

Loading…
Cancel
Save