diff --git a/src-tauri/src/ble/source.rs b/src-tauri/src/ble/source.rs index de15cc4..0903f82 100644 --- a/src-tauri/src/ble/source.rs +++ b/src-tauri/src/ble/source.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::ble::BleError; use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult}; use crate::device::Device; -use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, CscAccumulator, Telemetry}; +use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, parse_power_crank, CscAccumulator, Telemetry}; #[async_trait] pub(crate) trait SensorSource: Send + Sync { @@ -231,9 +231,11 @@ impl SensorSource for BleSource { let mut notifications = notifications; let mut acc = CscAccumulator::new(); let mut cancel_rx = cancel_rx; - // Первые 30 уведомлений логируем с сырыми байтами — чтобы видеть, что - // реально шлёт устройство (какие флаги/поля в 0x2AD2 и т.п.). - let mut logged = 0u32; + // Логируем сырые байты для диагностики. 0x2AD2 (Indoor Bike Data — там + // каденс/скорость/мощность) логируем дольше (до 500), остальные — до 30, + // чтобы «пустые» уведомления до начала вращения не съели лимит. + let mut bike_logged = 0u32; + let mut other_logged = 0u32; loop { tokio::select! { n = notifications.next() => { @@ -242,8 +244,11 @@ impl SensorSource for BleSource { let uuid = n.uuid; let raw = hex::encode(&n.value); let parsed = parse_notification(n, &mut acc, &control); - if logged < 30 { - logged += 1; + let is_bike = uuid.to_string().starts_with("00002ad2"); + let counter = if is_bike { &mut bike_logged } else { &mut other_logged }; + let cap = if is_bike { 500u32 } else { 30u32 }; + if *counter < cap { + *counter += 1; crate::logging::log("telemetry", &format!("uuid={uuid} raw={raw} parsed={parsed:?}")); } for t in parsed { @@ -371,7 +376,14 @@ fn parse_notification( .into_iter() .collect() } else if n.uuid == POWER_MEASUREMENT { - parse_power(&n.value).into_iter().collect() + // мощность + каденс из crank revolution data (пауэрметр Quarq и т.п.) + let mut out: Vec = parse_power(&n.value).into_iter().collect(); + if let Some(raw) = parse_power_crank(&n.value) { + if let Some(rpm) = acc.push(raw) { + out.push(Telemetry::Cadence { rpm }); + } + } + out } else if n.uuid == INDOOR_BIKE_DATA { parse_indoor_bike_data(&n.value) } else if n.uuid == CONTROL_POINT { diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index 11e5d5c..7f39320 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -23,15 +23,17 @@ pub enum DeviceType { HrSensor, CadSensor, Trainer, + PowerMeter, } impl DeviceType { - /// Категория на фронте ("hr"/"cadence"/"trainer") для маршрутизации события. + /// Категория на фронте ("hr"/"cadence"/"trainer"/"power") для маршрутизации события. pub fn category(&self) -> &'static str { match self { DeviceType::HrSensor => "hr", DeviceType::CadSensor => "cadence", DeviceType::Trainer => "trainer", + DeviceType::PowerMeter => "power", } } } @@ -43,9 +45,13 @@ pub fn get_device_types(device: &Device) -> Vec { let csc_services = ["1816"]; let csc_characteristics = ["2a5b"]; - // trainer / cycling power / fitness machine / indoor bike - let trainer_services = ["1818", "1826"]; - let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement + // smart trainer / FTMS (indoor bike, fitness machine) + let trainer_services = ["1826"]; + let trainer_characteristics = ["2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics + + // power meter (Cycling Power без FTMS) — Quarq и т.п. + let power_services = ["1818"]; + let power_characteristics = ["2a63"]; // 2A63 = Cycling Power Measurement // любые 2ADx характеристики (indoor bike related) - учитывать как признак каденса доступного в тренажёре let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. @@ -84,10 +90,13 @@ pub fn get_device_types(device: &Device) -> Vec { out.push(DeviceType::CadSensor); } - // Trainer/Cycling power/Indoor bike + // Trainer (FTMS). Приоритет: станок рекламирует и 0x1826, и 0x1818 — он всё равно станок. let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); if is_trainer { out.push(DeviceType::Trainer); + } else if contains_any(&power_services) || contains_any(&power_characteristics) { + // Power meter (Cycling Power без FTMS) — Quarq и т.п. + out.push(DeviceType::PowerMeter); } // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence - @@ -174,13 +183,13 @@ mod tests { } #[test] - fn test_trainer_by_power_service() { - assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); + fn test_power_meter_by_service() { + assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::PowerMeter]); } #[test] - fn test_trainer_by_power_characteristic() { - assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); + fn test_power_meter_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::PowerMeter]); } #[test] @@ -239,5 +248,6 @@ mod tests { assert_eq!(DeviceType::HrSensor.category(), "hr"); assert_eq!(DeviceType::CadSensor.category(), "cadence"); assert_eq!(DeviceType::Trainer.category(), "trainer"); + assert_eq!(DeviceType::PowerMeter.category(), "power"); } } diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 31fc824..fc5e661 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -9,6 +9,8 @@ pub struct Repo { cad_sensors: Vec, #[serde(default)] trainers: Vec, + #[serde(default)] + power_meters: Vec, } impl Repo { @@ -17,6 +19,7 @@ impl Repo { hr_sensors: Vec::new(), cad_sensors: Vec::new(), trainers: Vec::new(), + power_meters: Vec::new(), } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5568165..d5adda8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -274,6 +274,13 @@ fn get_workout(path: String) -> Result { workout::read_workout(&path).map_err(|e| e.to_string()) } +/// Развёрнутые линейные сегменты воркаута (для отрисовки профиля). +#[tauri::command] +fn get_segments(path: String) -> Result, String> { + let w = workout::read_workout(&path).map_err(|e| e.to_string())?; + Ok(workout::flatten(&w.steps)) +} + /// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. #[tauri::command] fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { @@ -344,10 +351,9 @@ async fn run_workout_engine( tick_duration: Duration, mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, ) -> bool { - if let Err(e) = source.request_control(device).await { - crate::logging::log("workout", &format!("request_control failed: {e}")); - return false; - } + // Контроль станка уже запрошен при подключении (run_watchdog). Повторный + // request_control здесь срывает тренировку на Magene (отказ/таймаут при уже + // выданном контроле) — воркаут мгновенно «останавливается» с ok:false. send_command(source, device, Command::FtmsStart).await; let total = workout::total_duration_secs(segments); @@ -678,6 +684,7 @@ pub fn run() { trainer_stop, list_workouts, get_workout, + get_segments, set_ftp, get_ftp, start_workout, diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs index 4492c98..c2a8dbe 100644 --- a/src-tauri/src/telemetry.rs +++ b/src-tauri/src/telemetry.rs @@ -35,6 +35,35 @@ pub(crate) fn parse_power(bytes: &[u8]) -> Option { Some(Telemetry::Power { watts }) } +/// Cycling Power Measurement (0x2A63): crank revolution data (кумулятивные обороты +/// шатуна + время) для вычисления каденса. Поле опциональное (флаг bit 5 = 0x0020), +/// после мгновенной мощности и предшествующих опциональных полей. +pub(crate) fn parse_power_crank(bytes: &[u8]) -> Option { + if bytes.len() < 4 { + return None; + } + let flags = u16::from_le_bytes([bytes[0], bytes[1]]); + if flags & 0x0020 == 0 { + return None; // нет crank revolution data + } + let mut idx = 4; // flags(2) + instantaneous power(2) + if flags & 0x0001 != 0 { + idx += 1; // pedal power balance (uint8) + } + if flags & 0x0004 != 0 { + idx += 2; // accumulated torque (uint16) + } + if flags & 0x0010 != 0 { + idx += 6; // wheel revolution data (uint32 + uint16) + } + if bytes.len() < idx + 4 { + return None; + } + let crank_revs = u16::from_le_bytes([bytes[idx], bytes[idx + 1]]); + let crank_time = u16::from_le_bytes([bytes[idx + 2], bytes[idx + 3]]); + Some(CscRaw { crank_revs, crank_time }) +} + /// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags. /// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5). pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec { @@ -44,15 +73,10 @@ pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec { let flags = u16::from_le_bytes([bytes[0], bytes[1]]); let mut out = Vec::new(); - // Magene T110 (и, возможно, другие Magene): шлёт НЕстандартный Indoor Bike Data — - // флаги всегда 0x0040, а раскладка фиксированная: speed(2-3), power(4-5), - // cadence(6-7, 0.1 rpm). Стандартный разбор по флагам тут не срабатывает. - // Единица 0.1 rpm (не стандартные 0.5): проверено по соотношению скорость/каденс. - if flags == 0x0040 && bytes.len() >= 8 { - let raw_cad = u16::from_le_bytes([bytes[6], bytes[7]]); - out.push(Telemetry::Cadence { rpm: raw_cad as f32 / 10.0 }); - return out; - } + // Magene T110: НЕстандартный Indoor Bike Data — флаги всегда 0x0040, раскладка + // speed(2-3, 0.01 km/h), power(4-5, W), flywheel speed(6-7, rpm). Каденса в 0x2AD2 + // НЕТ: станок не отдаёт обороты шатунов по стандартному BLE. Поэтому здесь ничего + // не извлекаем — стандартный разбор ниже вернёт пусто (флаги 0x0040 = avg power). // Byte offset of instantaneous cadence (after the 2-byte flags). let cadence_off = 2 @@ -175,6 +199,21 @@ mod tests { assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None); } + #[test] + fn test_parse_power_crank_extracts_crank_data() { + // flags=0x0021 (bit0 pedal balance + bit5 crank), power=200, balance=50, + // crank revs=10, crank time=2048 + let bytes = [0x21, 0x00, 0xC8, 0x00, 0x32, 0x0A, 0x00, 0x00, 0x08]; + assert_eq!(parse_power_crank(&bytes), Some(CscRaw { crank_revs: 10, crank_time: 2048 })); + } + + #[test] + fn test_parse_power_crank_no_crank_data() { + // flags=0x0001 (только pedal balance) — crank revolution data нет + let bytes = [0x01, 0x00, 0xC8, 0x00, 0x32]; + assert_eq!(parse_power_crank(&bytes), None); + } + #[test] fn test_indoor_bike_cadence_and_power() { // flags: bit 3 (cadence) + bit 5 (power) = 0x0028 @@ -196,14 +235,11 @@ mod tests { } #[test] - fn test_magene_indoor_bike_cadence() { - // Реальный пакет Magene T110 (ручное вращение): flags=0x0040, speed=0x02f2, - // power=8 (4-5), cadence=0x01fd (6-7) = 509 * 0.1 = 50.9 rpm. + fn test_magene_indoor_bike_has_no_cadence() { + // Magene T110 0x2AD2: flags=0x0040, speed(2-3), power(4-5), flywheel(6-7). + // Каденса нет — стандартный разбор по флагам (0x0040 = avg power) пуст. let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03]; - assert_eq!( - parse_indoor_bike_data(&bytes), - vec![Telemetry::Cadence { rpm: 50.9 }] - ); + assert!(parse_indoor_bike_data(&bytes).is_empty()); } #[test] diff --git a/src/index.html b/src/index.html index 9d72778..93106aa 100644 --- a/src/index.html +++ b/src/index.html @@ -25,12 +25,21 @@

каденс

    +
    +

    мощность

    +
      +

      станок

        + + @@ -53,6 +62,8 @@

        + +
        мощность diff --git a/src/main.js b/src/main.js index 7704fc9..0b38efe 100644 --- a/src/main.js +++ b/src/main.js @@ -4,11 +4,13 @@ const { invoke } = window.__TAURI__.core; const connectScreen = document.querySelector("#connect"); const workoutScreen = document.querySelector("#workout"); const startBtn = document.querySelector("#start-btn"); +const powerSourceCheck = document.querySelector("#power-source-check"); const groups = { hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" }, cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" }, trainers: { list: document.querySelector("#trainer-list"), category: "trainer" }, + power_meters: { list: document.querySelector("#power-list"), category: "power" }, }; const deviceCards = new Map(); // device_id -> li @@ -108,7 +110,7 @@ async function connect(deviceId, btn, category, card) { resetSelected(category); btn.textContent = "✓"; btn.classList.add("device__connect--done"); - selected[category] = { btn, card }; + selected[category] = { btn, card, deviceId }; if (pendingStatus[category]) { applyStatus(selected[category], category, pendingStatus[category]); delete pendingStatus[category]; @@ -139,6 +141,7 @@ once("devices-loaded", (event) => { renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.trainers, repo.trainers ?? []); + renderGroup(groups.power_meters, repo.power_meters ?? []); }); // Непрерывный скан: новые датчики добавляются в список по мере появления. @@ -164,12 +167,19 @@ listen("battery", (event) => { listen("telemetry", (event) => { const t = event.payload.telemetry; + const devId = event.payload.device_id; if (t.Power) { - actualWatts = t.Power.watts; - setMetric("power-value", `${actualWatts} W`); - updatePowerDeviation(); - samplePower(actualWatts); - scheduleDraw(); + // источник мощности: пауэрметр (чекбокс + он подключён) или станок + const pmId = selected.power?.deviceId; + const trainerId = selected.trainer?.deviceId; + const expectedId = powerSourceCheck.checked && pmId ? pmId : trainerId; + if (!expectedId || devId === expectedId) { + 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`); @@ -217,6 +227,7 @@ 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 workoutProfile = document.querySelector("#workout-profile"); const workoutStartBtn = document.querySelector("#workout-start"); const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutStopBtn = document.querySelector("#workout-stop"); @@ -288,8 +299,7 @@ const powerHistory = []; const hrHistory = []; let graphDirty = false; -function powerZone(watts, ftp) { - const pct = (watts / ftp) * 100; +function percentZone(pct) { if (pct < 55) return 0; if (pct < 76) return 1; if (pct < 91) return 2; @@ -299,6 +309,10 @@ function powerZone(watts, ftp) { return 6; } +function powerZone(watts, ftp) { + return percentZone((watts / ftp) * 100); +} + function currentFtp() { const v = Number(ftpInput.value); return Number.isFinite(v) && v > 0 ? v : 250; @@ -429,6 +443,60 @@ let workoutStartMs = 0; let workoutSaveName = "workout"; let lastSampleSecs = -1; +// --- Профиль воркаута (сегменты) --- + +function segmentPowerPct(seg) { + const p = seg.power; + if (!p) return null; + if (p.type === "fixed") return p.percent; + if (p.type === "ramp") return (p.from + p.to) / 2; + if (p.type === "range") return (p.low + p.high) / 2; + return null; +} + +function segmentColor(seg) { + const pct = segmentPowerPct(seg); + return pct == null ? "var(--c-surface-2)" : ZONE_COLORS[percentZone(pct)]; +} + +function segmentLabel(seg) { + const name = seg.text || "без имени"; + const dur = formatClock(seg.duration_secs); + let power = ""; + if (seg.power) { + const p = seg.power; + if (p.type === "fixed") power = `${p.percent}%`; + else if (p.type === "ramp") power = `${p.from}→${p.to}%`; + else if (p.type === "range") power = `${p.low}–${p.high}%`; + } + const cad = seg.cadence_rpm ? ` · ${seg.cadence_rpm} rpm` : ""; + return `${name} · ${dur}${power ? ` · ${power}` : ""}${cad}`; +} + +async function renderWorkoutProfile(path) { + try { + const segments = await invoke("get_segments", { path }); + workoutProfile.replaceChildren(); + if (!segments.length) return; + for (const seg of segments) { + const block = el("div", "workout-profile__seg"); + block.style.flexGrow = String(Math.max(seg.duration_secs, 1)); + block.style.background = segmentColor(seg); + block.title = segmentLabel(seg); + workoutProfile.append(block); + } + } catch (e) { + console.error(e); + } +} + +function highlightSegment(idx) { + const blocks = workoutProfile.children; + for (let i = 0; i < blocks.length; i++) { + blocks[i].classList.toggle("workout-profile__seg--current", i === idx); + } +} + function formatClock(sec) { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); @@ -461,6 +529,11 @@ workoutSelect.addEventListener("change", () => { const w = selectedWorkout(); workoutName.textContent = w ? w.name : "—"; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; + if (w) { + renderWorkoutProfile(w.path); + } else { + workoutProfile.replaceChildren(); + } }); async function setRunning(active) { @@ -476,6 +549,7 @@ async function setRunning(active) { workoutBar.style.width = "0%"; timerEl.textContent = "00:00"; clearTargets(); + highlightSegment(-1); } } @@ -521,6 +595,7 @@ workoutStopBtn.addEventListener("click", async () => { listen("workout-progress", (event) => { const p = event.payload; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; + highlightSegment(p.step_index); targetWatts = p.target_watts; targetCadence = p.target_cadence; updatePowerDeviation(); diff --git a/src/styles.css b/src/styles.css index b65f82a..94d31ae 100644 --- a/src/styles.css +++ b/src/styles.css @@ -53,7 +53,7 @@ body { /* --- Подключение датчиков --- */ #connect { - grid-template-rows: auto 1fr auto; + grid-template-rows: auto 1fr auto auto; gap: var(--space-4); padding: var(--space-4); width: 100%; @@ -257,6 +257,20 @@ body { align-self: end; } +.power-source { + display: flex; + align-items: center; + gap: var(--space-2); + justify-self: center; + font-size: var(--text-sm); + color: var(--c-text-muted); + cursor: pointer; +} + +.power-source input { + accent-color: var(--c-accent); +} + /* ============================================================ Экран упражнения ============================================================ */ @@ -455,3 +469,27 @@ body { .controls--workout { grid-template-columns: 1fr 1fr 1fr; } + +/* --- Профиль воркаута (сегменты) --- */ + +.workout-profile { + display: flex; + gap: 2px; + height: 28px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.workout-profile__seg { + flex-basis: 0; + min-width: 2px; + opacity: 0.75; + transition: opacity var(--duration-fast) var(--ease); +} + +.workout-profile__seg--current { + opacity: 1; + box-shadow: inset 0 0 0 2px var(--c-text); +}