|
|
|
|
@ -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<i16>,
|
|
|
|
|
target_cadence: Option<u16>,
|
|
|
|
|
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::<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.
|
|
|
|
|
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<dyn std::error::Error>>(())
|
|
|
|
|
})
|
|
|
|
|
@ -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");
|
|
|
|
|
|