|
|
|
|
@ -279,44 +279,40 @@ async fn apply_power(
|
|
|
|
|
watts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Шлёт событие прогресса воркаута на фронт.
|
|
|
|
|
fn emit_progress(
|
|
|
|
|
app: &AppHandle,
|
|
|
|
|
/// Собирает событие прогресса воркаута.
|
|
|
|
|
fn progress_event(
|
|
|
|
|
idx: usize,
|
|
|
|
|
seg: &workout::Segment,
|
|
|
|
|
watts: Option<i16>,
|
|
|
|
|
elapsed_total: u64,
|
|
|
|
|
total: u64,
|
|
|
|
|
) {
|
|
|
|
|
let _ = app.emit(
|
|
|
|
|
"workout-progress",
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
) -> 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце.
|
|
|
|
|
async fn run_workout(
|
|
|
|
|
app: AppHandle,
|
|
|
|
|
source: Arc<dyn SensorSource>,
|
|
|
|
|
device: Device,
|
|
|
|
|
segments: Vec<workout::Segment>,
|
|
|
|
|
/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource.
|
|
|
|
|
async fn run_workout_engine(
|
|
|
|
|
source: &Arc<dyn SensorSource>,
|
|
|
|
|
device: &Device,
|
|
|
|
|
segments: &[workout::Segment],
|
|
|
|
|
ftp: u16,
|
|
|
|
|
paused: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
if source.request_control(&device).await.is_err() {
|
|
|
|
|
let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false });
|
|
|
|
|
return;
|
|
|
|
|
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 _ = source.command(device, Command::FtmsStart).await;
|
|
|
|
|
|
|
|
|
|
let total = workout::total_duration_secs(&segments);
|
|
|
|
|
let total = workout::total_duration_secs(segments);
|
|
|
|
|
let mut elapsed_total: u64 = 0;
|
|
|
|
|
|
|
|
|
|
for (idx, seg) in segments.iter().enumerate() {
|
|
|
|
|
@ -324,17 +320,17 @@ async fn run_workout(
|
|
|
|
|
|
|
|
|
|
// каденс — константа сегмента, шлём один раз (в 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 _ = source.command(device, Command::FtmsSetTargetCadence(half)).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut last_watts: Option<i16> = None;
|
|
|
|
|
let mut tick = time::interval(Duration::from_secs(1));
|
|
|
|
|
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;
|
|
|
|
|
let w = apply_power(source, device, seg, 0.0, ftp, last_watts).await;
|
|
|
|
|
last_watts = w;
|
|
|
|
|
emit_progress(&app, idx, seg, w, elapsed_total, total);
|
|
|
|
|
on_progress(progress_event(idx, seg, w, elapsed_total, total));
|
|
|
|
|
|
|
|
|
|
let mut elapsed_in_seg: u64 = 0;
|
|
|
|
|
while elapsed_in_seg < dur {
|
|
|
|
|
@ -343,15 +339,40 @@ async fn run_workout(
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
elapsed_in_seg += 1;
|
|
|
|
|
let w = apply_power(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts).await;
|
|
|
|
|
let w = apply_power(source, device, seg, elapsed_in_seg as f64, ftp, last_watts).await;
|
|
|
|
|
last_watts = w;
|
|
|
|
|
elapsed_total += 1;
|
|
|
|
|
emit_progress(&app, idx, seg, w, elapsed_total, total);
|
|
|
|
|
on_progress(progress_event(idx, seg, w, elapsed_total, total));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = source.command(&device, Command::FtmsStop).await;
|
|
|
|
|
let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true });
|
|
|
|
|
let _ = source.command(device, Command::FtmsStop).await;
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение.
|
|
|
|
|
async fn run_workout(
|
|
|
|
|
app: AppHandle,
|
|
|
|
|
source: Arc<dyn SensorSource>,
|
|
|
|
|
device: Device,
|
|
|
|
|
segments: Vec<workout::Segment>,
|
|
|
|
|
ftp: u16,
|
|
|
|
|
paused: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Запустить воркаут (по имени файла из каталога воркаутов).
|
|
|
|
|
@ -638,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<dyn SensorSource> = 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,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|